[
  {
    "path": ".devcontainer/Dockerfile",
    "content": "# syntax=docker/dockerfile:1\nARG debian_version=slim-bookworm\nARG rust_version=1.85.0\nFROM rust:${rust_version}-${debian_version}\n\nARG DEBIAN_FRONTEND=noninteractive\nENV CARGO_REGISTRIES_CRATES_IO_PROTOCOL=\"sparse\"\nENV RUST_BACKTRACE=1\nENV RUSTFLAGS=\"-D warnings\"\n\nRUN apt-get update && \\\n    apt-get install -y --no-install-recommends \\\n        git \\\n        nano\\\n        openssh-server  \\\n        # for rust-analyzer vscode plugin\n        pkg-config \\\n        # developer dependencies\n        libunwind-dev \\\n        libpulse-dev \\\n        portaudio19-dev \\\n        libasound2-dev \\\n        libsdl2-dev \\\n        gstreamer1.0-dev \\\n        libgstreamer-plugins-base1.0-dev \\\n        libavahi-compat-libdnssd-dev && \\\n    rm -rf /var/lib/apt/lists/*\n\nRUN rustup component add rustfmt && \\\n    rustup component add clippy && \\\n    cargo install cargo-hack\n"
  },
  {
    "path": ".devcontainer/Dockerfile.alpine",
    "content": "# syntax=docker/dockerfile:1\nARG alpine_version=alpine3.20\nARG rust_version=1.85.0\nFROM rust:${rust_version}-${alpine_version}\n\nENV CARGO_REGISTRIES_CRATES_IO_PROTOCOL=\"sparse\"\nENV RUST_BACKTRACE=1\nENV RUSTFLAGS=\"-D warnings -C target-feature=-crt-static\"\n\nRUN apk add --no-cache \\\n        git \\\n        nano\\\n        openssh-server  \\\n        # for rust-analyzer vscode plugin\n        pkgconf \\\n        musl-dev \\\n        # developer dependencies\n        openssl-dev \\\n        libunwind-dev \\\n        pulseaudio-dev \\\n        portaudio-dev \\\n        alsa-lib-dev \\\n        sdl2-dev \\\n        gstreamer-dev \\\n        gst-plugins-base-dev \\\n        jack-dev \\\n        avahi-dev && \\\n        rm -rf /lib/apk/db/*\n\nRUN rustup component add rustfmt && \\\n    rustup component add clippy && \\\n    cargo install cargo-hack\n"
  },
  {
    "path": ".devcontainer/devcontainer.json",
    "content": "{\n  \"name\": \"Librespot Devcontainer\",\n  \"dockerFile\": \"Dockerfile.alpine\",\n  \"_postCreateCommand_comment\": \"Uncomment 'postCreateCommand' to run commands after the container is created.\",\n  \"_postCreateCommand\": \"\",\n  \"customizations\": {\n    \"_comment\": \"Configure properties specific to VS Code.\",\n    \"vscode\": {\n      \"settings\": {\n        \"dev.containers.copyGitConfig\": true\n      },\n      \"extensions\": [\"eamodio.gitlens\", \"github.vscode-github-actions\", \"rust-lang.rust-analyzer\"]\n    }\n  },\n  \"containerEnv\": {\n    \"GIT_EDITOR\": \"nano\"\n  },\n  \"_remoteUser_comment\": \"Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root\",\n  \"_remoteUser\": \"root\"\n}\n"
  },
  {
    "path": ".dockerignore",
    "content": "target\ncache\nprotocol/target\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.md",
    "content": "---\nname: Bug report\nabout: Create a report to help us improve\ntitle: ''\nlabels: bug\nassignees: ''\n\n---\n\n### Look for similar bugs\nPlease check if there's [already an issue](https://github.com/librespot-org/librespot/issues) for your problem, and you're running at least the [latest release](https://github.com/librespot-org/librespot/releases/latest).\nIf 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/)\nwill suffice. \n\n### Description\nA clear and concise description of what the problem is.\n\n### Version\nWhat version(s) of *librespot* does this problem exist in?\n\n### How to reproduce\nSteps to reproduce the behavior in *librespot* e.g.\n1. Launch `librespot` with '...'\n2. Connect with '...'\n3. In the client click on '...'\n4. See some error/problem\n\n### Log\n* A *full* **debug** log so we may trace your problem (launch `librespot` with `--verbose`).\n* Ideally contains your above steps to reproduce.\n* 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/).\n* Redact data you consider personal but do not remove/trim anything else.\n\n### Host (what you are running `librespot` on):\n- OS: [e.g. Linux]\n- Platform: [e.g. RPi 3B+]\n\n### Additional context\nAdd 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.\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.md",
    "content": "---\nname: Feature request\nabout: Suggest an idea for this project\ntitle: ''\nlabels: enhancement\nassignees: ''\n\n---\n\n**Is your feature request related to a problem? Please describe.**\nA clear and concise description of what the problem is. Ex. I'm always frustrated when [...]\n\n**Describe the solution you'd like**\nA clear and concise description of what you want to happen.\n\n**Describe alternatives you've considered**\nA clear and concise description of any alternative solutions or features you've considered.\n\n**Additional context**\nAdd any other context or screenshots about the feature request here.\n"
  },
  {
    "path": ".github/dependabot.yml",
    "content": "version: 2\n\nupdates:\n  - package-ecosystem: github-actions\n    schedule:\n      interval: weekly\n      day: saturday\n      time: \"10:00\"\n    groups:\n      gha-deps:\n        patterns:\n          - \"*\"\n    target-branch: dev\n    directories:\n      - \"/.github/workflows\"\n"
  },
  {
    "path": ".github/example/prepare-release.event",
    "content": "{\n  \"action\": \"workflow_dispatch\",\n  \"inputs\": {\n      \"versionBump\": \"minor\"\n  }\n}"
  },
  {
    "path": ".github/scripts/bump-versions.sh",
    "content": "#!/usr/bin/env bash\n\n# $fragment: see possible options https://github.com/christian-draeger/increment-semantic-version/tree/1.2.3?tab=readme-ov-file#version-fragment\nif [ \"$fragment\" = \"\" ]; then\n  fragment=$1\nfi\n\nallowed_crates=\"protocol oauth core discovery audio metadata playback connect\"\n\nif [ \"$fragment\" = \"patch\" ]; then\n  last_tag=$(git describe --tags --abbrev=0)\n  awk_crates=$(echo \"$allowed_crates\" | tr ' ' '|')\n  diff_crates=$(git diff $last_tag... --stat --name-only \\\n    | awk '/\\.(rs|proto)$/{print}' \\\n    | awk \"/($awk_crates)\\//{print}\" \\\n    | cut -d '/' -f 1 \\\n    | uniq \\\n    | tr \\\\n '\\ ' \\\n    | xargs )\n  echo \"upgrading the following crates: [$diff_crates]\"\nelse\n  diff_crates=$allowed_crates\n  echo \"upgrading all crates for consistency\"\nfi\n\n# append bin so that the version of the binary is also bumped\ndiff_crates=\"$diff_crates bin\"\n\n# required by script as it's usually a github action\nexport GITHUB_OUTPUT=\"version.txt\"\n# https://github.com/christian-draeger/increment-semantic-version/tree/1.2.3\nincrement_semver=$(curl https://raw.githubusercontent.com/christian-draeger/increment-semantic-version/refs/tags/1.2.3/entrypoint.sh)\n\nfor diff_crate in $diff_crates ; do\n  if [ \"$diff_crate\" = \"bin\" ]; then\n    toml=\"./Cargo.toml\"\n  else\n    toml=\"./$diff_crate/Cargo.toml\"\n  fi\n\n  from=\"$(cat $toml | awk \"/version/{print; exit}\" | cut -d\\\" -f 2)\"\n\n  # execute script inline, extract result and remove output file\n  echo \"$increment_semver\" | bash /dev/stdin $from $fragment\n  to=$(cat $GITHUB_OUTPUT | cut -d= -f 2)\n  rm $GITHUB_OUTPUT\n\n  echo \"upgrading [librespot-$diff_crate] from [$from] to [$to]\"\n\n  # replace version in associated diff_crate toml\n  sed -i \"0,/$from/{s/$from/$to/}\" $toml\n\n  if [ \"$diff_crate\" = \"bin\" ]; then\n    continue\n  fi\n\n  # update workspace dependency in root toml\n  sed -i \"/librespot-$diff_crate/{s/$from/$to/}\" ./Cargo.toml\n\n  # update related dependencies in diff_crate\n  for allowed_crate in $allowed_crates ; do\n    cat ./$allowed_crate/Cargo.toml | grep librespot-$diff_crate > /dev/null\n    if [ $? = 0 ]; then\n      sed -i \"/librespot-$diff_crate/{s/$from/$to/}\" ./$allowed_crate/Cargo.toml\n    fi\n  done\ndone\n\nexit 0\n"
  },
  {
    "path": ".github/workflows/build.yml",
    "content": "---\n# Note, this is used in the badge URL!\nname: build\n\n\"on\":\n  push:\n    branches: [dev, master]\n    paths-ignore:\n      - \"**.md\"\n      - \"docs/**\"\n      - \"contrib/**\"\n      - \"LICENSE\"\n      - \"*.sh\"\n      - \"**/Dockerfile*\"\n      - \"test.sh\"\n  pull_request:\n    paths-ignore:\n      - \"**.md\"\n      - \"docs/**\"\n      - \"contrib/**\"\n      - \"LICENSE\"\n      - \"*.sh\"\n      - \"**/Dockerfile*\"\n      - \"test.sh\"\n  schedule:\n    # Run CI every week\n    - cron: \"00 01 * * 0\"\n\nenv:\n  RUST_BACKTRACE: 1\n  RUSTFLAGS: -D warnings\n\njobs:\n  test:\n    name: cargo +${{ matrix.toolchain }} test (${{ matrix.os }})\n    runs-on: ${{ matrix.os }}\n    strategy:\n      matrix:\n        os: [ubuntu-latest, windows-latest]\n        toolchain:\n          - \"1.85\" # MSRV (Minimum supported rust version)\n          - stable\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v5\n\n      - name: Install Rust toolchain\n        uses: dtolnay/rust-toolchain@master\n        with:\n          toolchain: ${{ matrix.toolchain }}\n\n      - name: Cache Rust dependencies\n        uses: Swatinem/rust-cache@v2\n\n      - name: Install developer package dependencies (Linux)\n        if: runner.os == 'Linux'\n        run: >\n          sudo apt-get update && sudo apt-get install -y\n          libpulse-dev portaudio19-dev libasound2-dev libsdl2-dev\n          gstreamer1.0-dev libgstreamer-plugins-base1.0-dev\n          libavahi-compat-libdnssd-dev\n\n      - name: Fetch dependencies\n        run: cargo fetch --locked\n\n      - name: Build workspace with examples\n        run: cargo build --frozen --workspace --examples\n\n      - name: Run tests\n        run: cargo test --workspace\n\n      - name: Install cargo-hack\n        uses: taiki-e/install-action@cargo-hack\n\n      - name: Check packages without TLS requirements\n        run: cargo hack check -p librespot-protocol --each-feature\n\n      - name: Check workspace with native-tls\n        run: >\n          cargo hack check -p librespot --each-feature --exclude-all-features\n          --include-features native-tls\n          --exclude-features rustls-tls-native-roots,rustls-tls-webpki-roots\n\n      - name: Check workspace with rustls-tls-native-roots\n        run: >\n          cargo hack check -p librespot --each-feature --exclude-all-features\n          --include-features rustls-tls-native-roots\n          --exclude-features native-tls,rustls-tls-webpki-roots\n\n      - name: Check discovery features (Linux only)\n        if: runner.os == 'Linux'\n        run: >\n          cargo hack check -p librespot-discovery --each-feature --exclude-all-features\n          --include-features native-tls\n          --exclude-features rustls-tls-native-roots,rustls-tls-webpki-roots\n\n      - name: Build binary with default features\n        run: cargo build --frozen\n\n      - name: Upload debug artifacts\n        uses: actions/upload-artifact@v4\n        with:\n          name: librespot-${{ matrix.os }}-${{ matrix.toolchain }}\n          path: >\n            target/debug/librespot${{ runner.os == 'Windows' && '.exe' || '' }}\n          if-no-files-found: error\n"
  },
  {
    "path": ".github/workflows/cross-compile.yml",
    "content": "---\nname: cross-compile\n\n\"on\":\n  push:\n    branches: [dev, master]\n    paths-ignore:\n      - \"**.md\"\n      - \"docs/**\"\n      - \"contrib/**\"\n      - \"LICENSE\"\n      - \"*.sh\"\n      - \"**/Dockerfile*\"\n  pull_request:\n    paths-ignore:\n      - \"**.md\"\n      - \"docs/**\"\n      - \"contrib/**\"\n      - \"LICENSE\"\n      - \"*.sh\"\n      - \"**/Dockerfile*\"\n\nenv:\n  RUST_BACKTRACE: 1\n  RUSTFLAGS: -D warnings\n\njobs:\n  cross-compile:\n    name: cross +${{ matrix.toolchain }} build ${{ matrix.platform.target }}\n    runs-on: ${{ matrix.platform.runs-on }}\n    continue-on-error: false\n    strategy:\n      matrix:\n        platform:\n          - arch: armv7\n            runs-on: ubuntu-latest\n            target: armv7-unknown-linux-gnueabihf\n\n          - arch: aarch64\n            runs-on: ubuntu-latest\n            target: aarch64-unknown-linux-gnu\n\n          - arch: riscv64gc\n            runs-on: ubuntu-latest\n            target: riscv64gc-unknown-linux-gnu\n\n        toolchain:\n          - \"1.85\" # MSRV (Minimum Supported Rust Version)\n          - stable\n\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v5\n\n      - name: Build binary with default features\n        if: matrix.platform.target != 'riscv64gc-unknown-linux-gnu'\n        uses: houseabsolute/actions-rust-cross@v1\n        with:\n          command: build\n          target: ${{ matrix.platform.target }}\n          toolchain: ${{ matrix.toolchain }}\n          args: --locked --verbose\n\n      - name: Build binary without system dependencies\n        if: matrix.platform.target == 'riscv64gc-unknown-linux-gnu'\n        uses: houseabsolute/actions-rust-cross@v1\n        with:\n          command: build\n          target: ${{ matrix.platform.target }}\n          toolchain: ${{ matrix.toolchain }}\n          args: --locked --verbose --no-default-features --features rustls-tls-webpki-roots\n\n      - name: Upload debug artifacts\n        uses: actions/upload-artifact@v4\n        with:\n          name: librespot-${{ matrix.platform.runs-on }}-${{ matrix.platform.arch }}-${{ matrix.toolchain }} # yamllint disable-line rule:line-length\n          path: target/${{ matrix.platform.target }}/debug/librespot\n          if-no-files-found: error\n"
  },
  {
    "path": ".github/workflows/prepare-release.yml",
    "content": "---\n# test with\n# act --job prepare-release --eventpath ./.github/example/prepare-release.event\nname: prepare release\non:\n  workflow_dispatch:\n    inputs:\n      versionBump:\n        description: \"Version bump for\"\n        required: true\n        type: choice\n        options:\n          - major\n          - minor\n          - patch\n\njobs:\n  prepare-release:\n    name: Prepare release\n    runs-on: ubuntu-latest\n    permissions:\n      contents: write\n      pull-requests: write\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v5\n        with:\n          fetch-tags: true\n\n      - name: Install Rust toolchain\n        uses: dtolnay/rust-toolchain@stable\n\n      - name: Bump versions\n        env:\n          fragment: ${{ github.event.inputs.versionBump }}\n        run: ./.github/scripts/bump-versions.sh\n\n      - name: Update Cargo.lock\n        run: cargo update --workspace\n\n      - name: Get binary version\n        id: get-version\n        run: |\n          VERSION=$(cat ./Cargo.toml | awk \"/version/{print; exit}\" | cut -d\\\" -f 2)\n          echo VERSION=$VERSION >> ${GITHUB_OUTPUT}\n\n      - name: Update Changelog\n        uses: thomaseizinger/keep-a-changelog-new-release@3.1.0\n        with:\n          tag: v${{ steps.get-version.outputs.VERSION }}\n          version: ${{ steps.get-version.outputs.VERSION }}\n\n      - name: Create PR to review automated changes\n        uses: peter-evans/create-pull-request@v7\n        if: ${{ !env.ACT }}\n        with:\n          commit-message: 'Preparations for v${{ steps.get-version.outputs.VERSION }}'\n          title: \"Preparations for v${{ steps.get-version.outputs.VERSION }}\"\n          branch: \"prepare-release/v${{ steps.get-version.outputs.VERSION }}\"\n          assignees: ${{ github.actor }}\n          body: |\n            This PR prepares for the next ${{ github.event.inputs.versionBump }} release v${{ steps.get-version.outputs.VERSION }}.\n\n            **Files that should be automatically modified:**\n            - `Cargo.toml` (version bump)\n              - `<crate>/Cargo.toml` (version bump)\n              > for patch versions only the necessary crates will receive an update\n            - `Cargo.lock` (only bumps own crate versions)\n            - `CHANGELOG.md` (finalize changelog for upcoming version)\n            \n            **Review checklist:**\n            - [ ] Confirm the version bump in `Cargo.toml` is correct\n            - [ ] Ensure `Cargo.lock` did only update our crates\n            - [ ] Review the changelog for accuracy and completeness\n            \n            Please verify these changes before merging.\n            \n            ---\n            After merging, continue by creating a new release with the tag `v${{ steps.get-version.outputs.VERSION }}`.\n\n            > See [PUBLISHING.md](https://github.com/librespot-org/librespot/blob/dev/PUBLISHING.md) for further infos.\n"
  },
  {
    "path": ".github/workflows/quality.yml",
    "content": "---\nname: code-quality\n\n\"on\":\n  push:\n    branches: [dev, master]\n    paths-ignore:\n      - \"**.md\"\n      - \"docs/**\"\n      - \"contrib/**\"\n      - \"LICENSE\"\n      - \"*.sh\"\n      - \"**/Dockerfile*\"\n  pull_request:\n    paths-ignore:\n      - \"**.md\"\n      - \"docs/**\"\n      - \"contrib/**\"\n      - \"LICENSE\"\n      - \"*.sh\"\n      - \"**/Dockerfile*\"\n  schedule:\n    # Run CI every week\n    - cron: \"00 01 * * 0\"\n\nenv:\n  RUST_BACKTRACE: 1\n  RUSTFLAGS: -D warnings\n\njobs:\n  fmt:\n    name: cargo fmt\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v5\n\n      - name: Install Rust toolchain\n        uses: dtolnay/rust-toolchain@stable\n\n      - name: Check formatting\n        run: cargo fmt --all -- --check\n\n  clippy:\n    needs: fmt\n    name: cargo clippy\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v5\n\n      - name: Install Rust toolchain\n        uses: dtolnay/rust-toolchain@stable\n\n      - name: Cache Rust dependencies\n        uses: Swatinem/rust-cache@v2\n\n      - name: Install developer package dependencies\n        run: >\n          sudo apt-get update && sudo apt-get install -y\n          libpulse-dev portaudio19-dev libasound2-dev libsdl2-dev\n          gstreamer1.0-dev libgstreamer-plugins-base1.0-dev\n          libavahi-compat-libdnssd-dev\n\n      - name: Install cargo-hack\n        uses: taiki-e/install-action@cargo-hack\n\n      - name: Run clippy on packages without TLS requirements\n        run: cargo hack clippy -p librespot-protocol --each-feature\n\n      - name: Run clippy with native-tls\n        run: >\n          cargo hack clippy -p librespot --each-feature --exclude-all-features\n          --include-features native-tls\n          --exclude-features rustls-tls-native-roots,rustls-tls-webpki-roots\n\n      - name: Run clippy with rustls-tls-native-roots\n        run: >\n          cargo hack clippy -p librespot --each-feature --exclude-all-features\n          --include-features rustls-tls-native-roots\n          --exclude-features native-tls,rustls-tls-webpki-roots\n\n      - name: Run clippy with rustls-tls-webpki-roots\n        run: >\n          cargo hack clippy -p librespot --each-feature --exclude-all-features\n          --include-features rustls-tls-webpki-roots\n          --exclude-features native-tls,rustls-tls-native-roots\n"
  },
  {
    "path": ".github/workflows/release.yml",
    "content": "name: publish on release creation\non:\n  release:\n    types:\n      - created\n  workflow_dispatch:\n\njobs:\n  publish-crates:\n    name: Publish librespot\n    runs-on: ubuntu-latest\n    permissions: \n      contents: read\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v5\n\n      - name: Install Rust toolchain\n        uses: dtolnay/rust-toolchain@stable\n\n      - name: Cache Rust dependencies\n        uses: Swatinem/rust-cache@v2\n\n      - name: Install dependencies\n        run: sudo apt-get update && sudo apt-get install -y libasound2-dev\n\n      - name: Verify librespot workspace\n        run: cargo publish --workspace --dry-run\n\n      - name: Publish librespot workspace\n        if: ${{ !env.ACT }}\n        env:\n          CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}\n        run: cargo publish --workspace\n"
  },
  {
    "path": ".gitignore",
    "content": "target\n.cargo\nspotify_appkey.key\n.idea/\n.vagrant/\n.project\n.history\n.cache\n*.save\n*.*~\n"
  },
  {
    "path": ".pre-commit-config.yaml",
    "content": "# See https://pre-commit.com for more information\n# See https://pre-commit.com/hooks.html for more hooks\nrepos:\n-   repo: https://github.com/doublify/pre-commit-rust\n    rev: master\n    hooks:\n    -   id: fmt\n    -   id: clippy\n"
  },
  {
    "path": "CHANGELOG.md",
    "content": "# Changelog\n\nAll notable changes to this project will be documented in this file.\n\nThe format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),\nand this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html) since v0.2.0.\n\n## [Unreleased]\n\n### Added\n\n- [connect] Add method `add_to_queue` to `Spirc` to add tracks, episodes, albums and playlists to the queue\n- [playback] Add `SetQueue` player event, emitting when the queue changes (context loaded, track added to queue, or queue set via Spotify Connect). Gated behind `ConnectConfig::emit_set_queue_events`\n\n### Changed\n\n- [core] Made `SpotifyId::to_base62`, `SpotifyId::to_base16`, `FileId::to_base16`, `SpotifyUri::to_id`, `SpotifyUri::to_uri` infallible (breaking)\n\n### Fixed\n\n- [audio] Fixed integer overflow in throughput calculation\n- [main] Fixed `--volume-ctrl fixed` not disabling volume control\n- [core] Fix default permissions on credentials file and warn user if file is world readable\n- [core] Try all resolved addresses for the dealer connection instead of failing after the first one.\n\n## [0.8.0] - 2025-11-10\n\n### Added\n\n- [connect] Add method `transfer` to `Spirc` to automatically transfer the playback to ourselves\n- [core] Add method `transfer` to `SpClient`\n- [core] Add `SpotifyUri` type to represent more types of URI than `SpotifyId` can\n- [discovery] Add support for [device aliases](https://developer.spotify.com/documentation/commercial-hardware/implementation/guides/zeroconf#device-aliases)\n- [main] `--local-file-dir` / `-l` option added to binary to specify local file directories to pull from\n- [metadata] `Local` variant added to `UniqueFields` enum (breaking)\n- [playback] Local files can now be played with the following caveats:\n  - They must be sampled at 44,100 Hz\n  - They cannot be played from a Connect device using the dedicated 'Local Files' playlist; they must be added to another playlist first\n- [playback] `local_file_directories` field added to `PlayerConfig` struct (breaking)\n\n### Changed\n\n- [contrib] Switched contrib/Dockerfile to new Debian stable (trixie)\n- [core] `get_radio_for_track` function changed from accepting a `SpotifyId` to accepting a `SpotifyUri` (breaking)\n- [core] Changed return type of `get_extended_metadata` to return `BatchedExtensionResponse` (breaking)\n- [core] Changed parameter of `get_<item>_metadata` from `SpotifyId` to `SpotifyUri` (breaking)\n- [metadata] Changed arguments for `Metadata` trait from `&SpotifyId` to `&SpotifyUri` (breaking)\n- [playback] Changed type of `SpotifyId` fields in `PlayerEvent` members to `SpotifyUri` (breaking)\n- [playback] `load` function changed from accepting a `SpotifyId` to accepting a `SpotifyUri` (breaking)\n- [playback] `preload` function changed from accepting a `SpotifyId` to accepting a `SpotifyUri` (breaking)\n\n### Fixed\n\n- [connect] Fixed failed transferring with transfer data that had an empty context uri and no tracks\n- [connect] Use the provided index or the first as fallback value to always play a track on loading\n- [core] Fixed a problem where the metadata didn't include the audio file by switching to `get_extended_metadata`\n- [core] Fixed connection issues after system suspend on Linux\n\n### Removed\n\n- [core] Removed `SpotifyItemType` enum; the new `SpotifyUri` is an enum over all item types and so which variant it is \n  describes its item type (breaking)\n- [core] Removed `NamedSpotifyId` struct; it was made obsolete by `SpotifyUri` (breaking)\n- [core] The following methods have been removed from `SpotifyId` and moved to `SpotifyUri` (breaking):\n  - `is_playable`\n  - `from_uri`\n  - `to_uri`\n\n## [v0.7.1] - 2025-08-31\n\n### Changed\n\n- [connect] Shuffling was adjusted, so that shuffle and repeat can be used combined\n\n### Fixed\n\n- [core] Fix issue where building with native-tls would fail\n- [connect] Repeat context will not go into autoplay anymore and triggering autoplay while shuffling shouldn't reshuffle anymore\n- [connect] Only deletes the connect state on dealer shutdown instead on disconnecting\n- [core] Fixed a problem where in `spclient` where an HTTP/411 error was thrown because the header was set wrong\n- [main] Use the config instead of the type default for values that are not provided by the user\n\n## [0.7.0] - 2025-08-24\n\n### Changed\n\n- [core] MSRV is now 1.85 with Rust edition 2024 (breaking)\n- [core] AP connect and handshake have a combined 5 second timeout.\n- [core] `stream_from_cdn` now accepts the URL as `TryInto<Uri>` instead of `CdnUrl` (breaking)\n- [core] Add TLS backend selection with native-tls and rustls-tls options, defaulting to native-tls\n- [connect] Replaced `has_volume_ctrl` with `disable_volume` in `ConnectConfig` (breaking)\n- [connect] Changed `initial_volume` from `Option<u16>` to `u16` in `ConnectConfig` (breaking)\n- [connect] Replaced `SpircLoadCommand` with `LoadRequest`, `LoadRequestOptions` and `LoadContextOptions` (breaking)\n- [connect] Moved all public items to the highest level (breaking)\n- [connect] Replaced Mercury usage in `Spirc` with Dealer\n- [metadata] Replaced `AudioFileFormat` with own enum. (breaking)\n- [playback] Changed trait `Mixer::open` to return `Result<Self, Error>` instead of `Self` (breaking)\n- [playback] Changed type alias `MixerFn` to return `Result<Arc<dyn Mixer>, Error>` instead of `Arc<dyn Mixer>` (breaking)\n- [playback] Optimize audio conversion to always dither at 16-bit level, and improve performance\n- [playback] Normalizer maintains better stereo imaging, while also being faster\n- [oauth] Remove loopback address requirement from `redirect_uri` when spawning callback handling server versus using stdin.\n\n### Added\n\n- [connect] Add command line parameter for setting volume steps.\n- [connect] Add support for `seek_to`, `repeat_track` and `autoplay` for `Spirc` loading\n- [connect] Add `pause` parameter to `Spirc::disconnect` method (breaking)\n- [connect] Add `volume_steps` to `ConnectConfig` (breaking)\n- [connect] Add and enforce rustdoc\n- [playback] Add `track` field to `PlayerEvent::RepeatChanged` (breaking)\n- [playback] Add `PlayerEvent::PositionChanged` event to notify about the current playback position\n- [core] Add `request_with_options` and `request_with_protobuf_and_options` to `SpClient`\n- [core] Add `try_get_urls` to `CdnUrl`\n- [oauth] Add `OAuthClient` and `OAuthClientBuilder` structs to achieve a more customizable login process\n\n### Fixed\n\n- [test] Missing bindgen breaks crossbuild on recent runners. Now installing latest bindgen in addition.\n- [core] Fix \"no native root CA certificates found\" on platforms unsupported\n  by `rustls-native-certs`.\n- [core] Fix all APs rejecting with \"TryAnotherAP\" when connecting session\n  on Android platform.\n- [core] Fix \"Invalid Credentials\" when using a Keymaster access token and\n  client ID on Android platform.\n- [connect] Fix \"play\" command not handled if missing \"offset\" property\n- [discovery] Fix libmdns zerconf setup errors not propagating to the main task.\n- [metadata] `Show::trailer_uri` is now optional since it isn't always present (breaking)\n- [metadata] Fix incorrect parsing of audio format\n- [connect] Handle transfer of playback with empty \"uri\" field\n- [connect] Correctly apply playing/paused state when transferring playback\n- [player] Saturate invalid seek positions to track duration\n- [audio] Fall back to other URLs in case of a failure when downloading from CDN\n- [core] Metadata requests failing with 500 Internal Server Error\n- [player] Rodio backend did not honor audio output format request\n\n### Deprecated\n\n- [oauth] `get_access_token()` function marked for deprecation\n- [core] `try_get_url()` function marked for deprecation\n\n### Removed\n\n- [core] Removed `get_canvases` from SpClient (breaking)\n- [core] DeviceType `homething` removed due to crashes on Android (breaking)\n- [metadata] Removed `genres` from Album (breaking)\n- [metadata] Removed `genre` from Artists (breaking)\n\n## [0.6.0] - 2024-10-30\n\nThis version takes another step into the direction of the HTTP API, fixes a\ncouple of bugs, and makes it easier for developers to mock a certain platform.\nAlso it adds the option to choose avahi, dnssd or libmdns as your zeroconf\nbackend for Spotify Connect discovery.\n\n### Changed\n\n- [core] The `access_token` for http requests is now acquired by `login5`\n- [core] MSRV is now 1.75 (breaking)\n- [discovery] librespot can now be compiled with multiple MDNS/DNS-SD backends\n  (avahi, dns_sd, libmdns) which can be selected using a CLI flag. The defaults\n  are unchanged (breaking).\n\n### Added\n\n- [core] Add `get_token_with_client_id()` to get a token for a specific client ID\n- [core] Add `login` (mobile) and `auth_token` retrieval via login5\n- [core] Add `OS` and `os_version` to `config.rs`\n- [discovery] Added a new MDNS/DNS-SD backend which connects to Avahi via D-Bus.\n\n### Fixed\n\n- [connect] Fixes initial volume showing zero despite playing in full volume instead\n- [core] Fix \"source slice length (16) does not match destination slice length\n  (20)\" panic on some tracks\n\n## [0.5.0] - 2024-10-15\n\nThis version is be a major departure from the architecture up until now. It\nfocuses on implementing the \"new Spotify API\". This means moving large parts\nof the Spotify protocol from Mercury to HTTP. A lot of this was reverse\nengineered before by @devgianlu of librespot-java. It was long overdue that we\nstarted implementing it too, not in the least because new features like the\nhopefully upcoming Spotify HiFi depend on it.\n\nSplitting up the work on the new Spotify API, v0.5.0 brings HTTP-based file\ndownloads and metadata access. Implementing the \"dealer\" (replacing the current\nMercury-based SPIRC message bus with WebSockets, also required for social plays)\nis a large and separate effort, slated for some later release.\n\nWhile at it, we are taking the liberty to do some major refactoring to make\nlibrespot more robust. Consequently not only the Spotify API changed but large\nparts of the librespot API too. For downstream maintainers, we realise that it\ncan be a lot to move from the current codebase to this one, but believe us it\nwill be well worth it.\n\nAll these changes are likely to introduce new bugs as well as some regressions.\nWe appreciate all your testing and contributions to the repository:\n<https://github.com/librespot-org/librespot>\n\n### Changed\n\n- [all] Assertions were changed into `Result` or removed (breaking)\n- [all] Purge use of `unwrap`, `expect` and return `Result` (breaking)\n- [all] `chrono` replaced with `time` (breaking)\n- [all] `time` updated (CVE-2020-26235)\n- [all] Improve lock contention and performance (breaking)\n- [all] Use a single `player` instance. Eliminates occasional `player` and\n  `audio backend` restarts, which can cause issues with some playback\n  configurations.\n- [all] Updated and removed unused dependencies\n- [audio] Files are now downloaded over the HTTPS CDN (breaking)\n- [audio] Improve file opening and seeking performance (breaking)\n- [core] MSRV is now 1.74 (breaking)\n- [connect] `DeviceType` moved out of `connect` into `core` (breaking)\n- [connect] Update and expose all `spirc` context fields (breaking)\n- [connect] Add `Clone, Defaut` traits to `spirc` contexts\n- [connect] Autoplay contexts are now retrieved with the `spclient` (breaking)\n- [contrib] Updated Docker image\n- [core] Message listeners are registered before authenticating. As a result\n  there now is a separate `Session::new` and subsequent `session.connect`.\n  (breaking)\n- [core] `ConnectConfig` moved out of `core` into `connect` (breaking)\n- [core] `client_id` for `get_token` moved to `SessionConfig` (breaking)\n- [core] Mercury code has been refactored for better legibility (breaking)\n- [core] Cache resolved access points during runtime (breaking)\n- [core] `FileId` is moved out of `SpotifyId`. For now it will be re-exported.\n- [core] Report actual platform data on login\n- [core] Support `Session` authentication with a Spotify access token\n- [core] `Credentials.username` is now an `Option` (breaking)\n- [core] `Session::connect` tries multiple access points, retrying each one.\n- [core] Each access point connection now timesout after 3 seconds.\n- [core] Listen on both IPV4 and IPV6 on non-windows hosts\n- [main] `autoplay {on|off}` now acts as an override. If unspecified, `librespot`\n  now follows the setting in the Connect client that controls it. (breaking)\n- [metadata] Most metadata is now retrieved with the `spclient` (breaking)\n- [metadata] Playlists are moved to the `playlist4_external` protobuf (breaking)\n- [metadata] Handle playlists that are sent with microsecond-based timestamps\n- [playback] The audio decoder has been switched from `lewton` to `Symphonia`.\n  This improves the Vorbis sound quality, adds support for MP3 as well as for\n  FLAC in the future. (breaking)\n- [playback] Improve reporting of actual playback cursor\n- [playback] The passthrough decoder is now feature-gated (breaking)\n- [playback] `rodio`: call play and pause\n- [protocol] protobufs have been updated\n\n### Added\n\n- [all] Check that array indexes are within bounds (panic safety)\n- [all] Wrap errors in librespot `Error` type (breaking)\n- [audio] Make audio fetch parameters tunable\n- [connect] Add option on which zeroconf will bind. Defaults to all interfaces. Ignored by DNS-SD.\n- [connect] Add session events\n- [connect] Add `repeat`, `set_position_ms` and `set_volume` to `spirc.rs`\n- [contrib] Add `event_handler_example.py`\n- [core] Send metrics with metadata queries: client ID, country & product\n- [core] Verify Spotify server certificates (prevents man-in-the-middle attacks)\n- [core] User attributes are stored in `Session` upon login, accessible with a\n  getter and setter, and automatically updated as changes are pushed by the\n  Spotify infrastructure (breaking)\n- [core] HTTPS is now supported, including for proxies (breaking)\n- [core] Resolve `spclient` and `dealer` access points (breaking)\n- [core] Get and cache tokens through new token provider (breaking)\n- [core] `spclient` is the API for HTTP-based calls to the Spotify servers.\n  It supports a lot of functionality, including audio previews and image\n  downloads even if librespot doesn't use that for playback itself.\n- [core] Support downloading of lyrics\n- [core] Support parsing `SpotifyId` for local files\n- [core] Support parsing `SpotifyId` for named playlists\n- [core] Add checks and handling for stale server connections.\n- [core] Fix potential deadlock waiting for audio decryption keys.\n- [discovery] Add option to show playback device as a group\n- [main] Add all player events to `player_event_handler.rs`\n- [main] Add an event worker thread that runs async to the main thread(s) but\n  sync to itself to prevent potential data races for event consumers\n- [metadata] All metadata fields in the protobufs are now exposed (breaking)\n- [oauth] Standalone module to obtain Spotify access token using OAuth authorization code flow.\n- [playback] Explicit tracks are skipped if the controlling Connect client has\n  disabled such content. Applications that use librespot as a library without\n  Connect should use the 'filter-explicit-content' user attribute in the session.\n- [playback] Add metadata support via a `TrackChanged` event\n- [connect] Add `activate` and `load` functions to `Spirc`, allowing control over local connect sessions\n- [metadata] Add `Lyrics`\n- [discovery] Add discovery initialisation retries if within the 1st min of uptime\n\n### Fixed\n\n- [connect] Set `PlayStatus` to the correct value when Player is loading to\n  avoid blanking out the controls when `self.play_status` is `LoadingPlay` or\n  `LoadingPause` in `spirc.rs`\n- [connect] Handle attempts to play local files better by basically ignoring\n  attempts to load them in `handle_remote_update` in `spirc.rs`\n- [connect] Loading previous or next tracks, or looping back on repeat, will\n  only start playback when we were already playing\n- [connect, playback] Clean up and de-noise events and event firing\n- [core] Fixed frequent disconnections for some users\n- [core] More strict Spotify ID parsing\n- [discovery] Update active user field upon connection\n- [playback] Handle invalid track start positions by just starting the track\n  from the beginning\n- [playback] Handle disappearing and invalid devices better\n- [playback] Handle seek, pause, and play commands while loading\n- [playback] Handle disabled normalisation correctly when using fixed volume\n- [playback] Do not stop sink in gapless mode\n- [metadata] Fix missing colon when converting named spotify IDs to URIs\n\n## [0.4.2] - 2022-07-29\n\nBesides a couple of small fixes, this point release is mainly to blacklist the\nap-gew4 and ap-gue1 access points that caused librespot to fail to playback\nanything.\n\nDevelopment will now shift to the new HTTP-based API, targeted for a future\nv0.5.0 release. The new-api branch will therefore be promoted to dev. This is a\nmajor departure from the old API and although it brings many exciting new\nthings, it is also likely to introduce new bugs and some regressions.\n\nLong story short, this v0.4.2 release is the most stable that librespot has yet\nto offer. But, unless anything big comes up, it is also intended as the last\nrelease to be based on the old API. Happy listening.\n\n### Changed\n\n- [playback] `pipe`: Better error handling\n- [playback] `subprocess`: Better error handling\n\n### Added\n\n- [core] `apresolve`: Blacklist ap-gew4 and ap-gue1 access points that cause channel errors\n- [playback] `pipe`: Implement stop\n\n### Fixed\n\n- [main] fix `--opt=value` line argument logging\n- [playback] `alsamixer`: make `--volume-ctrl fixed` work as expected when combined with `--mixer alsa`\n\n## [0.4.1] - 2022-05-23\n\nThis release fixes dependency issues when installing from crates.\n\n### Changed\n\n- [chore] The MSRV is now 1.56\n\n### Fixed\n\n- [playback] Fixed dependency issues when installing from crate\n\n## [0.4.0] - 2022-05-21\n\nNote: This version was yanked, because a corrupt package was uploaded and failed\nto install.\n\nThis is a polishing release, adding a few little extras and improving on many\nthers. We had to break a couple of API's to do so, and therefore bumped the\nminor version number. v0.4.x may be the last in series before we migrate from\nthe current channel-based Spotify backend to a more HTTP-based backend.\nTargeting that major effort for a v0.5 release sometime, we intend to maintain\nv0.4.x as a stable branch until then.\n\n### Changed\n\n- [chore] The MSRV is now 1.53\n- [contrib] Hardened security of the `systemd` service units\n- [core] `Session`: `connect()` now returns the long-term credentials\n- [core] `Session`: `connect()` now accepts a flag if the credentails should be stored via the cache\n- [main] Different option descriptions and error messages based on what backends are enabled at build time\n- [playback] More robust dynamic limiter for very wide dynamic range (breaking)\n- [playback] `alsa`: improve `--device ?` output for the Alsa backend\n- [playback] `gstreamer`: create own context, set correct states and use sync handler\n- [playback] `pipe`: create file if it doesn't already exist\n- [playback] `Sink`: `write()` now receives ownership of the packet (breaking)\n\n### Added\n\n- [main] Enforce reasonable ranges for option values (breaking)\n- [main] Add the ability to parse environment variables\n- [main] Log now emits warning when trying to use options that would otherwise have no effect\n- [main] Verbose logging now logs all parsed environment variables and command line arguments (credentials are redacted)\n- [main] Add a `-q`, `--quiet` option that changes the logging level to WARN\n- [main] Add `disable-credential-cache` flag (breaking)\n- [main] Add a short name for every flag and option\n- [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)\n- [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)\n- [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)\n\n### Fixed\n\n- [connect] Don't panic when activating shuffle without previous interaction\n- [core] Removed unsafe code (breaking)\n- [main] Fix crash when built with Avahi support but Avahi is locally unavailable\n- [main] Prevent hang when discovery is disabled and there are no credentials or when bad credentials are given\n- [main] Don't panic when parsing options, instead list valid values and exit\n- [main] `--alsa-mixer-device` and `--alsa-mixer-index` now fallback to the card and index specified in `--device`.\n- [playback] Adhere to ReplayGain spec when calculating gain normalisation factor\n- [playback] `alsa`: make `--volume-range` overrides apply to Alsa softvol controls\n\n### Removed\n\n- [playback] `alsamixer`: previously deprecated options `mixer-card`, `mixer-name` and `mixer-index` have been removed\n\n## [0.3.1] - 2021-10-24\n\n### Changed\n\n- Include build profile in the displayed version information\n- [playback] Improve dithering CPU usage by about 33%\n\n### Fixed\n\n- [connect] Partly fix behavior after last track of an album/playlist\n\n## [0.3.0] - 2021-10-13\n\n### Added\n\n- [discovery] The crate `librespot-discovery` for discovery in LAN was created. Its functionality was previously part of `librespot-connect`.\n- [playback] Add support for dithering with `--dither` for lower requantization error (breaking)\n- [playback] Add `--volume-range` option to set dB range and control `log` and `cubic` volume control curves\n- [playback] `alsamixer`: support for querying dB range from Alsa softvol\n- [playback] Add `--format F64` (supported by Alsa and GStreamer only)\n- [playback] Add `--normalisation-gain-type auto` that switches between album and track automatically\n\n### Changed\n\n- [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)\n- [audio, playback] Use `Duration` for time constants and functions (breaking)\n- [connect, playback] Moved volume controls from `librespot-connect` to `librespot-playback` crate\n- [connect] Synchronize player volume with mixer volume on playback\n- [playback] Store and pass samples in 64-bit floating point\n- [playback] Make cubic volume control available to all mixers with `--volume-ctrl cubic`\n- [playback] Normalize volumes to `[0.0..1.0]` instead of `[0..65535]` for greater precision and performance (breaking)\n- [playback] `alsamixer`: complete rewrite (breaking)\n- [playback] `alsamixer`: query card dB range for the volume control unless specified otherwise\n- [playback] `alsamixer`: use `--device` name for `--mixer-card` unless specified otherwise\n- [playback] `player`: consider errors in `sink.start`, `sink.stop` and `sink.write` fatal and `exit(1)` (breaking)\n- [playback] `player`: make `convert` and `decoder` public so you can implement your own `Sink`\n- [playback] `player`: update default normalisation threshold to -2 dBFS\n- [playback] `player`: default normalisation type is now `auto`\n\n### Deprecated\n\n- [connect] The `discovery` module was deprecated in favor of the `librespot-discovery` crate\n- [playback] `alsamixer`: renamed `mixer-card` to `alsa-mixer-device`\n- [playback] `alsamixer`: renamed `mixer-name` to `alsa-mixer-control`\n- [playback] `alsamixer`: renamed `mixer-index` to `alsa-mixer-index`\n\n### Removed\n\n- [connect] Removed no-op mixer started/stopped logic (breaking)\n- [playback] Removed `with-vorbis` and `with-tremor` features\n- [playback] `alsamixer`: removed `--mixer-linear-volume` option, now that `--volume-ctrl {linear|log}` work as expected on Alsa\n\n### Fixed\n\n- [connect] Fix step size on volume up/down events\n- [connect] Fix looping back to the first track after the last track of an album or playlist\n- [playback] Incorrect `PlayerConfig::default().normalisation_threshold` caused distortion when using dynamic volume normalisation downstream\n- [playback] Fix `log` and `cubic` volume controls to be mute at zero volume\n- [playback] Fix `S24_3` format on big-endian systems\n- [playback] `alsamixer`: make `cubic` consistent between cards that report minimum volume as mute, and cards that report some dB value\n- [playback] `alsamixer`: make `--volume-ctrl {linear|log}` work as expected\n- [playback] `alsa`, `gstreamer`, `pulseaudio`: always output in native endianness\n- [playback] `alsa`: revert buffer size to ~500 ms\n- [playback] `alsa`, `pipe`, `pulseaudio`: better error handling\n- [metadata] Skip tracks whose Spotify ID's can't be found (e.g. local files, which aren't supported)\n\n## [0.2.0] - 2021-05-04\n\n## [0.1.6] - 2021-02-22\n\n## [0.1.5] - 2021-02-21\n\n## [0.1.3] - 2020-07-29\n\n## [0.1.2] - 2020-07-22\n\n## [0.1.1] - 2020-01-30\n\n## [0.1.0] - 2019-11-06\n\n[unreleased]: https://github.com/librespot-org/librespot/compare/v0.8.0...HEAD\n[0.8.0]: https://github.com/librespot-org/librespot/compare/v0.7.1...v0.8.0\n[0.7.1]: https://github.com/librespot-org/librespot/compare/v0.7.0...v0.7.1\n[0.7.0]: https://github.com/librespot-org/librespot/compare/v0.6.0...v0.7.0\n[0.6.0]: https://github.com/librespot-org/librespot/compare/v0.5.0...v0.6.0\n[0.5.0]: https://github.com/librespot-org/librespot/compare/v0.4.2...v0.5.0\n[0.4.2]: https://github.com/librespot-org/librespot/compare/v0.4.1...v0.4.2\n[0.4.1]: https://github.com/librespot-org/librespot/compare/v0.4.0...v0.4.1\n[0.4.0]: https://github.com/librespot-org/librespot/compare/v0.3.1...v0.4.0\n[0.3.1]: https://github.com/librespot-org/librespot/compare/v0.3.0...v0.3.1\n[0.3.0]: https://github.com/librespot-org/librespot/compare/v0.2.0...v0.3.0\n[0.2.0]: https://github.com/librespot-org/librespot/compare/v0.1.6...v0.2.0\n[0.1.6]: https://github.com/librespot-org/librespot/compare/v0.1.5...v0.1.6\n[0.1.5]: https://github.com/librespot-org/librespot/compare/v0.1.3...v0.1.5\n[0.1.3]: https://github.com/librespot-org/librespot/compare/v0.1.2...v0.1.3\n[0.1.2]: https://github.com/librespot-org/librespot/compare/v0.1.1...v0.1.2\n[0.1.1]: https://github.com/librespot-org/librespot/compare/v0.1.0...v0.1.1\n[0.1.0]: https://github.com/librespot-org/librespot/releases/tag/v0.1.0\n"
  },
  {
    "path": "COMPILING.md",
    "content": "# Compiling\n\n## Setup\n\nIn order to compile librespot, you will first need to set up a suitable Rust build environment, with the necessary dependencies installed. You will need to have a C compiler, Rust, and the development libraries for the audio backend(s) you want installed. These instructions will walk you through setting up a simple build environment.\n\n### Install Rust\nThe 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.\n\n#### Additional Rust tools - `rustfmt`\nTo 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:\n```bash\nrustup component add rustfmt\nrustup component add clippy\n```\nUsing `cargo fmt` and `cargo clippy` is not optional, as our CI checks against this repo's rules.\n\n### General dependencies\nAlong with Rust, you will also require a C compiler.\n\nOn Debian/Ubuntu, install with:\n```shell\nsudo apt-get install build-essential\n\n```\nOn Fedora systems, install with:\n```shell\nsudo dnf install gcc\n```\n### Audio library dependencies\nDepending on the chosen backend, specific development libraries are required.\n\n*_Note this is an non-exhaustive list, open a PR to add to it!_*\n\n| Audio backend      | Debian/Ubuntu                | Fedora                            | macOS       |\n|--------------------|------------------------------|-----------------------------------|-------------|\n|Rodio (default)     | `libasound2-dev`             | `alsa-lib-devel`                  |             |\n|ALSA                | `libasound2-dev, pkg-config` | `alsa-lib-devel`                  |             |\n|GStreamer | `gstreamer1.0-plugins-base libgstreamer-plugins-base1.0-dev gstreamer1.0-plugins-good libgstreamer-plugins-good1.0-dev` | `gstreamer1 gstreamer1-devel gstreamer1-plugins-base-devel gstreamer1-plugins-good` | `gstreamer gst-devtools gst-plugins-base gst-plugins-good` |\n|PortAudio           | `portaudio19-dev`            | `portaudio-devel`                 | `portaudio` |\n|PulseAudio          | `libpulse-dev`               | `pulseaudio-libs-devel`           |             |\n|JACK                | `libjack-dev`                | `jack-audio-connection-kit-devel` |  `jack`     |\n|JACK over Rodio     | `libjack-dev`                | `jack-audio-connection-kit-devel` |  `jack`     |\n|SDL                 | `libsdl2-dev`                | `SDL2-devel`                      |  `sdl2`     |\n|Pipe & subprocess   |  -                           |  -                                |  -          |\n\n###### For example, to build an ALSA based backend, you would need to run the following to install the required dependencies:\n\nOn Debian/Ubuntu:\n```shell\nsudo apt-get install libasound2-dev pkg-config\n\n```\nOn Fedora systems:\n```shell\nsudo dnf install alsa-lib-devel\n```\n\n### Zeroconf library dependencies\nDepending on the chosen backend, specific development libraries are required.\n\n*_Note this is an non-exhaustive list, open a PR to add to it!_*\n\n| Zeroconf backend   | Debian/Ubuntu                | Fedora                            | macOS       |\n|--------------------|------------------------------|-----------------------------------|-------------|\n|avahi               |                              |                                   |             |\n|dns_sd              | `libavahi-compat-libdnssd-dev pkg-config` | `avahi-compat-libdns_sd-devel` |   |\n|libmdns (default)   |                              |                                   |             |\n\n### TLS library dependencies\nlibrespot requires a TLS implementation for secure connections to Spotify's servers. You can choose between two mutually exclusive options:\n\n#### native-tls (default)\nUses your system's native TLS implementation:\n- **Linux**: OpenSSL\n- **macOS**: Secure Transport (Security.framework)\n- **Windows**: SChannel (Windows TLS)\n\nThis is the **default choice** and provides the best compatibility. It integrates with your system's certificate store and is well-tested across platforms.\n\n**When to choose native-tls:**\n- You want maximum compatibility\n- You're using system-managed certificates\n- You're on a standard Linux distribution with OpenSSL\n- You're deploying on platforms where OpenSSL is already present\n\n**Dependencies:**\nOn Debian/Ubuntu:\n```shell\nsudo apt-get install libssl-dev pkg-config\n```\n\nOn Fedora:\n```shell\nsudo dnf install openssl-devel pkg-config\n```\n\n#### rustls-tls\nUses a Rust-based TLS implementation with certificate authority (CA) verification. Two certificate store options are available:\n\n**rustls-tls-native-roots**:\n- **Linux**: Uses system ca-certificates package\n- **macOS**: Uses Security.framework for CA verification\n- **Windows**: Uses Windows certificate store\n- Integrates with system certificate management and security updates\n\n**rustls-tls-webpki-roots**:\n- Uses Mozilla's compiled-in certificate store (webpki-roots)\n- Certificate trust is independent of host system\n- Best for reproducible builds, containers, or embedded systems\n\n**When to choose rustls-tls:**\n- You want to avoid external OpenSSL dependencies\n- You're building for reproducible/deterministic builds\n- You're targeting platforms where OpenSSL is unavailable or problematic (musl, embedded, static linking)\n- You're cross-compiling and want to avoid OpenSSL build complexity\n- You prefer having cryptographic operations implemented in Rust\n\n**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.\n\n#### Building with specific TLS backends\n```bash\n# Default (native-tls)\ncargo build\n\n# Explicitly use native-tls\ncargo build --no-default-features --features \"native-tls rodio-backend with-libmdns\"\n\n# Use rustls-tls with native certificate stores\ncargo build --no-default-features --features \"rustls-tls-native-roots rodio-backend with-libmdns\"\n\n# Use rustls-tls with Mozilla's webpki certificate store\ncargo build --no-default-features --features \"rustls-tls-webpki-roots rodio-backend with-libmdns\"\n```\n\n**Important:** The TLS backends are mutually exclusive. Attempting to enable both will result in a compile-time error.\n\n### Getting the Source\n\nThe 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.\n\n```bash\ngit clone git@github.com:YOUR_USERNAME/librespot.git\n```\n\n## Compiling & Running\n\nOnce your build environment is setup, compiling the code is pretty simple.\n\n### Compiling\n\nTo build a ```debug``` build with the default backend, from the project root run:\n\n```bash\ncargo build\n```\n\nAnd for ```release```:\n\n```bash\ncargo build --release\n```\n\nYou 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.\n\nThere 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).\n\nBy default, librespot compiles with the ```native-tls```, ```rodio-backend```, and ```with-libmdns``` features.\n\n**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.\nFor example, to build with the ALSA audio, libmdns discovery, and native-tls backends:\n\n```bash\ncargo build --no-default-features --features \"native-tls alsa-backend with-libmdns\"\n```\n\nOr to use rustls-tls with ALSA:\n\n```bash\ncargo build --no-default-features --features \"rustls-tls alsa-backend with-libmdns\"\n```\n\n### Compiling on Apple Silicon (M1+) for Apple x86_64\n\nInstall the additional `x86_64-apple-darwin` target using rustup:\n\n```bash\nrustup target install x86_64-apple-darwin\n```\n\nThen run the build with the additional target parameter:\n\n```bash\ncargo build --target=x86_64-apple-darwin --release\n```\n\nYou can then use the `lipo` tool to create a single fat (universal) binary for both platforms:\n\n```bash\nlipo -create \\\n    -arch x86_64 target/x86_64-apple-darwin/release/librespot \\\n    -arch arm64 target/aarch64-apple-darwin/release/librespot \\\n    -output librespot\n```\n\n### Running\n\nAssuming you just compiled a ```debug``` build, you can run librespot with the following command:\n\n```bash\n./target/debug/librespot\n```\n\nThere are various runtime options, documented in the wiki, and visible by running librespot with the ```-h``` argument.\n\nNote that debug builds may cause buffer underruns and choppy audio when dithering is enabled (which it is by default). You can disable dithering with ```--dither none```.\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# Contributing\n\n## Reporting an Issue\n\nIssues are tracked in the Github issue tracker of the librespot repo.\n\nIf you have encountered a bug, please report it, as we rely on user reports to fix them.\n\nPlease also make sure that your issues are helpful. To ensure that your issue is helpful, please read over this brief checklist to avoid the more common pitfalls:\n\n- Please take a moment to search/read previous similar issues to ensure you aren’t posting a duplicate. Duplicates will be closed immediately.\n- Please include a clear description of what the issue is. Issues with descriptions such as ‘It hangs after 40 minutes’ will be closed immediately.\n- Please include, where possible, steps to reproduce the bug, along with any other material that is related to the bug. For example, if librespot consistently crashes when you try to play a song, please include the Spotify URI of that song. This can be immensely helpful in quickly pinpointing and resolving issues.\n- Please be alert and respond to questions asked by any project members. Stale issues will be closed.\n- When your issue concerns audio playback, please first make sure that your audio system is set up correctly and can play audio from other applications. This project aims to provide correct audio backends, not to provide Linux support to end users.\n- Lastly, and perhaps most importantly, please include a backtrace where possible. Recent versions of librespot should produce these automatically when it crashes, and print them to the console, but in some cases, you may need to run ‘export RUST_BACKTRACE=full’ before running librespot to enable backtraces.\n\n## Contributing Code\n\nIf there is an issue that you would like to write a fix for, or a feature you would like to implement, we use the following flow for updating code in the librespot repo:\n\n```\nFork -> Fix -> PR -> Review -> Merge\n```\n\nThis is how all code is added to the repository, even by those with write access.\n\n#### Steps before Committing\n\nIn order to prepare for a PR, you will need to do a couple of things first:\n\nMake any changes that you are going to make to the code, but do not commit yet.\n\nUnless your changes are negligible, please add an entry in the \"Unreleased\" section of `CHANGELOG.md`. Refer to [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) for instructions on how this entry should look like. If your changes break the API such that downstream packages that depend on librespot need to update their source to still compile, you should mark your changes as `(breaking)`.\n\nMake sure that the code is correctly formatted by running:\n```bash\ncargo fmt --all\n```\n\nThis command runs ```rustfmt```, a code formatting tool that will automatically correct any formatting that you have used that does not conform with the librespot code style. Once that command has run, you will need to rebuild the project:\n\n```bash\ncargo build\n```\n\nOnce it has built, check for common code mistakes by running:\n```bash\ncargo clippy\n```\n\nOnce you have confirmed there are no warnings or errors, you should commit your changes.\n\n```bash\ngit commit -a -m \"My fancy fix\"\n```\n\n**N.B.** Please, for the sake of a readable history, do not bundle multiple major changes into a single commit. Instead, break it up into multiple commits.\n\nOnce you have made the commits you wish to have merged, push them to your forked repo:\n\n```bash\ngit push\n```\n\nThen open a pull request on the main librespot repo.\n\nOnce a pull request is under way, it will be reviewed by one of the project maintainers, and either approved for merging, or have changes requested. Please be alert in the review period for possible questions about implementation decisions, implemented behaviour, and requests for changes. Once the PR is approved, it will be merged into the main repo.\n\nHappy Contributing :)\n"
  },
  {
    "path": "Cargo.toml",
    "content": "[package]\nname = \"librespot\"\nversion = \"0.8.0\"\nrust-version.workspace = true\nauthors.workspace = true\nlicense.workspace = true\ndescription = \"An open source client library for Spotify, with support for Spotify Connect\"\nkeywords = [\"audio\", \"spotify\", \"music\", \"streaming\", \"connect\"]\ncategories = [\"multimedia::audio\"]\nrepository.workspace = true\nreadme = \"README.md\"\nedition.workspace = true\ninclude = [\n    \"src/**/*\",\n    \"audio/**/*\",\n    \"connect/**/*\",\n    \"core/**/*\",\n    \"discovery/**/*\",\n    \"examples/**/*\",\n    \"metadata/**/*\",\n    \"oauth/**/*\",\n    \"playback/**/*\",\n    \"protocol/**/*\",\n    \"Cargo.toml\",\n    \"README.md\",\n    \"LICENSE\",\n    \"COMPILING.md\",\n    \"CONTRIBUTING.md\",\n]\n\n[workspace.package]\nrust-version = \"1.85\"\nauthors = [\"Librespot Org\"]\nlicense = \"MIT\"\nrepository = \"https://github.com/librespot-org/librespot\"\nedition = \"2024\"\n\n[features]\ndefault = [\"native-tls\", \"rodio-backend\", \"with-libmdns\"]\n\n# TLS backends (mutually exclusive - compile-time checks in oauth/src/lib.rs)\n# Note: Feature validation is in oauth crate since it's compiled first in the dependency tree.\n# See COMPILING.md for more details on TLS backend selection.\n\n# native-tls: Uses the system's native TLS stack (OpenSSL on Linux, Secure Transport on macOS,\n# SChannel on Windows). This is the default as it's well-tested, widely compatible, and integrates\n# with system certificate stores. Choose this for maximum compatibility and when you want to use\n# system-managed certificates.\nnative-tls = [\"librespot-core/native-tls\", \"librespot-oauth/native-tls\"]\n\n# rustls-tls: Uses the Rust-based rustls TLS implementation with certificate authority (CA)\n# verification. This provides a Rust TLS stack (with assembly optimizations). Choose this for\n# avoiding external  OpenSSL dependencies, reproducible builds, or when targeting platforms where\n# native TLS  dependencies are unavailable or problematic (musl, embedded, static linking).\n#\n# Two certificate store options are available:\n#\n# - rustls-tls-native-roots: Uses rustls with native system certificate stores (ca-certificates on\n# Linux, Security.framework on macOS, Windows certificate store on Windows). Best for most users as\n# it integrates with system-managed certificates and gets security updates through the OS.\nrustls-tls-native-roots = [\n    \"librespot-core/rustls-tls-native-roots\",\n    \"librespot-oauth/rustls-tls-native-roots\",\n]\n# rustls-tls-webpki-roots: Uses rustls with Mozilla's compiled-in certificate store (webpki-roots).\n# Best for reproducible builds, containerized environments, or when you want certificate handling\n# to be independent of the host system.\nrustls-tls-webpki-roots = [\n    \"librespot-core/rustls-tls-webpki-roots\",\n    \"librespot-oauth/rustls-tls-webpki-roots\",\n]\n\n# Audio backends - see README.md for audio backend selection guide\n# Cross-platform backends:\n\n# rodio-backend: Cross-platform audio backend using Rodio (default). Provides good cross-platform\n# compatibility with automatic backend selection. Uses ALSA on Linux, WASAPI on Windows, CoreAudio\n# on macOS.\nrodio-backend = [\"librespot-playback/rodio-backend\"]\n\n# rodiojack-backend: Rodio backend with JACK support for professional audio setups.\nrodiojack-backend = [\"librespot-playback/rodiojack-backend\"]\n\n# gstreamer-backend: Uses GStreamer multimedia framework for audio output.\n# Provides extensive audio processing capabilities.\ngstreamer-backend = [\"librespot-playback/gstreamer-backend\"]\n\n# portaudio-backend: Cross-platform audio I/O library backend.\nportaudio-backend = [\"librespot-playback/portaudio-backend\"]\n\n# sdl-backend: Simple DirectMedia Layer audio backend.\nsdl-backend = [\"librespot-playback/sdl-backend\"]\n\n# Platform-specific backends:\n\n# alsa-backend: Advanced Linux Sound Architecture backend (Linux only).\n# Provides low-latency audio output on Linux systems.\nalsa-backend = [\"librespot-playback/alsa-backend\"]\n\n# pulseaudio-backend: PulseAudio backend (Linux only).\n# Integrates with the PulseAudio sound server for advanced audio routing.\npulseaudio-backend = [\"librespot-playback/pulseaudio-backend\"]\n\n# jackaudio-backend: JACK Audio Connection Kit backend.\n# Professional audio backend for low-latency, high-quality audio routing.\njackaudio-backend = [\"librespot-playback/jackaudio-backend\"]\n\n# Network discovery backends - choose one for Spotify Connect device discovery\n# See COMPILING.md for dependencies and platform support.\n\n# with-libmdns: Pure-Rust mDNS implementation (default).\n# No external dependencies, works on all platforms. Choose this for simple deployments or when\n# avoiding system dependencies.\nwith-libmdns = [\"librespot-discovery/with-libmdns\"]\n\n# with-avahi: Uses Avahi daemon for mDNS (Linux only).\n# Integrates with system's Avahi service for network discovery. Choose this when you want to\n# integrate with existing Avahi infrastructure or need advanced mDNS features. Requires\n# libavahi-client-dev.\nwith-avahi = [\"librespot-discovery/with-avahi\"]\n\n# with-dns-sd: Uses DNS Service Discovery (cross-platform).\n# On macOS uses Bonjour, on Linux uses Avahi compatibility layer. Choose this for tight system\n# integration on macOS or when using Avahi's dns-sd compatibility mode on Linux.\nwith-dns-sd = [\"librespot-discovery/with-dns-sd\"]\n\n# Audio processing features:\n\n# passthrough-decoder: Enables direct passthrough of Ogg Vorbis streams without decoding.\n# Useful for custom audio processing pipelines or when you want to handle audio decoding\n# externally. When enabled, audio is not decoded by librespot but passed through as raw Ogg Vorbis\n# data.\npassthrough-decoder = [\"librespot-playback/passthrough-decoder\"]\n\n[lib]\nname = \"librespot\"\npath = \"src/lib.rs\"\n\n[[bin]]\nname = \"librespot\"\npath = \"src/main.rs\"\ndoc = false\n\n[workspace.dependencies]\nlibrespot-audio = { version = \"0.8.0\", path = \"audio\", default-features = false }\nlibrespot-connect = { version = \"0.8.0\", path = \"connect\", default-features = false }\nlibrespot-core = { version = \"0.8.0\", path = \"core\", default-features = false }\nlibrespot-discovery = { version = \"0.8.0\", path = \"discovery\", default-features = false }\nlibrespot-metadata = { version = \"0.8.0\", path = \"metadata\", default-features = false }\nlibrespot-oauth = { version = \"0.8.0\", path = \"oauth\", default-features = false }\nlibrespot-playback = { version = \"0.8.0\", path = \"playback\", default-features = false }\nlibrespot-protocol = { version = \"0.8.0\", path = \"protocol\", default-features = false }\n\n[dependencies]\nlibrespot-audio.workspace = true\nlibrespot-connect.workspace = true\nlibrespot-core.workspace = true\nlibrespot-discovery.workspace = true\nlibrespot-metadata.workspace = true\nlibrespot-oauth.workspace = true\nlibrespot-playback.workspace = true\nlibrespot-protocol.workspace = true\n\ndata-encoding = \"2.5\"\nenv_logger = { version = \"0.11.2\", default-features = false, features = [\n    \"color\",\n    \"humantime\",\n    \"auto-color\",\n] }\nfutures-util = { version = \"0.3\", default-features = false }\ngetopts = \"0.2\"\nlog = \"0.4\"\nsha1 = \"0.10\"\nsysinfo = { version = \"0.36\", default-features = false, features = [\"system\"] }\nthiserror = \"2\"\ntokio = { version = \"1\", features = [\n    \"rt\",\n    \"macros\",\n    \"signal\",\n    \"sync\",\n    \"process\",\n] }\nurl = \"2.2\"\n\n[package.metadata.deb]\nmaintainer = \"Librespot Organization <noreply@github.com>\"\ncopyright = \"2015, Paul Liétar\"\nlicense-file = [\"LICENSE\", \"4\"]\ndepends = \"$auto\"\nrecommends = \"avahi-daemon\"\nextended-description = \"\"\"\\\nlibrespot is an open source client library for Spotify. It enables applications \\\nto use Spotify's service to control and play music via various backends, and to \\\nact as a Spotify Connect receiver. It is an alternative to the official and now \\\ndeprecated closed-source libspotify. Additionally, it provides extra features \\\nwhich are not available in the official library.\n.\nThis package provides the librespot binary for headless Spotify Connect playback. \\\n.\nNote: librespot only works with Spotify Premium accounts.\"\"\"\nsection = \"sound\"\npriority = \"optional\"\nassets = [\n    # Main binary\n    [\"target/release/librespot\", \"usr/bin/\", \"755\"],\n    # Documentation\n    [\"README.md\", \"usr/share/doc/librespot/\", \"644\"],\n    # Systemd services\n    [\"contrib/librespot.service\", \"lib/systemd/system/\", \"644\"],\n    [\"contrib/librespot.user.service\", \"lib/systemd/user/\", \"644\"],\n]\n\n[workspace.lints]\nclippy.redundant_closure_for_method_calls = \"warn\"\n\n[lints]\nworkspace = true\n"
  },
  {
    "path": "Cross.toml",
    "content": "[build]\npre-build = [\n    \"dpkg --add-architecture $CROSS_DEB_ARCH\",\n    \"apt-get update\",\n    \"apt-get --assume-yes install libssl-dev:$CROSS_DEB_ARCH libasound2-dev:$CROSS_DEB_ARCH\",\n]\n\n[target.riscv64gc-unknown-linux-gnu]\n# RISC-V: Uses rustls-tls (no system dependencies needed)\n# Building with --no-default-features --features rustls-tls\n# No pre-build steps required - rustls is pure Rust\npre-build = []\n"
  },
  {
    "path": "LICENSE",
    "content": "The MIT License (MIT)\n\nCopyright (c) 2015 Paul Lietar\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n"
  },
  {
    "path": "PUBLISHING.md",
    "content": "# Publishing\n\n## How To\n\n1. [prepare the release](#prepare-the-release)\n2. [create a github-release](#creating-a-github-release)\n\n### Prepare the release\n\nFor preparing the release a manuel workflow should be available that takes care of the common preparation. But \nthis can also be done manually if so desired. The workflow does:\n- upgrade the version according to the targeted release (`major`, `minor`, `patch`)\n  - `major` and `minor` require all crates to be updated\n  - `patch` instead only upgrades the crates that had any changes\n- updates the changelog according to Keep-A-Changelog convention\n- commits and pushes the changes to remote\n\n### Creating a github-release\n\nAfter everything is prepared for the new version. A [new release can be created](https://github.com/librespot-org/librespot/releases/new) \nfrom the ui. The tag will not be available as it isn't set by the prepare workflow, so a new tag needs to be created.\n\nThe tag and name of the release should be named like `v<version>` where `version` is the version of the binary to be\npublished. As release notes, copy the entries from the changelog for this release.\n\nThe release should be created as draft, which will trigger the workflow that will publish the changed crates and binary.\nThe workflow will:\n- check if all crates needs to be published or only certain crates\n- publish the crates in a specific order while excluding crates that didn't have any changes\n- publish the binary\n\nAfter the workflow was successful the version can be published.\n\n## Notes\n\nPublishing librespot to crates.io is a slightly convoluted affair due to the various dependencies that each package has \non other local packages. The order of publishing that has been found to work is as follows:\n> `protocol -> core -> audio -> metadata -> playback -> connect -> librespot`\n\nThe `protocol` package needs to be published with `cargo publish --no-verify` due to the build script modifying the \nsource during compile time. Publishing can be done using the command `cargo publish` in each of the directories of the \nrespective crate.\n"
  },
  {
    "path": "README.md",
    "content": "[![Build Status](https://github.com/librespot-org/librespot/workflows/build/badge.svg)](https://github.com/librespot-org/librespot/actions)\n[![Gitter chat](https://badges.gitter.im/librespot-org/librespot.png)](https://gitter.im/librespot-org/spotify-connect-resources)\n[![Crates.io](https://img.shields.io/crates/v/librespot.svg)](https://crates.io/crates/librespot)\n\nCurrent maintainers are [listed on GitHub](https://github.com/orgs/librespot-org/people).\n\n# librespot\n*librespot* is an open source client library for Spotify. It enables applications to use Spotify's service to control and play music via various backends, and to act as a Spotify Connect receiver. It is an alternative to the official and [now deprecated](https://pyspotify.mopidy.com/en/latest/#libspotify-s-deprecation) closed-source `libspotify`. Additionally, it will provide extra features which are not available in the official library.\n\n_Note: librespot only works with Spotify Premium. This will remain the case. We will not support any features to make librespot compatible with free accounts, such as limited skips and adverts._\n\n## Quick start\nWe're available on [crates.io](https://crates.io/crates/librespot) as the _librespot_ package. Simply run `cargo install librespot` to install librespot on your system. Check the wiki for more info and possible [usage options](https://github.com/librespot-org/librespot/wiki/Options).\n\nAfter installation, you can run librespot from the CLI using a command such as `librespot -n \"Librespot Speaker\" -b 160` to create a speaker called _Librespot Speaker_ serving 160 kbps audio.\n\n## This fork\nAs the origin by [plietar](https://github.com/plietar/) is no longer actively maintained, this organisation and repository have been set up so that the project may be maintained and upgraded in the future.\n\n# Documentation\nDocumentation is currently a work in progress, contributions are welcome!\n\nThere is some brief documentation on how the protocol works in the [docs](https://github.com/librespot-org/librespot/tree/master/docs) folder.\n\n[COMPILING.md](https://github.com/librespot-org/librespot/blob/master/COMPILING.md) contains detailed instructions on setting up a development environment, and compiling librespot. More general usage and compilation information is available on the [wiki](https://github.com/librespot-org/librespot/wiki).\n[CONTRIBUTING.md](https://github.com/librespot-org/librespot/blob/master/CONTRIBUTING.md) also contains our contributing guidelines.\n\nIf you wish to learn more about how librespot works overall, the best way is to simply read the code, and ask any questions you have in our [Gitter Room](https://gitter.im/librespot-org/spotify-connect-resources).\n\n# Issues & Discussions\n**We have recently started using Github discussions for general questions and feature requests, as they are a more natural medium for such cases, and allow for upvoting to prioritize feature development. Check them out [here](https://github.com/librespot-org/librespot/discussions). Bugs and issues with the underlying library should still be reported as issues.**\n\nIf you run into a bug when using librespot, please search the existing issues before opening a new one. Chances are, we've encountered it before, and have provided a resolution. If not, please open a new one, and where possible, include the backtrace librespot generates on crashing, along with anything we can use to reproduce the issue, e.g. the Spotify URI of the song that caused the crash.\n\n# Building\nA quick walkthrough of the build process is outlined below, while a detailed compilation guide can be found [here](https://github.com/librespot-org/librespot/blob/master/COMPILING.md).\n\n## Additional Dependencies\nWe recently switched to using [Rodio](https://github.com/tomaka/rodio) for audio playback by default, hence for macOS and Windows, you should just be able to clone and build librespot (with the command below).\nFor Linux, you will need to run the additional commands below, depending on your distro.\n\nOn Debian/Ubuntu, the following command will install these dependencies:\n```shell\nsudo apt-get install build-essential libasound2-dev\n```\n\nOn Fedora systems, the following command will install these dependencies:\n```shell\nsudo dnf install alsa-lib-devel make gcc\n```\n\nlibrespot currently offers the following selection of [audio backends](https://github.com/librespot-org/librespot/wiki/Audio-Backends):\n```\nRodio (default)\nALSA\nGStreamer\nPortAudio\nPulseAudio\nJACK\nJACK over Rodio\nSDL\nPipe\nSubprocess\n```\nPlease 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.\n\nOnce you've installed the dependencies and cloned this repository you can build *librespot* with the default features using Cargo.\n```shell\ncargo build --release\n```\n\nBy 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.\n\n# Packages\n\nlibrespot 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.\n\n[![Packaging status](https://repology.org/badge/vertical-allrepos/librespot.svg)](https://repology.org/project/librespot/versions)\n\n## Usage\nA sample program implementing a headless Spotify Connect receiver is provided.\nOnce you've built *librespot*, run it using :\n```shell\ntarget/release/librespot --name DEVICENAME\n```\n\nThe above is a minimal example. Here is a more fully fledged one:\n```shell\ntarget/release/librespot -n \"Librespot\" -b 320 -c ./cache --enable-volume-normalisation --initial-volume 75 --device-type avr\n```\nThe above command will create a receiver named ```Librespot```, with bitrate set to 320 kbps, initial volume at 75%, with volume normalisation enabled, and the device displayed in the app as an Audio/Video Receiver. A folder named ```cache``` will be created/used in the current directory, and be used to cache audio data and credentials.\n\nA full list of runtime options is available [here](https://github.com/librespot-org/librespot/wiki/Options).\n\n_Please Note: When using the cache feature, an authentication blob is stored for your account in the cache directory. For security purposes, we recommend that you set directory permissions on the cache directory to `700`._\n\n## Contact\nCome and hang out on gitter if you need help or want to offer some:\nhttps://gitter.im/librespot-org/spotify-connect-resources\n\n## Disclaimer\nUsing this code to connect to Spotify's API is probably forbidden by them.\nUse at your own risk.\n\n## License\nEverything in this repository is licensed under the MIT license.\n\n## Related Projects\nThis is a non exhaustive list of projects that either use or have modified librespot. If you'd like to include yours, submit a PR.\n\n- [librespot-golang](https://github.com/librespot-org/librespot-golang) - A golang port of librespot.\n- [plugin.audio.spotify](https://github.com/marcelveldt/plugin.audio.spotify) - A Kodi plugin for Spotify.\n- [raspotify](https://github.com/dtcooper/raspotify) - A Spotify Connect client that mostly Just Works™\n- [Spotifyd](https://github.com/Spotifyd/spotifyd) - A stripped down librespot UNIX daemon.\n- [rpi-audio-receiver](https://github.com/nicokaiser/rpi-audio-receiver) - easy Raspbian install scripts for Spotifyd, Bluetooth, Shairport and other audio receivers\n- [Spotcontrol](https://github.com/badfortrains/spotcontrol) - A golang implementation of a Spotify Connect controller. No Playback functionality.\n- [librespot-java](https://github.com/devgianlu/librespot-java) - A Java port of librespot.\n- [ncspot](https://github.com/hrkfdn/ncspot) - Cross-platform ncurses Spotify client.\n- [ansible-role-librespot](https://github.com/xMordax/ansible-role-librespot/tree/master) - Ansible role that will build, install and configure Librespot.\n- [Spot](https://github.com/xou816/spot) - Gtk/Rust native Spotify client for the GNOME desktop.\n- [Snapcast](https://github.com/badaix/snapcast) - synchronised multi-room audio player that uses librespot as its source for Spotify content\n- [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.\n- [RoPieee](https://ropieee.org) - An easy-to-use Raspberry Pi image for network audio streaming solutions.\n"
  },
  {
    "path": "SECURITY.md",
    "content": "# Security Policy\n\n## Supported Versions\n\nWe will support the latest release and main development branch with security updates.\n\n## Reporting a Vulnerability\n\nIf you believe to have found a vulnerability in `librespot` itself or as a result from\none of its dependencies, please report it by contacting one or more of the active\nmaintainers directly, allowing no less than three calendar days to receive a response.\n\nIf you believe that the vulnerability is public knowledge or already being exploited\nin the wild, regardless of having received a response to your direct messages or not,\nplease create an issue report to warn other users about continued use and instruct\nthem on any known workarounds.\n\nOn your report you may expect feedback on whether we believe that the vulnerability\nis indeed applicable and if so, when and how it may be fixed. You may expect to\nbe asked for assistance with review and testing.\n"
  },
  {
    "path": "audio/Cargo.toml",
    "content": "[package]\nname = \"librespot-audio\"\nversion = \"0.8.0\"\nrust-version.workspace = true\nauthors = [\"Paul Lietar <paul@lietar.net>\"]\nlicense.workspace = true\ndescription = \"The audio fetching logic for librespot\"\nrepository.workspace = true\nedition.workspace = true\n\n[features]\n# Refer to the workspace Cargo.toml for the list of features\ndefault = [\"native-tls\"]\n\n# TLS backend propagation\nnative-tls = [\"librespot-core/native-tls\"]\nrustls-tls-native-roots = [\"librespot-core/rustls-tls-native-roots\"]\nrustls-tls-webpki-roots = [\"librespot-core/rustls-tls-webpki-roots\"]\n\n[dependencies]\nlibrespot-core = { version = \"0.8.0\", path = \"../core\", default-features = false }\n\naes = \"0.8\"\nbytes = \"1\"\nctr = \"0.9\"\nfutures-util = { version = \"0.3\", default-features = false, features = [\"std\"] }\nhttp-body-util = \"0.1\"\nhyper = { version = \"1.6\", features = [\"http1\", \"http2\"] }\nhyper-util = { version = \"0.1\", features = [\"client\", \"http2\"] }\nlog = \"0.4\"\ntempfile = \"3\"\nthiserror = \"2\"\ntokio = { version = \"1\", features = [\"macros\", \"sync\"] }\n\n[lints]\nworkspace = true\n"
  },
  {
    "path": "audio/src/decrypt.rs",
    "content": "use std::io;\n\nuse aes::cipher::{KeyIvInit, StreamCipher, StreamCipherSeek};\n\ntype Aes128Ctr = ctr::Ctr128BE<aes::Aes128>;\n\nuse librespot_core::audio_key::AudioKey;\n\nconst AUDIO_AESIV: [u8; 16] = [\n    0x72, 0xe0, 0x67, 0xfb, 0xdd, 0xcb, 0xcf, 0x77, 0xeb, 0xe8, 0xbc, 0x64, 0x3f, 0x63, 0x0d, 0x93,\n];\n\npub struct AudioDecrypt<T: io::Read> {\n    // a `None` cipher is a convenience to make `AudioDecrypt` pass files unaltered\n    cipher: Option<Aes128Ctr>,\n    reader: T,\n}\n\nimpl<T: io::Read> AudioDecrypt<T> {\n    pub fn new(key: Option<AudioKey>, reader: T) -> AudioDecrypt<T> {\n        let cipher = if let Some(key) = key {\n            Aes128Ctr::new_from_slices(&key.0, &AUDIO_AESIV).ok()\n        } else {\n            // some files are unencrypted\n            None\n        };\n\n        AudioDecrypt { cipher, reader }\n    }\n}\n\nimpl<T: io::Read> io::Read for AudioDecrypt<T> {\n    fn read(&mut self, output: &mut [u8]) -> io::Result<usize> {\n        let len = self.reader.read(output)?;\n\n        if let Some(ref mut cipher) = self.cipher {\n            cipher.apply_keystream(&mut output[..len]);\n        }\n\n        Ok(len)\n    }\n}\n\nimpl<T: io::Read + io::Seek> io::Seek for AudioDecrypt<T> {\n    fn seek(&mut self, pos: io::SeekFrom) -> io::Result<u64> {\n        let newpos = self.reader.seek(pos)?;\n\n        if let Some(ref mut cipher) = self.cipher {\n            cipher.seek(newpos);\n        }\n\n        Ok(newpos)\n    }\n}\n"
  },
  {
    "path": "audio/src/fetch/mod.rs",
    "content": "mod receive;\n\nuse std::{\n    cmp::min,\n    fs,\n    io::{self, Read, Seek, SeekFrom},\n    sync::{\n        Arc, OnceLock,\n        atomic::{AtomicBool, AtomicUsize, Ordering},\n    },\n    sync::{Condvar, Mutex},\n    time::Duration,\n};\n\nuse futures_util::{StreamExt, TryFutureExt, future::IntoStream};\nuse hyper::{Response, StatusCode, body::Incoming, header::CONTENT_RANGE};\nuse hyper_util::client::legacy::ResponseFuture;\n\nuse tempfile::NamedTempFile;\nuse thiserror::Error;\nuse tokio::sync::{Semaphore, mpsc, oneshot};\n\nuse librespot_core::{Error, FileId, Session, cdn_url::CdnUrl};\n\nuse self::receive::audio_file_fetch;\n\nuse crate::range_set::{Range, RangeSet};\n\npub type AudioFileResult = Result<(), librespot_core::Error>;\n\nconst DOWNLOAD_STATUS_POISON_MSG: &str = \"audio download status mutex should not be poisoned\";\n\n#[derive(Error, Debug)]\npub enum AudioFileError {\n    #[error(\"other end of channel disconnected\")]\n    Channel,\n    #[error(\"required header not found\")]\n    Header,\n    #[error(\"streamer received no data\")]\n    NoData,\n    #[error(\"no output available\")]\n    Output,\n    #[error(\"invalid status code {0}\")]\n    StatusCode(StatusCode),\n    #[error(\"wait timeout exceeded\")]\n    WaitTimeout,\n}\n\nimpl From<AudioFileError> for Error {\n    fn from(err: AudioFileError) -> Self {\n        match err {\n            AudioFileError::Channel => Error::aborted(err),\n            AudioFileError::Header => Error::unavailable(err),\n            AudioFileError::NoData => Error::unavailable(err),\n            AudioFileError::Output => Error::aborted(err),\n            AudioFileError::StatusCode(_) => Error::failed_precondition(err),\n            AudioFileError::WaitTimeout => Error::deadline_exceeded(err),\n        }\n    }\n}\n\n#[derive(Clone)]\npub struct AudioFetchParams {\n    /// The minimum size of a block that is requested from the Spotify servers in one request.\n    /// This is the block size that is typically requested while doing a `seek()` on a file.\n    /// The Symphonia decoder requires this to be a power of 2 and > 32 kB.\n    /// Note: smaller requests can happen if part of the block is downloaded already.\n    pub minimum_download_size: usize,\n\n    /// The minimum network throughput that we expect. Together with the minimum download size,\n    /// this will determine the time we will wait for a response.\n    pub minimum_throughput: usize,\n\n    /// The ping time that is used for calculations before a ping time was actually measured.\n    pub initial_ping_time_estimate: Duration,\n\n    /// If the measured ping time to the Spotify server is larger than this value, it is capped\n    /// to avoid run-away block sizes and pre-fetching.\n    pub maximum_assumed_ping_time: Duration,\n\n    /// Before playback starts, this many seconds of data must be present.\n    /// Note: the calculations are done using the nominal bitrate of the file. The actual amount\n    /// of audio data may be larger or smaller.\n    pub read_ahead_before_playback: Duration,\n\n    /// While playing back, this many seconds of data ahead of the current read position are\n    /// requested.\n    /// Note: the calculations are done using the nominal bitrate of the file. The actual amount\n    /// of audio data may be larger or smaller.\n    pub read_ahead_during_playback: Duration,\n\n    /// If the amount of data that is pending (requested but not received) is less than a certain amount,\n    /// data is pre-fetched in addition to the read ahead settings above. The threshold for requesting more\n    /// data is calculated as `<pending bytes> < PREFETCH_THRESHOLD_FACTOR * <ping time> * <nominal data rate>`\n    pub prefetch_threshold_factor: f32,\n\n    /// The time we will wait to obtain status updates on downloading.\n    pub download_timeout: Duration,\n}\n\nimpl Default for AudioFetchParams {\n    fn default() -> Self {\n        let minimum_download_size = 64 * 1024;\n        let minimum_throughput = 8 * 1024;\n        Self {\n            minimum_download_size,\n            minimum_throughput,\n            initial_ping_time_estimate: Duration::from_millis(500),\n            maximum_assumed_ping_time: Duration::from_millis(1500),\n            read_ahead_before_playback: Duration::from_secs(1),\n            read_ahead_during_playback: Duration::from_secs(5),\n            prefetch_threshold_factor: 4.0,\n            download_timeout: Duration::from_secs(\n                (minimum_download_size / minimum_throughput) as u64,\n            ),\n        }\n    }\n}\n\nstatic AUDIO_FETCH_PARAMS: OnceLock<AudioFetchParams> = OnceLock::new();\n\nimpl AudioFetchParams {\n    pub fn set(params: AudioFetchParams) -> Result<(), AudioFetchParams> {\n        AUDIO_FETCH_PARAMS.set(params)\n    }\n\n    pub fn get() -> &'static AudioFetchParams {\n        AUDIO_FETCH_PARAMS.get_or_init(AudioFetchParams::default)\n    }\n}\n\npub enum AudioFile {\n    Cached(fs::File),\n    Streaming(AudioFileStreaming),\n}\n\n#[derive(Debug)]\npub struct StreamingRequest {\n    streamer: IntoStream<ResponseFuture>,\n    initial_response: Option<Response<Incoming>>,\n    offset: usize,\n    length: usize,\n}\n\n#[derive(Debug)]\npub enum StreamLoaderCommand {\n    Fetch(Range), // signal the stream loader to fetch a range of the file\n    Close,        // terminate and don't load any more data\n}\n\n#[derive(Clone)]\npub struct StreamLoaderController {\n    channel_tx: Option<mpsc::UnboundedSender<StreamLoaderCommand>>,\n    stream_shared: Option<Arc<AudioFileShared>>,\n    file_size: usize,\n}\n\nimpl StreamLoaderController {\n    pub fn len(&self) -> usize {\n        self.file_size\n    }\n\n    pub fn is_empty(&self) -> bool {\n        self.file_size == 0\n    }\n\n    pub fn range_available(&self, range: Range) -> bool {\n        if let Some(ref shared) = self.stream_shared {\n            let download_status = shared\n                .download_status\n                .lock()\n                .expect(DOWNLOAD_STATUS_POISON_MSG);\n\n            range.length\n                <= download_status\n                    .downloaded\n                    .contained_length_from_value(range.start)\n        } else {\n            range.length <= self.len() - range.start\n        }\n    }\n\n    pub fn range_to_end_available(&self) -> bool {\n        match self.stream_shared {\n            Some(ref shared) => {\n                let read_position = shared.read_position();\n                self.range_available(Range::new(read_position, self.len() - read_position))\n            }\n            None => true,\n        }\n    }\n\n    pub fn ping_time(&self) -> Option<Duration> {\n        self.stream_shared.as_ref().map(|shared| shared.ping_time())\n    }\n\n    fn send_stream_loader_command(&self, command: StreamLoaderCommand) {\n        if let Some(ref channel) = self.channel_tx {\n            // Ignore the error in case the channel has been closed already.\n            // This means that the file was completely downloaded.\n            let _ = channel.send(command);\n        }\n    }\n\n    pub fn fetch(&self, range: Range) {\n        // signal the stream loader to fetch a range of the file\n        self.send_stream_loader_command(StreamLoaderCommand::Fetch(range));\n    }\n\n    pub fn fetch_blocking(&self, mut range: Range) -> AudioFileResult {\n        // signal the stream loader to tech a range of the file and block until it is loaded.\n\n        // ensure the range is within the file's bounds.\n        if range.start >= self.len() {\n            range.length = 0;\n        } else if range.end() > self.len() {\n            range.length = self.len() - range.start;\n        }\n\n        self.fetch(range);\n\n        if let Some(ref shared) = self.stream_shared {\n            let mut download_status = shared\n                .download_status\n                .lock()\n                .expect(DOWNLOAD_STATUS_POISON_MSG);\n            let download_timeout = AudioFetchParams::get().download_timeout;\n\n            while range.length\n                > download_status\n                    .downloaded\n                    .contained_length_from_value(range.start)\n            {\n                let (new_download_status, wait_result) = shared\n                    .cond\n                    .wait_timeout(download_status, download_timeout)\n                    .expect(DOWNLOAD_STATUS_POISON_MSG);\n\n                download_status = new_download_status;\n                if wait_result.timed_out() {\n                    return Err(AudioFileError::WaitTimeout.into());\n                }\n\n                if range.length\n                    > (download_status\n                        .downloaded\n                        .union(&download_status.requested)\n                        .contained_length_from_value(range.start))\n                {\n                    // For some reason, the requested range is neither downloaded nor requested.\n                    // This could be due to a network error. Request it again.\n                    self.fetch(range);\n                }\n            }\n        }\n\n        Ok(())\n    }\n\n    pub fn fetch_next_and_wait(\n        &self,\n        request_length: usize,\n        wait_length: usize,\n    ) -> AudioFileResult {\n        match self.stream_shared {\n            Some(ref shared) => {\n                let start = shared.read_position();\n\n                let request_range = Range {\n                    start,\n                    length: request_length,\n                };\n                self.fetch(request_range);\n\n                let wait_range = Range {\n                    start,\n                    length: wait_length,\n                };\n                self.fetch_blocking(wait_range)\n            }\n            None => Ok(()),\n        }\n    }\n\n    pub fn set_random_access_mode(&self) {\n        // optimise download strategy for random access\n        if let Some(ref shared) = self.stream_shared {\n            shared.set_download_streaming(false)\n        }\n    }\n\n    pub fn set_stream_mode(&self) {\n        // optimise download strategy for streaming\n        if let Some(ref shared) = self.stream_shared {\n            shared.set_download_streaming(true)\n        }\n    }\n\n    pub fn close(&self) {\n        // terminate stream loading and don't load any more data for this file.\n        self.send_stream_loader_command(StreamLoaderCommand::Close);\n    }\n\n    pub fn from_local_file(file_size: u64) -> Self {\n        Self {\n            channel_tx: None,\n            stream_shared: None,\n            file_size: file_size as usize,\n        }\n    }\n}\n\npub struct AudioFileStreaming {\n    read_file: fs::File,\n    position: u64,\n    stream_loader_command_tx: mpsc::UnboundedSender<StreamLoaderCommand>,\n    shared: Arc<AudioFileShared>,\n}\n\nstruct AudioFileDownloadStatus {\n    requested: RangeSet,\n    downloaded: RangeSet,\n}\n\nstruct AudioFileShared {\n    cdn_url: String,\n    file_size: usize,\n    bytes_per_second: usize,\n    cond: Condvar,\n    download_status: Mutex<AudioFileDownloadStatus>,\n    download_streaming: AtomicBool,\n    download_slots: Semaphore,\n    ping_time_ms: AtomicUsize,\n    read_position: AtomicUsize,\n    throughput: AtomicUsize,\n}\n\nimpl AudioFileShared {\n    fn is_download_streaming(&self) -> bool {\n        self.download_streaming.load(Ordering::Acquire)\n    }\n\n    fn set_download_streaming(&self, streaming: bool) {\n        self.download_streaming.store(streaming, Ordering::Release)\n    }\n\n    fn ping_time(&self) -> Duration {\n        let ping_time_ms = self.ping_time_ms.load(Ordering::Acquire);\n        if ping_time_ms > 0 {\n            Duration::from_millis(ping_time_ms as u64)\n        } else {\n            AudioFetchParams::get().initial_ping_time_estimate\n        }\n    }\n\n    fn set_ping_time(&self, duration: Duration) {\n        self.ping_time_ms\n            .store(duration.as_millis() as usize, Ordering::Release)\n    }\n\n    fn throughput(&self) -> usize {\n        self.throughput.load(Ordering::Acquire)\n    }\n\n    fn set_throughput(&self, throughput: usize) {\n        self.throughput.store(throughput, Ordering::Release)\n    }\n\n    fn read_position(&self) -> usize {\n        self.read_position.load(Ordering::Acquire)\n    }\n\n    fn set_read_position(&self, position: u64) {\n        self.read_position\n            .store(position as usize, Ordering::Release)\n    }\n}\n\nimpl AudioFile {\n    pub async fn open(\n        session: &Session,\n        file_id: FileId,\n        bytes_per_second: usize,\n    ) -> Result<AudioFile, Error> {\n        if let Some(file) = session.cache().and_then(|cache| cache.file(file_id)) {\n            debug!(\"File {file_id} already in cache\");\n            return Ok(AudioFile::Cached(file));\n        }\n\n        debug!(\"Downloading file {file_id}\");\n\n        let (complete_tx, complete_rx) = oneshot::channel();\n\n        let streaming =\n            AudioFileStreaming::open(session.clone(), file_id, complete_tx, bytes_per_second);\n\n        let session_ = session.clone();\n        session.spawn(complete_rx.map_ok(move |mut file| {\n            debug!(\"Downloading file {file_id} complete\");\n\n            if let Some(cache) = session_.cache() {\n                if let Some(cache_id) = cache.file_path(file_id) {\n                    if let Err(e) = cache.save_file(file_id, &mut file) {\n                        error!(\"Error caching file {file_id} to {cache_id:?}: {e}\");\n                    } else {\n                        debug!(\"File {file_id} cached to {cache_id:?}\");\n                    }\n                }\n            }\n        }));\n\n        Ok(AudioFile::Streaming(streaming.await?))\n    }\n\n    pub fn get_stream_loader_controller(&self) -> Result<StreamLoaderController, Error> {\n        let controller = match self {\n            AudioFile::Streaming(stream) => StreamLoaderController {\n                channel_tx: Some(stream.stream_loader_command_tx.clone()),\n                stream_shared: Some(stream.shared.clone()),\n                file_size: stream.shared.file_size,\n            },\n            AudioFile::Cached(file) => StreamLoaderController {\n                channel_tx: None,\n                stream_shared: None,\n                file_size: file.metadata()?.len() as usize,\n            },\n        };\n\n        Ok(controller)\n    }\n\n    pub fn is_cached(&self) -> bool {\n        matches!(self, AudioFile::Cached { .. })\n    }\n}\n\nimpl AudioFileStreaming {\n    pub async fn open(\n        session: Session,\n        file_id: FileId,\n        complete_tx: oneshot::Sender<NamedTempFile>,\n        bytes_per_second: usize,\n    ) -> Result<AudioFileStreaming, Error> {\n        let cdn_url = CdnUrl::new(file_id).resolve_audio(&session).await?;\n\n        let minimum_download_size = AudioFetchParams::get().minimum_download_size;\n\n        let mut response_streamer_url = None;\n        let urls = cdn_url.try_get_urls()?;\n        for url in &urls {\n            // When the audio file is really small, this `download_size` may turn out to be\n            // larger than the audio file we're going to stream later on. This is OK; requesting\n            // `Content-Range` > `Content-Length` will return the complete file with status code\n            // 206 Partial Content.\n            let mut streamer =\n                session\n                    .spclient()\n                    .stream_from_cdn(*url, 0, minimum_download_size)?;\n\n            // Get the first chunk with the headers to get the file size.\n            // The remainder of that chunk with possibly also a response body is then\n            // further processed in `audio_file_fetch`.\n            let streamer_result = tokio::time::timeout(Duration::from_secs(10), streamer.next())\n                .await\n                .map_err(|_| AudioFileError::WaitTimeout.into())\n                .and_then(|x| x.ok_or_else(|| AudioFileError::NoData.into()))\n                .and_then(|x| x.map_err(Error::from));\n\n            match streamer_result {\n                Ok(r) => {\n                    response_streamer_url = Some((r, streamer, url));\n                    break;\n                }\n                Err(e) => warn!(\"Fetching {url} failed with error {e:?}, trying next\"),\n            }\n        }\n\n        let Some((response, streamer, url)) = response_streamer_url else {\n            return Err(Error::unavailable(format!(\n                \"{} URLs failed, none left to try\",\n                urls.len()\n            )));\n        };\n\n        trace!(\"Streaming from {url}\");\n\n        let code = response.status();\n        if code != StatusCode::PARTIAL_CONTENT {\n            debug!(\"Opening audio file expected partial content but got: {code}\");\n            return Err(AudioFileError::StatusCode(code).into());\n        }\n\n        let header_value = response\n            .headers()\n            .get(CONTENT_RANGE)\n            .ok_or(AudioFileError::Header)?;\n        let str_value = header_value.to_str()?;\n        let hyphen_index = str_value.find('-').unwrap_or_default();\n        let slash_index = str_value.find('/').unwrap_or_default();\n        let upper_bound: usize = str_value[hyphen_index + 1..slash_index].parse()?;\n        let file_size = str_value[slash_index + 1..].parse()?;\n\n        let initial_request = StreamingRequest {\n            streamer,\n            initial_response: Some(response),\n            offset: 0,\n            length: upper_bound + 1,\n        };\n\n        let shared = Arc::new(AudioFileShared {\n            cdn_url: url.to_string(),\n            file_size,\n            bytes_per_second,\n            cond: Condvar::new(),\n            download_status: Mutex::new(AudioFileDownloadStatus {\n                requested: RangeSet::new(),\n                downloaded: RangeSet::new(),\n            }),\n            download_streaming: AtomicBool::new(false),\n            download_slots: Semaphore::new(1),\n            ping_time_ms: AtomicUsize::new(0),\n            read_position: AtomicUsize::new(0),\n            throughput: AtomicUsize::new(0),\n        });\n\n        let write_file = NamedTempFile::new_in(session.config().tmp_dir.clone())?;\n        write_file.as_file().set_len(file_size as u64)?;\n\n        let read_file = write_file.reopen()?;\n\n        let (stream_loader_command_tx, stream_loader_command_rx) =\n            mpsc::unbounded_channel::<StreamLoaderCommand>();\n\n        session.spawn(audio_file_fetch(\n            session.clone(),\n            shared.clone(),\n            initial_request,\n            write_file,\n            stream_loader_command_rx,\n            complete_tx,\n        ));\n\n        Ok(AudioFileStreaming {\n            read_file,\n            position: 0,\n            stream_loader_command_tx,\n            shared,\n        })\n    }\n}\n\nimpl Read for AudioFileStreaming {\n    fn read(&mut self, output: &mut [u8]) -> io::Result<usize> {\n        let offset = self.position as usize;\n\n        if offset >= self.shared.file_size {\n            return Ok(0);\n        }\n\n        let length = min(output.len(), self.shared.file_size - offset);\n        if length == 0 {\n            return Ok(0);\n        }\n\n        let read_ahead_during_playback = AudioFetchParams::get().read_ahead_during_playback;\n        let length_to_request = if self.shared.is_download_streaming() {\n            let length_to_request = length\n                + (read_ahead_during_playback.as_secs_f32() * self.shared.bytes_per_second as f32)\n                    as usize;\n\n            // Due to the read-ahead stuff, we potentially request more than the actual request demanded.\n            min(length_to_request, self.shared.file_size - offset)\n        } else {\n            length\n        };\n\n        let mut ranges_to_request = RangeSet::new();\n        ranges_to_request.add_range(&Range::new(offset, length_to_request));\n\n        let mut download_status = self\n            .shared\n            .download_status\n            .lock()\n            .expect(DOWNLOAD_STATUS_POISON_MSG);\n\n        ranges_to_request.subtract_range_set(&download_status.downloaded);\n        ranges_to_request.subtract_range_set(&download_status.requested);\n\n        for &range in ranges_to_request.iter() {\n            self.stream_loader_command_tx\n                .send(StreamLoaderCommand::Fetch(range))\n                .map_err(|err| io::Error::new(io::ErrorKind::BrokenPipe, err))?;\n        }\n\n        let download_timeout = AudioFetchParams::get().download_timeout;\n        while !download_status.downloaded.contains(offset) {\n            let (new_download_status, wait_result) = self\n                .shared\n                .cond\n                .wait_timeout(download_status, download_timeout)\n                .expect(DOWNLOAD_STATUS_POISON_MSG);\n\n            download_status = new_download_status;\n            if wait_result.timed_out() {\n                return Err(io::Error::new(\n                    io::ErrorKind::TimedOut,\n                    Error::deadline_exceeded(AudioFileError::WaitTimeout),\n                ));\n            }\n        }\n        let available_length = download_status\n            .downloaded\n            .contained_length_from_value(offset);\n\n        drop(download_status);\n\n        self.position = self.read_file.seek(SeekFrom::Start(offset as u64))?;\n        let read_len = min(length, available_length);\n        let read_len = self.read_file.read(&mut output[..read_len])?;\n\n        self.position += read_len as u64;\n        self.shared.set_read_position(self.position);\n\n        Ok(read_len)\n    }\n}\n\nimpl Seek for AudioFileStreaming {\n    fn seek(&mut self, pos: SeekFrom) -> io::Result<u64> {\n        // If we are already at this position, we don't need to switch download mode.\n        // These checks and locks are less expensive than interrupting streaming.\n        let current_position = self.position as i64;\n        let requested_pos = match pos {\n            SeekFrom::Start(pos) => pos as i64,\n            SeekFrom::End(pos) => self.shared.file_size as i64 - pos - 1,\n            SeekFrom::Current(pos) => current_position + pos,\n        };\n        if requested_pos == current_position {\n            return Ok(current_position as u64);\n        }\n\n        // Again if we have already downloaded this part.\n        let available = self\n            .shared\n            .download_status\n            .lock()\n            .expect(DOWNLOAD_STATUS_POISON_MSG)\n            .downloaded\n            .contains(requested_pos as usize);\n\n        let mut was_streaming = false;\n        if !available {\n            // Ensure random access mode if we need to download this part.\n            // Checking whether we are streaming now is a micro-optimization\n            // to save an atomic load.\n            was_streaming = self.shared.is_download_streaming();\n            if was_streaming {\n                self.shared.set_download_streaming(false);\n            }\n        }\n\n        self.position = self.read_file.seek(pos)?;\n        self.shared.set_read_position(self.position);\n\n        if !available && was_streaming {\n            self.shared.set_download_streaming(true);\n        }\n\n        Ok(self.position)\n    }\n}\n\nimpl Read for AudioFile {\n    fn read(&mut self, output: &mut [u8]) -> io::Result<usize> {\n        match *self {\n            AudioFile::Cached(ref mut file) => file.read(output),\n            AudioFile::Streaming(ref mut file) => file.read(output),\n        }\n    }\n}\n\nimpl Seek for AudioFile {\n    fn seek(&mut self, pos: SeekFrom) -> io::Result<u64> {\n        match *self {\n            AudioFile::Cached(ref mut file) => file.seek(pos),\n            AudioFile::Streaming(ref mut file) => file.seek(pos),\n        }\n    }\n}\n"
  },
  {
    "path": "audio/src/fetch/receive.rs",
    "content": "use std::{\n    cmp::{max, min},\n    io::{Seek, SeekFrom, Write},\n    sync::Arc,\n    time::{Duration, Instant},\n};\n\nuse bytes::Bytes;\nuse futures_util::StreamExt;\nuse http_body_util::BodyExt;\nuse hyper::StatusCode;\nuse tempfile::NamedTempFile;\nuse tokio::sync::{mpsc, oneshot};\n\nuse librespot_core::{Error, http_client::HttpClient, session::Session};\n\nuse crate::range_set::{Range, RangeSet};\n\nuse super::{\n    AudioFetchParams, AudioFileError, AudioFileResult, AudioFileShared, StreamLoaderCommand,\n    StreamingRequest,\n};\n\nstruct PartialFileData {\n    offset: usize,\n    data: Bytes,\n}\n\nenum ReceivedData {\n    Throughput(usize),\n    ResponseTime(Duration),\n    Data(PartialFileData),\n}\n\nconst ONE_SECOND: Duration = Duration::from_secs(1);\nconst DOWNLOAD_STATUS_POISON_MSG: &str = \"audio download status mutex should not be poisoned\";\n\nasync fn receive_data(\n    shared: Arc<AudioFileShared>,\n    file_data_tx: mpsc::UnboundedSender<ReceivedData>,\n    mut request: StreamingRequest,\n) -> AudioFileResult {\n    let mut offset = request.offset;\n    let mut actual_length = 0;\n\n    let permit = shared.download_slots.acquire().await?;\n\n    let request_time = Instant::now();\n    let mut measure_ping_time = true;\n    let mut measure_throughput = true;\n\n    let result: Result<_, Error> = loop {\n        let response = match request.initial_response.take() {\n            Some(data) => {\n                // the request was already made outside of this function\n                measure_ping_time = false;\n                measure_throughput = false;\n\n                data\n            }\n            None => match request.streamer.next().await {\n                Some(Ok(response)) => response,\n                Some(Err(e)) => break Err(e.into()),\n                None => {\n                    if actual_length != request.length {\n                        let msg = format!(\"did not expect body to contain {actual_length} bytes\");\n                        break Err(Error::data_loss(msg));\n                    }\n\n                    break Ok(());\n                }\n            },\n        };\n\n        if measure_ping_time {\n            let duration = Instant::now().duration_since(request_time);\n            // may be zero if we are handling an initial response\n            if duration.as_millis() > 0 {\n                file_data_tx.send(ReceivedData::ResponseTime(duration))?;\n                measure_ping_time = false;\n            }\n        }\n\n        let code = response.status();\n        if code != StatusCode::PARTIAL_CONTENT {\n            if code == StatusCode::TOO_MANY_REQUESTS {\n                if let Some(duration) = HttpClient::get_retry_after(response.headers()) {\n                    warn!(\n                        \"Rate limiting, retrying in {} seconds...\",\n                        duration.as_secs()\n                    );\n                    // sleeping here means we hold onto this streamer \"slot\"\n                    // (we don't decrease the number of open requests)\n                    tokio::time::sleep(duration).await;\n                }\n            }\n\n            break Err(AudioFileError::StatusCode(code).into());\n        }\n\n        let body = response.into_body();\n        let data = match body\n            .collect()\n            .await\n            .map(http_body_util::Collected::to_bytes)\n        {\n            Ok(bytes) => bytes,\n            Err(e) => break Err(e.into()),\n        };\n\n        let data_size = data.len();\n        file_data_tx.send(ReceivedData::Data(PartialFileData { offset, data }))?;\n\n        actual_length += data_size;\n        offset += data_size;\n    };\n\n    drop(request.streamer);\n\n    if measure_throughput {\n        let duration = Instant::now().duration_since(request_time).as_millis();\n        if actual_length > 0 && duration > 0 {\n            let throughput = ONE_SECOND.as_millis() * actual_length as u128 / duration;\n            file_data_tx.send(ReceivedData::Throughput(throughput as usize))?;\n        }\n    }\n\n    let bytes_remaining = request.length - actual_length;\n    if bytes_remaining > 0 {\n        {\n            let missing_range = Range::new(offset, bytes_remaining);\n            let mut download_status = shared\n                .download_status\n                .lock()\n                .expect(DOWNLOAD_STATUS_POISON_MSG);\n            download_status.requested.subtract_range(&missing_range);\n            shared.cond.notify_all();\n        }\n    }\n\n    drop(permit);\n\n    if let Err(e) = result {\n        error!(\n            \"Streamer error requesting range {} +{}: {:?}\",\n            request.offset, request.length, e\n        );\n        return Err(e);\n    }\n\n    Ok(())\n}\n\nstruct AudioFileFetch {\n    session: Session,\n    shared: Arc<AudioFileShared>,\n    output: Option<NamedTempFile>,\n\n    file_data_tx: mpsc::UnboundedSender<ReceivedData>,\n    complete_tx: Option<oneshot::Sender<NamedTempFile>>,\n    network_response_times: Vec<Duration>,\n\n    params: AudioFetchParams,\n}\n\n// Might be replaced by enum from std once stable\n#[derive(PartialEq, Eq)]\nenum ControlFlow {\n    Break,\n    Continue,\n}\n\nimpl AudioFileFetch {\n    fn has_download_slots_available(&self) -> bool {\n        self.shared.download_slots.available_permits() > 0\n    }\n\n    fn download_range(&mut self, offset: usize, mut length: usize) -> AudioFileResult {\n        if length < self.params.minimum_download_size {\n            length = self.params.minimum_download_size;\n        }\n\n        // If we are in streaming mode (so not seeking) then start downloading as large\n        // of chunks as possible for better throughput and improved CPU usage, while\n        // still being reasonably responsive (~1 second) in case we want to seek.\n        if self.shared.is_download_streaming() {\n            let throughput = self.shared.throughput();\n            length = max(length, throughput);\n        }\n\n        if offset + length > self.shared.file_size {\n            length = self.shared.file_size - offset;\n        }\n        let mut ranges_to_request = RangeSet::new();\n        ranges_to_request.add_range(&Range::new(offset, length));\n\n        // The iteration that follows spawns streamers fast, without awaiting them,\n        // so holding the lock for the entire scope of this function should be faster\n        // then locking and unlocking multiple times.\n        let mut download_status = self\n            .shared\n            .download_status\n            .lock()\n            .expect(DOWNLOAD_STATUS_POISON_MSG);\n\n        ranges_to_request.subtract_range_set(&download_status.downloaded);\n        ranges_to_request.subtract_range_set(&download_status.requested);\n\n        // TODO : refresh cdn_url when the token expired\n\n        for range in ranges_to_request.iter() {\n            let streamer = self.session.spclient().stream_from_cdn(\n                &self.shared.cdn_url,\n                range.start,\n                range.length,\n            )?;\n\n            download_status.requested.add_range(range);\n\n            let streaming_request = StreamingRequest {\n                streamer,\n                initial_response: None,\n                offset: range.start,\n                length: range.length,\n            };\n\n            self.session.spawn(receive_data(\n                self.shared.clone(),\n                self.file_data_tx.clone(),\n                streaming_request,\n            ));\n        }\n\n        Ok(())\n    }\n\n    fn pre_fetch_more_data(&mut self, bytes: usize) -> AudioFileResult {\n        // determine what is still missing\n        let mut missing_data = RangeSet::new();\n        missing_data.add_range(&Range::new(0, self.shared.file_size));\n        {\n            let download_status = self\n                .shared\n                .download_status\n                .lock()\n                .expect(DOWNLOAD_STATUS_POISON_MSG);\n            missing_data.subtract_range_set(&download_status.downloaded);\n            missing_data.subtract_range_set(&download_status.requested);\n        }\n\n        // download data from after the current read position first\n        let mut tail_end = RangeSet::new();\n        let read_position = self.shared.read_position();\n        tail_end.add_range(&Range::new(\n            read_position,\n            self.shared.file_size - read_position,\n        ));\n        let tail_end = tail_end.intersection(&missing_data);\n\n        if !tail_end.is_empty() {\n            let range = tail_end.get_range(0);\n            let offset = range.start;\n            let length = min(range.length, bytes);\n            self.download_range(offset, length)?;\n        } else if !missing_data.is_empty() {\n            // ok, the tail is downloaded, download something fom the beginning.\n            let range = missing_data.get_range(0);\n            let offset = range.start;\n            let length = min(range.length, bytes);\n            self.download_range(offset, length)?;\n        }\n\n        Ok(())\n    }\n\n    fn handle_file_data(&mut self, data: ReceivedData) -> Result<ControlFlow, Error> {\n        match data {\n            ReceivedData::Throughput(mut throughput) => {\n                if throughput < self.params.minimum_throughput {\n                    warn!(\n                        \"Throughput {} kbps lower than minimum {}, setting to minimum\",\n                        throughput / 1000,\n                        self.params.minimum_throughput / 1000,\n                    );\n                    throughput = self.params.minimum_throughput;\n                }\n\n                let old_throughput = self.shared.throughput();\n                let avg_throughput = if old_throughput > 0 {\n                    (old_throughput + throughput) / 2\n                } else {\n                    throughput\n                };\n\n                // print when the new estimate deviates by more than 10% from the last\n                if f32::abs((avg_throughput as f32 - old_throughput as f32) / old_throughput as f32)\n                    > 0.1\n                {\n                    trace!(\n                        \"Throughput now estimated as: {} kbps\",\n                        avg_throughput / 1000\n                    );\n                }\n\n                self.shared.set_throughput(avg_throughput);\n            }\n            ReceivedData::ResponseTime(mut response_time) => {\n                if response_time > self.params.maximum_assumed_ping_time {\n                    warn!(\n                        \"Time to first byte {} ms exceeds maximum {}, setting to maximum\",\n                        response_time.as_millis(),\n                        self.params.maximum_assumed_ping_time.as_millis()\n                    );\n                    response_time = self.params.maximum_assumed_ping_time;\n                }\n\n                let old_ping_time_ms = self.shared.ping_time().as_millis();\n\n                // prune old response times. Keep at most two so we can push a third.\n                while self.network_response_times.len() >= 3 {\n                    self.network_response_times.remove(0);\n                }\n\n                // record the response time\n                self.network_response_times.push(response_time);\n\n                // stats::median is experimental. So we calculate the median of up to three ourselves.\n                let ping_time = {\n                    match self.network_response_times.len() {\n                        1 => self.network_response_times[0],\n                        2 => (self.network_response_times[0] + self.network_response_times[1]) / 2,\n                        3 => {\n                            let mut times = self.network_response_times.clone();\n                            times.sort_unstable();\n                            times[1]\n                        }\n                        _ => unreachable!(),\n                    }\n                };\n\n                // print when the new estimate deviates by more than 10% from the last\n                if f32::abs(\n                    (ping_time.as_millis() as f32 - old_ping_time_ms as f32)\n                        / old_ping_time_ms as f32,\n                ) > 0.1\n                {\n                    trace!(\n                        \"Time to first byte now estimated as: {} ms\",\n                        ping_time.as_millis()\n                    );\n                }\n\n                // store our new estimate for everyone to see\n                self.shared.set_ping_time(ping_time);\n            }\n            ReceivedData::Data(data) => {\n                match self.output.as_mut() {\n                    Some(output) => {\n                        output.seek(SeekFrom::Start(data.offset as u64))?;\n                        output.write_all(data.data.as_ref())?;\n                    }\n                    None => return Err(AudioFileError::Output.into()),\n                }\n\n                let received_range = Range::new(data.offset, data.data.len());\n\n                let full = {\n                    let mut download_status = self\n                        .shared\n                        .download_status\n                        .lock()\n                        .expect(DOWNLOAD_STATUS_POISON_MSG);\n                    download_status.downloaded.add_range(&received_range);\n                    self.shared.cond.notify_all();\n\n                    download_status.downloaded.contained_length_from_value(0)\n                        >= self.shared.file_size\n                };\n\n                if full {\n                    self.finish()?;\n                    return Ok(ControlFlow::Break);\n                }\n            }\n        }\n\n        Ok(ControlFlow::Continue)\n    }\n\n    fn handle_stream_loader_command(\n        &mut self,\n        cmd: StreamLoaderCommand,\n    ) -> Result<ControlFlow, Error> {\n        match cmd {\n            StreamLoaderCommand::Fetch(request) => {\n                self.download_range(request.start, request.length)?\n            }\n            StreamLoaderCommand::Close => return Ok(ControlFlow::Break),\n        }\n\n        Ok(ControlFlow::Continue)\n    }\n\n    fn finish(&mut self) -> AudioFileResult {\n        let output = self.output.take();\n\n        let complete_tx = self.complete_tx.take();\n\n        if let Some(mut output) = output {\n            output.rewind()?;\n            if let Some(complete_tx) = complete_tx {\n                complete_tx\n                    .send(output)\n                    .map_err(|_| AudioFileError::Channel)?;\n            }\n        }\n\n        Ok(())\n    }\n}\n\npub(super) async fn audio_file_fetch(\n    session: Session,\n    shared: Arc<AudioFileShared>,\n    initial_request: StreamingRequest,\n    output: NamedTempFile,\n    mut stream_loader_command_rx: mpsc::UnboundedReceiver<StreamLoaderCommand>,\n    complete_tx: oneshot::Sender<NamedTempFile>,\n) -> AudioFileResult {\n    let (file_data_tx, mut file_data_rx) = mpsc::unbounded_channel();\n\n    {\n        let requested_range = Range::new(\n            initial_request.offset,\n            initial_request.offset + initial_request.length,\n        );\n\n        let mut download_status = shared\n            .download_status\n            .lock()\n            .expect(DOWNLOAD_STATUS_POISON_MSG);\n        download_status.requested.add_range(&requested_range);\n    }\n\n    session.spawn(receive_data(\n        shared.clone(),\n        file_data_tx.clone(),\n        initial_request,\n    ));\n\n    let params = AudioFetchParams::get();\n\n    let mut fetch = AudioFileFetch {\n        session: session.clone(),\n        shared,\n        output: Some(output),\n\n        file_data_tx,\n        complete_tx: Some(complete_tx),\n        network_response_times: Vec::with_capacity(3),\n\n        params: params.clone(),\n    };\n\n    loop {\n        tokio::select! {\n            cmd = stream_loader_command_rx.recv() => {\n                match cmd {\n                        Some(cmd) => {\n                            if fetch.handle_stream_loader_command(cmd)? == ControlFlow::Break {\n                                break;\n                            }\n                        }\n                        None => break,\n                    }\n                }\n            data = file_data_rx.recv() => {\n                match data {\n                    Some(data) => {\n                        if fetch.handle_file_data(data)? == ControlFlow::Break {\n                            break;\n                        }\n                    }\n                    None => break,\n                }\n            },\n            else => (),\n        }\n\n        if fetch.shared.is_download_streaming() && fetch.has_download_slots_available() {\n            let bytes_pending: usize = {\n                let download_status = fetch\n                    .shared\n                    .download_status\n                    .lock()\n                    .expect(DOWNLOAD_STATUS_POISON_MSG);\n\n                download_status\n                    .requested\n                    .minus(&download_status.downloaded)\n                    .len()\n            };\n\n            let ping_time_seconds = fetch.shared.ping_time().as_secs_f32();\n            let throughput = fetch.shared.throughput();\n\n            let desired_pending_bytes = max(\n                (params.prefetch_threshold_factor\n                    * ping_time_seconds\n                    * fetch.shared.bytes_per_second as f32) as usize,\n                (ping_time_seconds * throughput as f32) as usize,\n            );\n\n            if bytes_pending < desired_pending_bytes {\n                fetch.pre_fetch_more_data(desired_pending_bytes - bytes_pending)?;\n            }\n        }\n    }\n\n    Ok(())\n}\n"
  },
  {
    "path": "audio/src/lib.rs",
    "content": "#[macro_use]\nextern crate log;\n\nmod decrypt;\nmod fetch;\n\nmod range_set;\n\npub use decrypt::AudioDecrypt;\npub use fetch::{AudioFetchParams, AudioFile, AudioFileError, StreamLoaderController};\n"
  },
  {
    "path": "audio/src/range_set.rs",
    "content": "use std::{\n    cmp::{max, min},\n    fmt,\n    slice::Iter,\n};\n\n#[derive(Copy, Clone, Debug)]\npub struct Range {\n    pub start: usize,\n    pub length: usize,\n}\n\nimpl fmt::Display for Range {\n    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n        write!(f, \"[{}, {}]\", self.start, self.start + self.length - 1)\n    }\n}\n\nimpl Range {\n    pub fn new(start: usize, length: usize) -> Range {\n        Range { start, length }\n    }\n\n    pub fn end(&self) -> usize {\n        self.start + self.length\n    }\n}\n\n#[derive(Debug, Clone)]\npub struct RangeSet {\n    ranges: Vec<Range>,\n}\n\nimpl fmt::Display for RangeSet {\n    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n        write!(f, \"(\")?;\n        for range in self.ranges.iter() {\n            write!(f, \"{range}\")?;\n        }\n        write!(f, \")\")\n    }\n}\n\nimpl RangeSet {\n    pub fn new() -> RangeSet {\n        RangeSet {\n            ranges: Vec::<Range>::new(),\n        }\n    }\n\n    pub fn is_empty(&self) -> bool {\n        self.ranges.is_empty()\n    }\n\n    pub fn len(&self) -> usize {\n        self.ranges.iter().map(|r| r.length).sum()\n    }\n\n    pub fn get_range(&self, index: usize) -> Range {\n        self.ranges[index]\n    }\n\n    pub fn iter(&self) -> Iter<'_, Range> {\n        self.ranges.iter()\n    }\n\n    pub fn contains(&self, value: usize) -> bool {\n        for range in self.ranges.iter() {\n            if value < range.start {\n                return false;\n            } else if range.start <= value && value < range.end() {\n                return true;\n            }\n        }\n        false\n    }\n\n    pub fn contained_length_from_value(&self, value: usize) -> usize {\n        for range in self.ranges.iter() {\n            if value < range.start {\n                return 0;\n            } else if range.start <= value && value < range.end() {\n                return range.end() - value;\n            }\n        }\n        0\n    }\n\n    #[allow(dead_code)]\n    pub fn contains_range_set(&self, other: &RangeSet) -> bool {\n        for range in other.ranges.iter() {\n            if self.contained_length_from_value(range.start) < range.length {\n                return false;\n            }\n        }\n        true\n    }\n\n    pub fn add_range(&mut self, range: &Range) {\n        if range.length == 0 {\n            // the interval is empty -> nothing to do.\n            return;\n        }\n\n        for index in 0..self.ranges.len() {\n            // the new range is clear of any ranges we already iterated over.\n            if range.end() < self.ranges[index].start {\n                // the new range starts after anything we already passed and ends before the next range starts (they don't touch) -> insert it.\n                self.ranges.insert(index, *range);\n                return;\n            } else if range.start <= self.ranges[index].end()\n                && self.ranges[index].start <= range.end()\n            {\n                // the new range overlaps (or touches) the first range. They are to be merged.\n                // In addition we might have to merge further ranges in as well.\n\n                let mut new_range = *range;\n\n                while index < self.ranges.len() && self.ranges[index].start <= new_range.end() {\n                    let new_end = max(new_range.end(), self.ranges[index].end());\n                    new_range.start = min(new_range.start, self.ranges[index].start);\n                    new_range.length = new_end - new_range.start;\n                    self.ranges.remove(index);\n                }\n\n                self.ranges.insert(index, new_range);\n                return;\n            }\n        }\n\n        // the new range is after everything else -> just add it\n        self.ranges.push(*range);\n    }\n\n    #[allow(dead_code)]\n    pub fn add_range_set(&mut self, other: &RangeSet) {\n        for range in other.ranges.iter() {\n            self.add_range(range);\n        }\n    }\n\n    #[allow(dead_code)]\n    pub fn union(&self, other: &RangeSet) -> RangeSet {\n        let mut result = self.clone();\n        result.add_range_set(other);\n        result\n    }\n\n    pub fn subtract_range(&mut self, range: &Range) {\n        if range.length == 0 {\n            return;\n        }\n\n        for index in 0..self.ranges.len() {\n            // the ranges we already passed don't overlap with the range to remove\n\n            if range.end() <= self.ranges[index].start {\n                // the remaining ranges are past the one to subtract. -> we're done.\n                return;\n            } else if range.start <= self.ranges[index].start\n                && self.ranges[index].start < range.end()\n            {\n                // the range to subtract started before the current range and reaches into the current range\n                // -> we have to remove the beginning of the range or the entire range and do the same for following ranges.\n\n                while index < self.ranges.len() && self.ranges[index].end() <= range.end() {\n                    self.ranges.remove(index);\n                }\n\n                if index < self.ranges.len() && self.ranges[index].start < range.end() {\n                    self.ranges[index].length -= range.end() - self.ranges[index].start;\n                    self.ranges[index].start = range.end();\n                }\n\n                return;\n            } else if range.end() < self.ranges[index].end() {\n                // the range to subtract punches a hole into the current range -> we need to create two smaller ranges.\n\n                let first_range = Range {\n                    start: self.ranges[index].start,\n                    length: range.start - self.ranges[index].start,\n                };\n\n                self.ranges[index].length -= range.end() - self.ranges[index].start;\n                self.ranges[index].start = range.end();\n\n                self.ranges.insert(index, first_range);\n\n                return;\n            } else if range.start < self.ranges[index].end() {\n                // the range truncates the existing range -> truncate the range. Let the for loop take care of overlaps with other ranges.\n                self.ranges[index].length = range.start - self.ranges[index].start;\n            }\n        }\n    }\n\n    pub fn subtract_range_set(&mut self, other: &RangeSet) {\n        for range in other.ranges.iter() {\n            self.subtract_range(range);\n        }\n    }\n\n    pub fn minus(&self, other: &RangeSet) -> RangeSet {\n        let mut result = self.clone();\n        result.subtract_range_set(other);\n        result\n    }\n\n    pub fn intersection(&self, other: &RangeSet) -> RangeSet {\n        let mut result = RangeSet::new();\n\n        let mut self_index: usize = 0;\n        let mut other_index: usize = 0;\n\n        while self_index < self.ranges.len() && other_index < other.ranges.len() {\n            if self.ranges[self_index].end() <= other.ranges[other_index].start {\n                // skip the interval\n                self_index += 1;\n            } else if other.ranges[other_index].end() <= self.ranges[self_index].start {\n                // skip the interval\n                other_index += 1;\n            } else {\n                // the two intervals overlap. Add the union and advance the index of the one that ends first.\n                let new_start = max(\n                    self.ranges[self_index].start,\n                    other.ranges[other_index].start,\n                );\n                let new_end = min(\n                    self.ranges[self_index].end(),\n                    other.ranges[other_index].end(),\n                );\n                result.add_range(&Range::new(new_start, new_end - new_start));\n                if self.ranges[self_index].end() <= other.ranges[other_index].end() {\n                    self_index += 1;\n                } else {\n                    other_index += 1;\n                }\n            }\n        }\n\n        result\n    }\n}\n"
  },
  {
    "path": "cache/.gitignore",
    "content": "*\n!.gitignore\n"
  },
  {
    "path": "connect/Cargo.toml",
    "content": "[package]\nname = \"librespot-connect\"\nversion = \"0.8.0\"\nrust-version.workspace = true\nauthors = [\"Paul Lietar <paul@lietar.net>\"]\nlicense.workspace = true\ndescription = \"The Spotify Connect logic for librespot\"\nrepository.workspace = true\nedition.workspace = true\n\n[features]\n# Refer to the workspace Cargo.toml for the list of features\ndefault = [\"native-tls\"]\n\n# TLS backend propagation\nnative-tls = [\"librespot-core/native-tls\"]\nrustls-tls-native-roots = [\"librespot-core/rustls-tls-native-roots\"]\nrustls-tls-webpki-roots = [\"librespot-core/rustls-tls-webpki-roots\"]\n\n[dependencies]\nlibrespot-core = { version = \"0.8.0\", path = \"../core\", default-features = false }\nlibrespot-playback = { version = \"0.8.0\", path = \"../playback\", default-features = false }\nlibrespot-protocol = { version = \"0.8.0\", path = \"../protocol\", default-features = false }\n\nfutures-util = { version = \"0.3\", default-features = false, features = [\"std\"] }\nlog = \"0.4\"\nprotobuf = \"3.7\"\nrand = { version = \"0.9\", default-features = false, features = [\"small_rng\"] }\nserde_json = \"1.0\"\nthiserror = \"2\"\ntokio = { version = \"1\", features = [\"macros\", \"sync\"] }\ntokio-stream = { version = \"0.1\", default-features = false }\nuuid = { version = \"1.18\", default-features = false, features = [\"v4\"] }\n\n[lints]\nworkspace = true\n"
  },
  {
    "path": "connect/README.md",
    "content": "[//]: # (This readme is optimized for inline rustdoc, if some links don't work, they will when included in lib.rs)\n\n# Connect\n\nThe connect module of librespot. Provides the option to create your own connect device\nand stream to it like any other official spotify client.\n\nThe [`Spirc`] is the entrypoint to creating your own connect device. It can be\nconfigured with the given [`ConnectConfig`] options and requires some additional data\nto start up the device.\n\nWhen creating a new [`Spirc`] it returns two items. The [`Spirc`] itself, which is can\nbe used as to control the local connect device. And a [`Future`](std::future::Future),\nlets name it `SpircTask`, that starts and executes the event loop of the connect device\nwhen awaited.\n\nA basic example in which the `Spirc` and `SpircTask` is used can be found here:\n[`examples/play_connect.rs`](../examples/play_connect.rs).\n\n# Example\n\n```rust\nuse std::{future::Future, thread};\n\nuse librespot_connect::{ConnectConfig, Spirc};\nuse librespot_core::{authentication::Credentials, Error, Session, SessionConfig};\nuse librespot_playback::{\n    audio_backend, mixer,\n    config::{AudioFormat, PlayerConfig},\n    mixer::{MixerConfig, NoOpVolume},\n    player::Player\n};\n\nasync fn create_basic_spirc() -> Result<(), Error> {\n    let credentials = Credentials::with_access_token(\"access-token-here\");\n    let session = Session::new(SessionConfig::default(), None);\n\n    let backend = audio_backend::find(None).expect(\"will default to rodio\");\n\n    let player = Player::new(\n        PlayerConfig::default(),\n        session.clone(),\n        Box::new(NoOpVolume),\n        move || {\n            let format = AudioFormat::default();\n            let device = None;\n            backend(device, format)\n        },\n    );\n\n    let mixer = mixer::find(None).expect(\"will default to SoftMixer\");\n\n    let (spirc, spirc_task): (Spirc, _) = Spirc::new(\n        ConnectConfig::default(),\n        session,\n        credentials,\n        player,\n        mixer(MixerConfig::default())?\n    ).await?;\n\n    Ok(())\n}\n```"
  },
  {
    "path": "connect/src/context_resolver.rs",
    "content": "use crate::{\n    core::{Error, Session},\n    protocol::{\n        autoplay_context_request::AutoplayContextRequest, context::Context,\n        transfer_state::TransferState,\n    },\n    state::{ConnectState, context::ContextType},\n};\nuse std::{\n    cmp::PartialEq,\n    collections::{HashMap, VecDeque},\n    fmt::{Display, Formatter},\n    hash::Hash,\n    time::Duration,\n};\nuse thiserror::Error as ThisError;\nuse tokio::time::Instant;\n\n#[derive(Debug, Clone, Hash, PartialEq, Eq)]\nenum Resolve {\n    Uri(String),\n    Context(Context),\n}\n\n#[derive(Debug, Clone, Hash, PartialEq, Eq)]\npub(super) enum ContextAction {\n    Append,\n    Replace,\n}\n\n#[derive(Debug, Clone, Hash, PartialEq, Eq)]\npub(super) struct ResolveContext {\n    resolve: Resolve,\n    fallback: Option<String>,\n    update: ContextType,\n    action: ContextAction,\n}\n\nimpl ResolveContext {\n    fn append_context(uri: impl Into<String>) -> Self {\n        Self {\n            resolve: Resolve::Uri(uri.into()),\n            fallback: None,\n            update: ContextType::Default,\n            action: ContextAction::Append,\n        }\n    }\n\n    pub fn from_uri(\n        uri: impl Into<String>,\n        fallback: impl Into<String>,\n        update: ContextType,\n        action: ContextAction,\n    ) -> Self {\n        let fallback_uri = fallback.into();\n        Self {\n            resolve: Resolve::Uri(uri.into()),\n            fallback: (!fallback_uri.is_empty()).then_some(fallback_uri),\n            update,\n            action,\n        }\n    }\n\n    pub fn from_context(context: Context, update: ContextType, action: ContextAction) -> Self {\n        Self {\n            resolve: Resolve::Context(context),\n            fallback: None,\n            update,\n            action,\n        }\n    }\n\n    /// the uri which should be used to resolve the context, might not be the context uri\n    fn resolve_uri(&self) -> Option<&str> {\n        // it's important to call this always, or at least for every ResolveContext\n        // otherwise we might not even check if we need to fallback and just use the fallback uri\n        match self.resolve {\n            Resolve::Uri(ref uri) => ConnectState::valid_resolve_uri(uri),\n            Resolve::Context(ref ctx) => {\n                ConnectState::find_valid_uri(ctx.uri.as_deref(), ctx.pages.first())\n            }\n        }\n        .or(self.fallback.as_deref())\n    }\n\n    /// the actual context uri\n    fn context_uri(&self) -> &str {\n        match self.resolve {\n            Resolve::Uri(ref uri) => uri,\n            Resolve::Context(ref ctx) => ctx.uri.as_deref().unwrap_or_default(),\n        }\n    }\n}\n\nimpl Display for ResolveContext {\n    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {\n        write!(\n            f,\n            \"resolve_uri: <{:?}>, context_uri: <{}>, update: <{:?}>\",\n            self.resolve_uri(),\n            self.context_uri(),\n            self.update,\n        )\n    }\n}\n\n#[derive(Debug, ThisError)]\nenum ContextResolverError {\n    #[error(\"no next context to resolve\")]\n    NoNext,\n    #[error(\"tried appending context with {0} pages\")]\n    UnexpectedPagesSize(usize),\n    #[error(\"tried resolving not allowed context: {0:?}\")]\n    NotAllowedContext(String),\n}\n\nimpl From<ContextResolverError> for Error {\n    fn from(value: ContextResolverError) -> Self {\n        Error::failed_precondition(value)\n    }\n}\n\npub struct ContextResolver {\n    session: Session,\n    queue: VecDeque<ResolveContext>,\n    unavailable_contexts: HashMap<ResolveContext, Instant>,\n}\n\n// time after which an unavailable context is retried\nconst RETRY_UNAVAILABLE: Duration = Duration::from_secs(3600);\n\nimpl ContextResolver {\n    pub fn new(session: Session) -> Self {\n        Self {\n            session,\n            queue: VecDeque::new(),\n            unavailable_contexts: HashMap::new(),\n        }\n    }\n\n    pub fn add(&mut self, resolve: ResolveContext) {\n        let last_try = self\n            .unavailable_contexts\n            .get(&resolve)\n            .map(Instant::elapsed);\n\n        let last_try = if matches!(last_try, Some(last_try) if last_try > RETRY_UNAVAILABLE) {\n            let _ = self.unavailable_contexts.remove(&resolve);\n            debug!(\n                \"context was requested {}s ago, trying again to resolve the requested context\",\n                last_try.expect(\"checked by condition\").as_secs()\n            );\n            None\n        } else {\n            last_try\n        };\n\n        if last_try.is_some() {\n            debug!(\"tried loading unavailable context: {resolve}\");\n            return;\n        } else if self.queue.contains(&resolve) {\n            debug!(\"update for {resolve} is already added\");\n            return;\n        } else {\n            trace!(\n                \"added {} to resolver queue\",\n                resolve.resolve_uri().unwrap_or(resolve.context_uri())\n            )\n        }\n\n        self.queue.push_back(resolve)\n    }\n\n    pub fn add_list(&mut self, resolve: Vec<ResolveContext>) {\n        for resolve in resolve {\n            self.add(resolve)\n        }\n    }\n\n    pub fn remove_used_and_invalid(&mut self) {\n        if let Some((_, _, remove)) = self.find_next() {\n            let _ = self.queue.drain(0..remove); // remove invalid\n        }\n        self.queue.pop_front(); // remove used\n    }\n\n    pub fn clear(&mut self) {\n        self.queue = VecDeque::new()\n    }\n\n    fn find_next(&self) -> Option<(&ResolveContext, &str, usize)> {\n        for idx in 0..self.queue.len() {\n            let next = self.queue.get(idx)?;\n            match next.resolve_uri() {\n                None => {\n                    warn!(\"skipped {idx} because of invalid resolve_uri: {next}\");\n                    continue;\n                }\n                Some(uri) => return Some((next, uri, idx)),\n            }\n        }\n        None\n    }\n\n    pub fn has_next(&self) -> bool {\n        self.find_next().is_some()\n    }\n\n    pub async fn get_next_context(\n        &self,\n        recent_track_uri: impl Fn() -> Vec<String>,\n    ) -> Result<Context, Error> {\n        let (next, resolve_uri, _) = self.find_next().ok_or(ContextResolverError::NoNext)?;\n\n        match next.update {\n            ContextType::Default => {\n                let mut ctx = self.session.spclient().get_context(resolve_uri).await;\n                if let Ok(ctx) = ctx.as_mut() {\n                    ctx.uri = Some(next.context_uri().to_string());\n                    ctx.url = ctx.uri.as_ref().map(|s| format!(\"context://{s}\"));\n                }\n\n                ctx\n            }\n            ContextType::Autoplay => {\n                if resolve_uri.contains(\"spotify:show:\") || resolve_uri.contains(\"spotify:episode:\")\n                {\n                    // autoplay is not supported for podcasts\n                    Err(ContextResolverError::NotAllowedContext(\n                        resolve_uri.to_string(),\n                    ))?\n                }\n\n                let request = AutoplayContextRequest {\n                    context_uri: Some(resolve_uri.to_string()),\n                    recent_track_uri: recent_track_uri(),\n                    ..Default::default()\n                };\n                self.session.spclient().get_autoplay_context(&request).await\n            }\n        }\n    }\n\n    pub fn mark_next_unavailable(&mut self) {\n        if let Some((next, _, _)) = self.find_next() {\n            self.unavailable_contexts\n                .insert(next.clone(), Instant::now());\n        }\n    }\n\n    pub fn apply_next_context(\n        &self,\n        state: &mut ConnectState,\n        mut context: Context,\n    ) -> Result<Option<Vec<ResolveContext>>, Error> {\n        let (next, _, _) = self.find_next().ok_or(ContextResolverError::NoNext)?;\n\n        let remaining = match next.action {\n            ContextAction::Append if context.pages.len() == 1 => state\n                .fill_context_from_page(context.pages.remove(0))\n                .map(|_| None),\n            ContextAction::Replace => {\n                let remaining = state.update_context(context, next.update);\n                if let Resolve::Context(ref ctx) = next.resolve {\n                    state.merge_context(ctx.pages.clone().pop());\n                }\n\n                remaining\n            }\n            ContextAction::Append => {\n                warn!(\"unexpected page size: {context:#?}\");\n                Err(ContextResolverError::UnexpectedPagesSize(context.pages.len()).into())\n            }\n        }?;\n\n        Ok(remaining.map(|remaining| {\n            remaining\n                .into_iter()\n                .map(ResolveContext::append_context)\n                .collect::<Vec<_>>()\n        }))\n    }\n\n    pub fn try_finish(\n        &self,\n        state: &mut ConnectState,\n        transfer_state: &mut Option<TransferState>,\n    ) -> bool {\n        let (next, _, _) = match self.find_next() {\n            None => return false,\n            Some(next) => next,\n        };\n\n        // when there is only one update type, we are the last of our kind, so we should update the state\n        if self\n            .queue\n            .iter()\n            .filter(|resolve| resolve.update == next.update)\n            .count()\n            != 1\n        {\n            return false;\n        }\n\n        match (next.update, state.active_context) {\n            (ContextType::Default, ContextType::Default) | (ContextType::Autoplay, _) => {\n                debug!(\n                    \"last item of type <{:?}>, finishing state setup\",\n                    next.update\n                );\n            }\n            (ContextType::Default, _) => {\n                debug!(\"skipped finishing default, because it isn't the active context\");\n                return false;\n            }\n        }\n\n        let active_ctx = state.get_context(state.active_context);\n        let res = if let Some(transfer_state) = transfer_state.take() {\n            state.finish_transfer(transfer_state)\n        } else if state.shuffling_context() && next.update == ContextType::Default {\n            state.shuffle_new()\n        } else if matches!(active_ctx, Ok(ctx) if ctx.index.track == 0) {\n            // has context, and context is not touched\n            // when the index is not zero, the next index was already evaluated elsewhere\n            let ctx = active_ctx.expect(\"checked by precondition\");\n            let idx = ConnectState::find_index_in_context(ctx, |t| {\n                state.current_track(|c| t.uri == c.uri)\n            })\n            .ok();\n\n            state.reset_playback_to_position(idx)\n        } else {\n            state.fill_up_next_tracks()\n        };\n\n        if let Err(why) = res {\n            error!(\"setup of state failed: {why}, last used resolve {next:#?}\")\n        }\n\n        state.update_restrictions();\n        state.update_queue_revision();\n\n        true\n    }\n}\n"
  },
  {
    "path": "connect/src/lib.rs",
    "content": "#![warn(missing_docs)]\n#![doc=include_str!(\"../README.md\")]\n\n#[macro_use]\nextern crate log;\n\nuse librespot_core as core;\nuse librespot_playback as playback;\nuse librespot_protocol as protocol;\n\nmod context_resolver;\nmod model;\nmod shuffle_vec;\nmod spirc;\nmod state;\n\npub use model::*;\npub use spirc::*;\npub use state::*;\n"
  },
  {
    "path": "connect/src/model.rs",
    "content": "use crate::{\n    core::dealer::protocol::SkipTo, protocol::context_player_options::ContextPlayerOptionOverrides,\n};\n\nuse std::ops::Deref;\n\n/// Request for loading playback\n#[derive(Debug, Clone)]\npub struct LoadRequest {\n    pub(super) context: PlayContext,\n    pub(super) options: LoadRequestOptions,\n}\n\nimpl Deref for LoadRequest {\n    type Target = LoadRequestOptions;\n\n    fn deref(&self) -> &Self::Target {\n        &self.options\n    }\n}\n\n#[derive(Debug, Clone)]\npub(super) enum PlayContext {\n    Uri(String),\n    Tracks(Vec<String>),\n}\n\n/// The parameters for creating a load request\n#[derive(Debug, Default, Clone)]\npub struct LoadRequestOptions {\n    /// Whether the given tracks should immediately start playing, or just be initially loaded.\n    pub start_playing: bool,\n    /// Start the playback at a specific point of the track.\n    ///\n    /// The provided value is used as milliseconds. Providing a value greater\n    /// than the track duration will start the track at the beginning.\n    pub seek_to: u32,\n    /// Options that decide how the context starts playing\n    pub context_options: Option<LoadContextOptions>,\n    /// Decides the starting position in the given context.\n    ///\n    /// If the provided item doesn't exist or is out of range,\n    /// the playback starts at the beginning of the context.\n    ///\n    /// If `None` is provided and `shuffle` is `true`, a random track is played, otherwise the first\n    pub playing_track: Option<PlayingTrack>,\n}\n\n/// The options which decide how the playback is started\n///\n/// Separated into an `enum` to exclude the other variants from being used\n/// simultaneously, as they are not compatible.\n#[derive(Debug, Clone)]\npub enum LoadContextOptions {\n    /// Starts the context with options\n    Options(Options),\n    /// Starts the playback as the autoplay variant of the context\n    ///\n    /// This is the same as finishing a context and\n    /// automatically continuing playback of similar tracks\n    Autoplay,\n}\n\n/// The available options that indicate how to start the context\n#[derive(Debug, Default, Clone)]\npub struct Options {\n    /// Start the context in shuffle mode\n    pub shuffle: bool,\n    /// Start the context in repeat mode\n    pub repeat: bool,\n    /// Start the context, repeating the first track until skipped or manually disabled\n    pub repeat_track: bool,\n}\n\nimpl From<ContextPlayerOptionOverrides> for Options {\n    fn from(value: ContextPlayerOptionOverrides) -> Self {\n        Self {\n            shuffle: value.shuffling_context.unwrap_or_default(),\n            repeat: value.repeating_context.unwrap_or_default(),\n            repeat_track: value.repeating_track.unwrap_or_default(),\n        }\n    }\n}\n\nimpl LoadRequest {\n    /// Create a load request from a `context_uri`\n    ///\n    /// For supported `context_uri` see [`SpClient::get_context`](librespot_core::spclient::SpClient::get_context)\n    ///\n    /// Equivalent to using [`/me/player/play`](https://developer.spotify.com/documentation/web-api/reference/start-a-users-playback)\n    /// and providing `context_uri`\n    pub fn from_context_uri(context_uri: String, options: LoadRequestOptions) -> Self {\n        Self {\n            context: PlayContext::Uri(context_uri),\n            options,\n        }\n    }\n\n    /// Create a load request from a set of `tracks`\n    ///\n    /// Equivalent to using [`/me/player/play`](https://developer.spotify.com/documentation/web-api/reference/start-a-users-playback)\n    /// and providing `uris`\n    pub fn from_tracks(tracks: Vec<String>, options: LoadRequestOptions) -> Self {\n        Self {\n            context: PlayContext::Tracks(tracks),\n            options,\n        }\n    }\n}\n\n/// An item that represent a track to play\n#[derive(Debug, Clone)]\npub enum PlayingTrack {\n    /// Represent the track at a given index.\n    Index(u32),\n    /// Represent the uri of a track.\n    Uri(String),\n    #[doc(hidden)]\n    /// Represent an internal identifier from spotify.\n    ///\n    /// The internal identifier is not the id contained in the uri. And rather\n    /// an unrelated id probably unique in spotify's internal database. But that's\n    /// just speculation.\n    ///\n    /// This identifier is not available by any public api. It's used for varies in\n    /// any spotify client, like sorting, displaying which track is currently played\n    /// and skipping to a track. Mobile uses it pretty intensively but also web and\n    /// desktop seem to make use of it.\n    Uid(String),\n}\n\nimpl TryFrom<SkipTo> for PlayingTrack {\n    type Error = ();\n\n    fn try_from(value: SkipTo) -> Result<Self, Self::Error> {\n        // order of checks is important, as the index can be 0, but still has an uid or uri provided,\n        // so we only use the index as last resort\n        if let Some(uri) = value.track_uri {\n            Ok(PlayingTrack::Uri(uri))\n        } else if let Some(uid) = value.track_uid {\n            Ok(PlayingTrack::Uid(uid))\n        } else if let Some(index) = value.track_index {\n            Ok(PlayingTrack::Index(index))\n        } else {\n            Err(())\n        }\n    }\n}\n\n#[derive(Debug)]\npub(super) enum SpircPlayStatus {\n    Stopped,\n    LoadingPlay {\n        position_ms: u32,\n    },\n    LoadingPause {\n        position_ms: u32,\n    },\n    Playing {\n        nominal_start_time: i64,\n        preloading_of_next_track_triggered: bool,\n    },\n    Paused {\n        position_ms: u32,\n        preloading_of_next_track_triggered: bool,\n    },\n}\n"
  },
  {
    "path": "connect/src/shuffle_vec.rs",
    "content": "use rand::{Rng, SeedableRng, rngs::SmallRng};\nuse std::{\n    ops::{Deref, DerefMut},\n    vec::IntoIter,\n};\n\n#[derive(Debug, Clone, Default)]\npub struct ShuffleVec<T> {\n    vec: Vec<T>,\n    indices: Option<Vec<usize>>,\n    /// This is primarily necessary to ensure that shuffle does not behave out of place.\n    ///\n    /// For that reason we swap the first track with the currently playing track. By that we ensure\n    /// that the shuffle state is consistent between resets of the state because the first track is\n    /// always the track with which we started playing when switching to shuffle.\n    original_first_position: Option<usize>,\n}\n\nimpl<T: PartialEq> PartialEq for ShuffleVec<T> {\n    fn eq(&self, other: &Self) -> bool {\n        self.vec == other.vec\n    }\n}\n\nimpl<T> Deref for ShuffleVec<T> {\n    type Target = Vec<T>;\n\n    fn deref(&self) -> &Self::Target {\n        &self.vec\n    }\n}\n\nimpl<T> DerefMut for ShuffleVec<T> {\n    fn deref_mut(&mut self) -> &mut Self::Target {\n        self.vec.as_mut()\n    }\n}\n\nimpl<T> IntoIterator for ShuffleVec<T> {\n    type Item = T;\n    type IntoIter = IntoIter<T>;\n\n    fn into_iter(self) -> Self::IntoIter {\n        self.vec.into_iter()\n    }\n}\n\nimpl<T> From<Vec<T>> for ShuffleVec<T> {\n    fn from(vec: Vec<T>) -> Self {\n        Self {\n            vec,\n            original_first_position: None,\n            indices: None,\n        }\n    }\n}\n\nimpl<T> ShuffleVec<T> {\n    pub fn shuffle_with_seed<F: Fn(&T) -> bool>(&mut self, seed: u64, is_first: F) {\n        self.shuffle_with_rng(SmallRng::seed_from_u64(seed), is_first)\n    }\n\n    pub fn shuffle_with_rng<F: Fn(&T) -> bool>(&mut self, mut rng: impl Rng, is_first: F) {\n        if self.vec.len() <= 1 {\n            info!(\"skipped shuffling for less or equal one item\");\n            return;\n        }\n\n        if self.indices.is_some() {\n            self.unshuffle()\n        }\n\n        let indices: Vec<_> = {\n            (1..self.vec.len())\n                .rev()\n                .map(|i| rng.random_range(0..i + 1))\n                .collect()\n        };\n\n        for (i, &rnd_ind) in (1..self.vec.len()).rev().zip(&indices) {\n            self.vec.swap(i, rnd_ind);\n        }\n\n        self.indices = Some(indices);\n\n        self.original_first_position = self.vec.iter().position(is_first);\n        if let Some(first_pos) = self.original_first_position {\n            self.vec.swap(0, first_pos)\n        }\n    }\n\n    pub fn unshuffle(&mut self) {\n        let indices = match self.indices.take() {\n            Some(indices) => indices,\n            None => return,\n        };\n\n        if let Some(first_pos) = self.original_first_position {\n            self.vec.swap(0, first_pos);\n            self.original_first_position = None;\n        }\n\n        for i in 1..self.vec.len() {\n            match indices.get(self.vec.len() - i - 1) {\n                None => return,\n                Some(n) => self.vec.swap(*n, i),\n            }\n        }\n    }\n}\n\n#[cfg(test)]\nmod test {\n    use super::*;\n    use rand::Rng;\n    use std::ops::Range;\n\n    fn base(range: Range<usize>) -> (ShuffleVec<usize>, u64) {\n        let seed = rand::rng().random_range(0..10_000_000_000_000);\n\n        let vec = range.collect::<Vec<_>>();\n        (vec.into(), seed)\n    }\n\n    #[test]\n    fn test_shuffle_without_first() {\n        let (base_vec, seed) = base(0..100);\n\n        let mut shuffled_vec = base_vec.clone();\n        shuffled_vec.shuffle_with_seed(seed, |_| false);\n\n        let mut different_shuffled_vec = base_vec.clone();\n        different_shuffled_vec.shuffle_with_seed(seed, |_| false);\n\n        assert_eq!(\n            shuffled_vec, different_shuffled_vec,\n            \"shuffling with the same seed has the same result\"\n        );\n\n        let mut unshuffled_vec = shuffled_vec.clone();\n        unshuffled_vec.unshuffle();\n\n        assert_eq!(\n            base_vec, unshuffled_vec,\n            \"unshuffle restores the original state\"\n        );\n    }\n\n    #[test]\n    fn test_shuffle_with_first() {\n        const MAX_RANGE: usize = 200;\n\n        let (base_vec, seed) = base(0..MAX_RANGE);\n        let rand_first = rand::rng().random_range(0..MAX_RANGE);\n\n        let mut shuffled_with_first = base_vec.clone();\n        shuffled_with_first.shuffle_with_seed(seed, |i| i == &rand_first);\n\n        assert_eq!(\n            Some(&rand_first),\n            shuffled_with_first.first(),\n            \"after shuffling the first is expected to be the given item\"\n        );\n\n        let mut shuffled_without_first = base_vec.clone();\n        shuffled_without_first.shuffle_with_seed(seed, |_| false);\n\n        let mut switched_positions = Vec::with_capacity(2);\n        for (i, without_first_value) in shuffled_without_first.iter().enumerate() {\n            if without_first_value != &shuffled_with_first[i] {\n                switched_positions.push(i);\n            } else {\n                assert_eq!(\n                    without_first_value, &shuffled_with_first[i],\n                    \"shuffling with the same seed has the same result\"\n                );\n            }\n        }\n\n        assert_eq!(\n            switched_positions.len(),\n            2,\n            \"only the switched positions should be different\"\n        );\n\n        assert_eq!(\n            shuffled_with_first[switched_positions[0]],\n            shuffled_without_first[switched_positions[1]],\n            \"the switched values should be equal\"\n        );\n\n        assert_eq!(\n            shuffled_with_first[switched_positions[1]],\n            shuffled_without_first[switched_positions[0]],\n            \"the switched values should be equal\"\n        )\n    }\n}\n"
  },
  {
    "path": "connect/src/spirc.rs",
    "content": "use crate::{\n    LoadContextOptions, LoadRequestOptions, PlayContext,\n    context_resolver::{ContextAction, ContextResolver, ResolveContext},\n    core::{\n        Error, Session, SpotifyUri,\n        authentication::Credentials,\n        dealer::{\n            manager::{BoxedStream, BoxedStreamResult, Reply, RequestReply},\n            protocol::{Command, FallbackWrapper, Message, Request},\n        },\n        session::UserAttributes,\n        spclient::TransferRequest,\n    },\n    model::{LoadRequest, PlayingTrack, SpircPlayStatus},\n    playback::{\n        mixer::Mixer,\n        player::{Player, PlayerEvent, PlayerEventChannel, QueueTrack},\n    },\n    protocol::{\n        connect::{Cluster, ClusterUpdate, LogoutCommand, SetVolumeCommand},\n        context::Context,\n        explicit_content_pubsub::UserAttributesUpdate,\n        player::ProvidedTrack,\n        playlist4_external::PlaylistModificationInfo,\n        social_connect_v2::SessionUpdate,\n        transfer_state::TransferState,\n        user_attributes::UserAttributesMutation,\n    },\n    state::{\n        context::{ContextType, ResetContext},\n        provider::IsProvider,\n        {ConnectConfig, ConnectState},\n    },\n};\nuse futures_util::StreamExt;\nuse librespot_protocol::context_page::ContextPage;\nuse protobuf::MessageField;\nuse std::{\n    future::Future,\n    sync::Arc,\n    sync::atomic::{AtomicUsize, Ordering},\n    time::{Duration, SystemTime, UNIX_EPOCH},\n};\nuse thiserror::Error;\nuse tokio::{sync::mpsc, time::sleep};\n\n#[derive(Debug, Error)]\nenum SpircError {\n    #[error(\"response payload empty\")]\n    NoData,\n    #[error(\"{0} had no uri\")]\n    NoUri(&'static str),\n    #[error(\"message pushed for another URI\")]\n    InvalidUri(String),\n    #[error(\"failed to put connect state for new device\")]\n    FailedDealerSetup,\n    #[error(\"unknown endpoint: {0:#?}\")]\n    UnknownEndpoint(serde_json::Value),\n}\n\nimpl From<SpircError> for Error {\n    fn from(err: SpircError) -> Self {\n        use SpircError::*;\n        match err {\n            NoData | NoUri(_) => Error::unavailable(err),\n            InvalidUri(_) | FailedDealerSetup => Error::aborted(err),\n            UnknownEndpoint(_) => Error::unimplemented(err),\n        }\n    }\n}\n\nstruct SpircTask {\n    player: Arc<Player>,\n    mixer: Arc<dyn Mixer>,\n\n    /// the state management object\n    connect_state: ConnectState,\n    connect_established: bool,\n\n    play_request_id: Option<u64>,\n    play_status: SpircPlayStatus,\n\n    connection_id_update: BoxedStreamResult<String>,\n    connect_state_update: BoxedStreamResult<ClusterUpdate>,\n    connect_state_volume_update: BoxedStreamResult<SetVolumeCommand>,\n    connect_state_logout_request: BoxedStreamResult<LogoutCommand>,\n    playlist_update: BoxedStreamResult<PlaylistModificationInfo>,\n    session_update: BoxedStreamResult<FallbackWrapper<SessionUpdate>>,\n    connect_state_command: BoxedStream<RequestReply>,\n    user_attributes_update: BoxedStreamResult<UserAttributesUpdate>,\n    user_attributes_mutation: BoxedStreamResult<UserAttributesMutation>,\n\n    commands: Option<mpsc::UnboundedReceiver<SpircCommand>>,\n    player_events: Option<PlayerEventChannel>,\n\n    context_resolver: ContextResolver,\n\n    emit_set_queue_events: bool,\n\n    shutdown: bool,\n    session: Session,\n\n    /// is set when transferring, and used after resolving the contexts to finish the transfer\n    pub transfer_state: Option<TransferState>,\n\n    /// when set to true, it will update the volume after [VOLUME_UPDATE_DELAY],\n    /// when no other future resolves, otherwise resets the delay\n    update_volume: bool,\n\n    /// when set to true, it will update the volume after [UPDATE_STATE_DELAY],\n    /// when no other future resolves, otherwise resets the delay\n    update_state: bool,\n\n    spirc_id: usize,\n}\n\nstatic SPIRC_COUNTER: AtomicUsize = AtomicUsize::new(0);\n\n#[derive(Debug)]\nenum SpircCommand {\n    Play,\n    PlayPause,\n    Pause,\n    Prev,\n    Next,\n    VolumeUp,\n    VolumeDown,\n    Shutdown,\n    Shuffle(bool),\n    Repeat(bool),\n    RepeatTrack(bool),\n    Disconnect { pause: bool },\n    SetPosition(u32),\n    SetVolume(u16),\n    Activate,\n    Transfer(Option<TransferRequest>),\n    Load(LoadRequest),\n    AddToQueue(SpotifyUri),\n}\n\nconst CONTEXT_FETCH_THRESHOLD: usize = 2;\n\n// delay to update volume after a certain amount of time, instead on each update request\nconst VOLUME_UPDATE_DELAY: Duration = Duration::from_millis(500);\n// to reduce updates to remote, we group some request by waiting for a set amount of time\nconst UPDATE_STATE_DELAY: Duration = Duration::from_millis(200);\n\n/// The spotify connect handle\npub struct Spirc {\n    commands: mpsc::UnboundedSender<SpircCommand>,\n}\n\nimpl Spirc {\n    /// Initializes a new spotify connect device\n    ///\n    /// The returned tuple consists out of a handle to the [`Spirc`] that\n    /// can control the local connect device when active. And a [`Future`]\n    /// which represents the [`Spirc`] event loop that processes the whole\n    /// connect device logic.\n    pub async fn new(\n        config: ConnectConfig,\n        session: Session,\n        credentials: Credentials,\n        player: Arc<Player>,\n        mixer: Arc<dyn Mixer>,\n    ) -> Result<(Spirc, impl Future<Output = ()>), Error> {\n        fn extract_connection_id(msg: Message) -> Result<String, Error> {\n            let connection_id = msg\n                .headers\n                .get(\"Spotify-Connection-Id\")\n                .ok_or_else(|| SpircError::InvalidUri(msg.uri.clone()))?;\n            Ok(connection_id.to_owned())\n        }\n\n        let spirc_id = SPIRC_COUNTER.fetch_add(1, Ordering::AcqRel);\n        debug!(\"new Spirc[{spirc_id}]\");\n\n        let emit_set_queue_events = config.emit_set_queue_events;\n        let connect_state = ConnectState::new(config, &session);\n\n        let connection_id_update = session\n            .dealer()\n            .listen_for(\"hm://pusher/v1/connections/\", extract_connection_id)?;\n\n        let connect_state_update = session\n            .dealer()\n            .listen_for(\"hm://connect-state/v1/cluster\", Message::from_raw)?;\n\n        let connect_state_volume_update = session\n            .dealer()\n            .listen_for(\"hm://connect-state/v1/connect/volume\", Message::from_raw)?;\n\n        let connect_state_logout_request = session\n            .dealer()\n            .listen_for(\"hm://connect-state/v1/connect/logout\", Message::from_raw)?;\n\n        let playlist_update = session\n            .dealer()\n            .listen_for(\"hm://playlist/v2/playlist/\", Message::from_raw)?;\n\n        let session_update = session\n            .dealer()\n            .listen_for(\"social-connect/v2/session_update\", Message::try_from_json)?;\n\n        let user_attributes_update = session\n            .dealer()\n            .listen_for(\"spotify:user:attributes:update\", Message::from_raw)?;\n\n        // can be trigger by toggling autoplay in a desktop client\n        let user_attributes_mutation = session\n            .dealer()\n            .listen_for(\"spotify:user:attributes:mutated\", Message::from_raw)?;\n\n        let connect_state_command = session\n            .dealer()\n            .handle_for(\"hm://connect-state/v1/player/command\")?;\n\n        // pre-acquire client_token, preventing multiple request while running\n        let _ = session.spclient().client_token().await?;\n\n        // Connect *after* all message listeners are registered\n        session.connect(credentials, true).await?;\n\n        // pre-acquire access_token (we need to be authenticated to retrieve a token)\n        let _ = session.login5().auth_token().await?;\n\n        let (cmd_tx, cmd_rx) = mpsc::unbounded_channel();\n\n        let player_events = player.get_player_event_channel();\n\n        let mut task = SpircTask {\n            player,\n            mixer,\n\n            connect_state,\n            connect_established: false,\n\n            play_request_id: None,\n            play_status: SpircPlayStatus::Stopped,\n\n            connection_id_update,\n            connect_state_update,\n            connect_state_volume_update,\n            connect_state_logout_request,\n            playlist_update,\n            session_update,\n            connect_state_command,\n            user_attributes_update,\n            user_attributes_mutation,\n            commands: Some(cmd_rx),\n            player_events: Some(player_events),\n\n            context_resolver: ContextResolver::new(session.clone()),\n\n            emit_set_queue_events,\n\n            shutdown: false,\n            session,\n\n            transfer_state: None,\n            update_volume: false,\n            update_state: false,\n\n            spirc_id,\n        };\n\n        let spirc = Spirc { commands: cmd_tx };\n\n        let initial_volume = task.connect_state.device_info().volume;\n        task.connect_state.set_volume(0);\n\n        match initial_volume.try_into() {\n            Ok(volume) => {\n                task.set_volume(volume);\n                // we don't want to update the volume initially,\n                // we just want to set the mixer to the correct volume\n                task.update_volume = false;\n            }\n            Err(why) => error!(\"failed to update initial volume: {why}\"),\n        };\n\n        Ok((spirc, task.run()))\n    }\n\n    /// Safely shutdowns the spirc.\n    ///\n    /// This pauses the playback, disconnects the connect device and\n    /// bring the future initially returned to an end.\n    pub fn shutdown(&self) -> Result<(), Error> {\n        Ok(self.commands.send(SpircCommand::Shutdown)?)\n    }\n\n    /// Resumes the playback\n    ///\n    /// Does nothing if we are not the active device, or it isn't paused.\n    pub fn play(&self) -> Result<(), Error> {\n        Ok(self.commands.send(SpircCommand::Play)?)\n    }\n\n    /// Resumes or pauses the playback\n    ///\n    /// Does nothing if we are not the active device.\n    pub fn play_pause(&self) -> Result<(), Error> {\n        Ok(self.commands.send(SpircCommand::PlayPause)?)\n    }\n\n    /// Pauses the playback\n    ///\n    /// Does nothing if we are not the active device, or if it isn't playing.\n    pub fn pause(&self) -> Result<(), Error> {\n        Ok(self.commands.send(SpircCommand::Pause)?)\n    }\n\n    /// Seeks to the beginning or skips to the previous track.\n    ///\n    /// Seeks to the beginning when the current track position\n    /// is greater than 3 seconds.\n    ///\n    /// Does nothing if we are not the active device.\n    pub fn prev(&self) -> Result<(), Error> {\n        Ok(self.commands.send(SpircCommand::Prev)?)\n    }\n\n    /// Skips to the next track.\n    ///\n    /// Does nothing if we are not the active device.\n    pub fn next(&self) -> Result<(), Error> {\n        Ok(self.commands.send(SpircCommand::Next)?)\n    }\n\n    /// Increases the volume by configured steps of [ConnectConfig].\n    ///\n    /// Does nothing if we are not the active device.\n    pub fn volume_up(&self) -> Result<(), Error> {\n        Ok(self.commands.send(SpircCommand::VolumeUp)?)\n    }\n\n    /// Decreases the volume by configured steps of [ConnectConfig].\n    ///\n    /// Does nothing if we are not the active device.\n    pub fn volume_down(&self) -> Result<(), Error> {\n        Ok(self.commands.send(SpircCommand::VolumeDown)?)\n    }\n\n    /// Shuffles the playback according to the value.\n    ///\n    /// If true shuffles/reshuffles the playback. Otherwise, does\n    /// nothing (if not shuffled) or unshuffles the playback while\n    /// resuming at the position of the current track.\n    ///\n    /// Does nothing if we are not the active device.\n    pub fn shuffle(&self, shuffle: bool) -> Result<(), Error> {\n        Ok(self.commands.send(SpircCommand::Shuffle(shuffle))?)\n    }\n\n    /// Repeats the playback context according to the value.\n    ///\n    /// Does nothing if we are not the active device.\n    pub fn repeat(&self, repeat: bool) -> Result<(), Error> {\n        Ok(self.commands.send(SpircCommand::Repeat(repeat))?)\n    }\n\n    /// Repeats the current track if true.\n    ///\n    /// Does nothing if we are not the active device.\n    ///\n    /// Skipping to the next track disables the repeating.\n    pub fn repeat_track(&self, repeat: bool) -> Result<(), Error> {\n        Ok(self.commands.send(SpircCommand::RepeatTrack(repeat))?)\n    }\n\n    /// Update the volume to the given value.\n    ///\n    /// Does nothing if we are not the active device.\n    pub fn set_volume(&self, volume: u16) -> Result<(), Error> {\n        Ok(self.commands.send(SpircCommand::SetVolume(volume))?)\n    }\n\n    /// Updates the position to the given value.\n    ///\n    /// Does nothing if we are not the active device.\n    ///\n    /// If value is greater than the track duration,\n    /// the update is ignored.\n    pub fn set_position_ms(&self, position_ms: u32) -> Result<(), Error> {\n        Ok(self.commands.send(SpircCommand::SetPosition(position_ms))?)\n    }\n\n    /// Load a new context and replace the current.\n    ///\n    /// Does nothing if we are not the active device.\n    ///\n    /// Does not overwrite the queue.\n    pub fn load(&self, command: LoadRequest) -> Result<(), Error> {\n        Ok(self.commands.send(SpircCommand::Load(command))?)\n    }\n\n    /// Adds a track, episode, album or playlist to the queue.\n    ///\n    /// Does nothing if we are not the active device.\n    ///\n    /// For albums and playlists, all tracks/episodes are resolved and added to the queue.\n    pub fn add_to_queue(&self, uri: SpotifyUri) -> Result<(), Error> {\n        if !matches!(\n            uri,\n            SpotifyUri::Track { .. }\n                | SpotifyUri::Episode { .. }\n                | SpotifyUri::Album { .. }\n                | SpotifyUri::Playlist { .. }\n        ) {\n            return Err(Error::invalid_argument(\"uri\"));\n        }\n        Ok(self.commands.send(SpircCommand::AddToQueue(uri))?)\n    }\n\n    /// Disconnects the current device and pauses the playback according the value.\n    ///\n    /// Does nothing if we are not the active device.\n    pub fn disconnect(&self, pause: bool) -> Result<(), Error> {\n        Ok(self.commands.send(SpircCommand::Disconnect { pause })?)\n    }\n\n    /// Acquires the control as active connect device.\n    ///\n    /// Does not [Spirc::transfer] the playback. Does nothing if we are not the active device.\n    pub fn activate(&self) -> Result<(), Error> {\n        Ok(self.commands.send(SpircCommand::Activate)?)\n    }\n\n    /// Acquires the control as active connect device over the transfer flow.\n    ///\n    /// Does nothing if we are not the active device.\n    pub fn transfer(&self, transfer_request: Option<TransferRequest>) -> Result<(), Error> {\n        Ok(self\n            .commands\n            .send(SpircCommand::Transfer(transfer_request))?)\n    }\n}\n\nimpl SpircTask {\n    async fn run(mut self) {\n        // simplify unwrapping of received item or parsed result\n        macro_rules! unwrap {\n            ( $next:expr, |$some:ident| $use_some:expr ) => {\n                match $next {\n                    Some($some) => $use_some,\n                    None => {\n                        error!(\"{} selected, but none received\", stringify!($next));\n                        break;\n                    }\n                }\n            };\n            ( $next:expr, match |$ok:ident| $use_ok:expr ) => {\n                unwrap!($next, |$ok| match $ok {\n                    Ok($ok) => $use_ok,\n                    Err(why) => error!(\"could not parse {}: {}\", stringify!($ok), why),\n                })\n            };\n        }\n\n        if let Err(why) = self.session.dealer().start().await {\n            error!(\"starting dealer failed: {why}\");\n            return;\n        }\n\n        while !self.session.is_invalid() && !self.shutdown {\n            let commands = self.commands.as_mut();\n            let player_events = self.player_events.as_mut();\n\n            // when state and volume update have a higher priority than context resolving\n            // because of that the context resolving has to wait, so that the other tasks can finish\n            let allow_context_resolving = !self.update_state && !self.update_volume;\n\n            tokio::select! {\n                // startup of the dealer requires a connection_id, which is retrieved at the very beginning\n                connection_id_update = self.connection_id_update.next() => unwrap! {\n                    connection_id_update,\n                    match |connection_id| if let Err(why) = self.handle_connection_id_update(connection_id).await {\n                        error!(\"failed handling connection id update: {why}\");\n                        break;\n                    }\n                },\n                // main dealer update of any remote device updates\n                cluster_update = self.connect_state_update.next() => unwrap! {\n                    cluster_update,\n                    match |cluster_update| if let Err(e) = self.handle_cluster_update(cluster_update).await {\n                        error!(\"could not dispatch connect state update: {e}\");\n                    }\n                },\n                // main dealer request handling (dealer expects an answer)\n                request = self.connect_state_command.next() => unwrap! {\n                    request,\n                    |request| if let Err(e) = self.handle_connect_state_request(request).await {\n                        error!(\"couldn't handle connect state command: {e}\");\n                    }\n                },\n                // volume request handling is send separately (it's more like a fire forget)\n                volume_update = self.connect_state_volume_update.next() => unwrap! {\n                    volume_update,\n                    match |volume_update| match volume_update.volume.try_into() {\n                        Ok(volume) => self.set_volume(volume),\n                        Err(why) => error!(\"can't update volume, failed to parse i32 to u16: {why}\")\n                    }\n                },\n                logout_request = self.connect_state_logout_request.next() => unwrap! {\n                    logout_request,\n                    |logout_request| {\n                        error!(\"received logout request, currently not supported: {logout_request:#?}\");\n                        // todo: call logout handling\n                    }\n                },\n                playlist_update = self.playlist_update.next() => unwrap! {\n                    playlist_update,\n                    match |playlist_update| if let Err(why) = self.handle_playlist_modification(playlist_update) {\n                        error!(\"failed to handle playlist modification: {why}\")\n                    }\n                },\n                user_attributes_update = self.user_attributes_update.next() => unwrap! {\n                    user_attributes_update,\n                    match |attributes| self.handle_user_attributes_update(attributes)\n                },\n                user_attributes_mutation = self.user_attributes_mutation.next() => unwrap! {\n                    user_attributes_mutation,\n                    match |attributes| self.handle_user_attributes_mutation(attributes)\n                },\n                session_update = self.session_update.next() => unwrap! {\n                    session_update,\n                    match |session_update| self.handle_session_update(session_update)\n                },\n                cmd = async { commands?.recv().await }, if commands.is_some() && self.connect_established => if let Some(cmd) = cmd {\n                    if let Err(e) = self.handle_command(cmd).await {\n                        debug!(\"could not dispatch command: {e}\");\n                    }\n                },\n                event = async { player_events?.recv().await }, if player_events.is_some() => if let Some(event) = event {\n                    if let Err(e) = self.handle_player_event(event) {\n                        error!(\"could not dispatch player event: {e}\");\n                    }\n                },\n                _ = async { sleep(UPDATE_STATE_DELAY).await }, if self.update_state => {\n                    self.update_state = false;\n\n                    if let Err(why) = self.notify().await {\n                        error!(\"state update: {why}\")\n                    }\n                },\n                _ = async { sleep(VOLUME_UPDATE_DELAY).await }, if self.update_volume => {\n                    self.update_volume = false;\n\n                    info!(\"delayed volume update for all devices: volume is now {}\", self.connect_state.device_info().volume);\n                    if let Err(why) = self.connect_state.notify_volume_changed(&self.session).await {\n                        error!(\"error updating connect state for volume update: {why}\")\n                    }\n\n                    // for some reason the web-player does need two separate updates, so that the\n                    // position of the current track is retained, other clients also send a state\n                    // update before they send the volume update\n                    if let Err(why) = self.notify().await {\n                        error!(\"error updating connect state for volume update: {why}\")\n                    }\n                },\n                // context resolver handling, the idea/reason behind it the following:\n                //\n                // when we request a context that has multiple pages (for example an artist)\n                // resolving all pages at once can take around ~1-30sec, when we resolve\n                // everything at once that would block our main loop for that time\n                //\n                // to circumvent this behavior, we request each context separately here and\n                // finish after we received our last item of a type\n                next_context = async {\n                    self.context_resolver.get_next_context(|| {\n                        // Sending local file URIs to this endpoint results in a Bad Request status.\n                        // It's likely appropriate to filter them out anyway; Spotify's backend\n                        // has no knowledge about these tracks and so can't do anything with them.\n                        self.connect_state.recent_track_uris()\n                            .into_iter()\n                            .filter(|t| !t.starts_with(\"spotify:local\"))\n                            .collect::<Vec<_>>()\n                    }).await\n                }, if allow_context_resolving && self.context_resolver.has_next() => {\n                    let update_state = self.handle_next_context(next_context);\n                    if update_state {\n                        if let Err(why) = self.notify().await {\n                            error!(\"update after context resolving failed: {why}\")\n                        }\n                    }\n                },\n                else => break\n            }\n        }\n\n        if !self.shutdown && self.connect_state.is_active() {\n            warn!(\"unexpected shutdown\");\n            if let Err(why) = self.handle_disconnect().await {\n                error!(\"error during disconnecting: {why}\")\n            }\n        }\n\n        // this should clear the active session id, leaving an empty state\n        if let Err(why) = self.session.spclient().delete_connect_state_request().await {\n            error!(\"error during connect state deletion: {why}\")\n        };\n\n        self.session.dealer().close().await;\n    }\n\n    fn handle_next_context(&mut self, next_context: Result<Context, Error>) -> bool {\n        let next_context = match next_context {\n            Err(why) => {\n                self.context_resolver.mark_next_unavailable();\n                self.context_resolver.remove_used_and_invalid();\n                error!(\"{why}\");\n                return false;\n            }\n            Ok(ctx) => ctx,\n        };\n\n        debug!(\"handling next context {:?}\", next_context.uri);\n\n        match self\n            .context_resolver\n            .apply_next_context(&mut self.connect_state, next_context)\n        {\n            Ok(remaining) => {\n                if let Some(remaining) = remaining {\n                    self.context_resolver.add_list(remaining)\n                }\n            }\n            Err(why) => {\n                error!(\"{why}\")\n            }\n        }\n\n        let update_state = if self\n            .context_resolver\n            .try_finish(&mut self.connect_state, &mut self.transfer_state)\n        {\n            self.add_autoplay_resolving_when_required();\n            true\n        } else {\n            false\n        };\n\n        // Fire set queue event if context was successfully loaded\n        if update_state {\n            self.emit_set_queue_event();\n        }\n\n        self.context_resolver.remove_used_and_invalid();\n        update_state\n    }\n\n    /// Emit set queue event via PlayerEvent\n    fn emit_set_queue_event(&self) {\n        if !self.emit_set_queue_events {\n            return;\n        }\n\n        let state_player = self.connect_state.player();\n\n        let current_track = state_player.track.as_ref().map(|t| QueueTrack {\n            uri: t.uri.clone(),\n            provider: t.provider.clone(),\n        });\n\n        let next_tracks: Vec<_> = state_player\n            .next_tracks\n            .iter()\n            .map(|t| QueueTrack {\n                uri: t.uri.clone(),\n                provider: t.provider.clone(),\n            })\n            .collect();\n\n        let prev_tracks: Vec<_> = state_player\n            .prev_tracks\n            .iter()\n            .map(|t| QueueTrack {\n                uri: t.uri.clone(),\n                provider: t.provider.clone(),\n            })\n            .collect();\n\n        let context_uri = self.connect_state.context_uri().clone();\n\n        self.player\n            .emit_set_queue_event(context_uri, current_track, next_tracks, prev_tracks);\n    }\n\n    // todo: is the time_delta still necessary?\n    fn now_ms(&self) -> i64 {\n        let dur = SystemTime::now()\n            .duration_since(UNIX_EPOCH)\n            .unwrap_or_else(|err| err.duration());\n\n        dur.as_millis() as i64 + 1000 * self.session.time_delta()\n    }\n\n    async fn handle_command(&mut self, cmd: SpircCommand) -> Result<(), Error> {\n        trace!(\"Received SpircCommand::{cmd:?}\");\n        match cmd {\n            SpircCommand::Shutdown => {\n                trace!(\"Received SpircCommand::Shutdown\");\n                self.handle_pause();\n                self.handle_disconnect().await?;\n                self.shutdown = true;\n                if let Some(rx) = self.commands.as_mut() {\n                    rx.close()\n                }\n            }\n            SpircCommand::Transfer(request) if !self.connect_state.is_active() => {\n                let device_id = self.session.device_id();\n                self.session\n                    .spclient()\n                    .transfer(device_id, device_id, request.as_ref())\n                    .await?;\n                return Ok(());\n            }\n            SpircCommand::Activate if !self.connect_state.is_active() => {\n                trace!(\"Received SpircCommand::{cmd:?}\");\n                self.handle_activate();\n                return self.notify().await;\n            }\n            SpircCommand::Transfer(..) | SpircCommand::Activate => {\n                warn!(\"SpircCommand::{cmd:?} will be ignored while already active\")\n            }\n            _ if !self.connect_state.is_active() => {\n                warn!(\"SpircCommand::{cmd:?} will be ignored while Not Active\")\n            }\n            SpircCommand::Disconnect { pause } => {\n                if pause {\n                    self.handle_pause()\n                }\n                return self.handle_disconnect().await;\n            }\n            SpircCommand::Play => self.handle_play(),\n            SpircCommand::PlayPause => self.handle_play_pause(),\n            SpircCommand::Pause => self.handle_pause(),\n            SpircCommand::Prev => self.handle_prev()?,\n            SpircCommand::Next => self.handle_next(None)?,\n            SpircCommand::VolumeUp => self.handle_volume_up(),\n            SpircCommand::VolumeDown => self.handle_volume_down(),\n            SpircCommand::Shuffle(shuffle) => self.handle_shuffle(shuffle)?,\n            SpircCommand::Repeat(repeat) => self.handle_repeat_context(repeat)?,\n            SpircCommand::RepeatTrack(repeat) => self.handle_repeat_track(repeat),\n            SpircCommand::SetPosition(position) => self.handle_seek(position),\n            SpircCommand::SetVolume(volume) => self.set_volume(volume),\n            SpircCommand::Load(command) => self.handle_load(command, None, None).await?,\n            SpircCommand::AddToQueue(uri) => self.handle_add_to_queue(uri).await,\n        };\n\n        self.notify().await\n    }\n\n    fn handle_player_event(&mut self, event: PlayerEvent) -> Result<(), Error> {\n        if let PlayerEvent::TrackChanged { audio_item } = event {\n            self.connect_state.update_duration(audio_item.duration_ms);\n            self.update_state = true;\n            return Ok(());\n        }\n\n        // update play_request_id\n        if let PlayerEvent::PlayRequestIdChanged { play_request_id } = event {\n            self.play_request_id = Some(play_request_id);\n            return Ok(());\n        }\n\n        let is_current_track = matches! {\n            (event.get_play_request_id(), self.play_request_id),\n            (Some(event_id), Some(current_id)) if event_id == current_id\n        };\n\n        // we only process events if the play_request_id matches. If it doesn't, it is\n        // an event that belongs to a previous track and only arrives now due to a race\n        // condition. In this case we have updated the state already and don't want to\n        // mess with it.\n        if !is_current_track {\n            return Ok(());\n        }\n\n        match event {\n            PlayerEvent::EndOfTrack { .. } => {\n                let next_track = self\n                    .connect_state\n                    .repeat_track()\n                    .then(|| self.connect_state.current_track(|t| t.uri.clone()));\n\n                self.handle_next(next_track)?\n            }\n            PlayerEvent::Loading { .. } => match self.play_status {\n                SpircPlayStatus::LoadingPlay { position_ms } => {\n                    self.connect_state\n                        .update_position(position_ms, self.now_ms());\n                    trace!(\"==> LoadingPlay\");\n                }\n                SpircPlayStatus::LoadingPause { position_ms } => {\n                    self.connect_state\n                        .update_position(position_ms, self.now_ms());\n                    trace!(\"==> LoadingPause\");\n                }\n                _ => {\n                    self.connect_state.update_position(0, self.now_ms());\n                    trace!(\"==> Loading\");\n                }\n            },\n            PlayerEvent::Seeked { position_ms, .. } => {\n                trace!(\"==> Seeked\");\n                self.connect_state\n                    .update_position(position_ms, self.now_ms())\n            }\n            PlayerEvent::Playing { position_ms, .. }\n            | PlayerEvent::PositionCorrection { position_ms, .. } => {\n                trace!(\"==> Playing\");\n                let new_nominal_start_time = self.now_ms() - position_ms as i64;\n                match self.play_status {\n                    SpircPlayStatus::Playing {\n                        ref mut nominal_start_time,\n                        ..\n                    } => {\n                        if (*nominal_start_time - new_nominal_start_time).abs() > 100 {\n                            *nominal_start_time = new_nominal_start_time;\n                            self.connect_state\n                                .update_position(position_ms, self.now_ms());\n                        } else {\n                            return Ok(());\n                        }\n                    }\n                    SpircPlayStatus::LoadingPlay { .. } | SpircPlayStatus::LoadingPause { .. } => {\n                        self.connect_state\n                            .update_position(position_ms, self.now_ms());\n                        self.play_status = SpircPlayStatus::Playing {\n                            nominal_start_time: new_nominal_start_time,\n                            preloading_of_next_track_triggered: false,\n                        };\n                    }\n                    _ => return Ok(()),\n                }\n            }\n            PlayerEvent::Paused {\n                position_ms: new_position_ms,\n                ..\n            } => {\n                trace!(\"==> Paused\");\n                match self.play_status {\n                    SpircPlayStatus::Paused { .. } | SpircPlayStatus::Playing { .. } => {\n                        self.connect_state\n                            .update_position(new_position_ms, self.now_ms());\n                        self.play_status = SpircPlayStatus::Paused {\n                            position_ms: new_position_ms,\n                            preloading_of_next_track_triggered: false,\n                        };\n                    }\n                    SpircPlayStatus::LoadingPlay { .. } | SpircPlayStatus::LoadingPause { .. } => {\n                        self.connect_state\n                            .update_position(new_position_ms, self.now_ms());\n                        self.play_status = SpircPlayStatus::Paused {\n                            position_ms: new_position_ms,\n                            preloading_of_next_track_triggered: false,\n                        };\n                    }\n                    _ => return Ok(()),\n                }\n            }\n            PlayerEvent::Stopped { .. } => {\n                trace!(\"==> Stopped\");\n                match self.play_status {\n                    SpircPlayStatus::Stopped => return Ok(()),\n                    _ => self.play_status = SpircPlayStatus::Stopped,\n                }\n            }\n            PlayerEvent::TimeToPreloadNextTrack { .. } => {\n                self.handle_preload_next_track();\n                return Ok(());\n            }\n            PlayerEvent::Unavailable { track_id, .. } => {\n                self.handle_unavailable(&track_id)?;\n                if self.connect_state.current_track(|t| &t.uri) == &track_id.to_uri() {\n                    self.handle_next(None)?\n                }\n            }\n            _ => return Ok(()),\n        }\n\n        self.update_state = true;\n        Ok(())\n    }\n\n    async fn handle_connection_id_update(&mut self, connection_id: String) -> Result<(), Error> {\n        trace!(\"Received connection ID update: {connection_id:?}\");\n        self.session.set_connection_id(&connection_id);\n\n        let cluster = match self\n            .connect_state\n            .notify_new_device_appeared(&self.session)\n            .await\n        {\n            Ok(res) => Cluster::parse_from_bytes(&res).ok(),\n            Err(why) => {\n                error!(\"{why:?}\");\n                None\n            }\n        }\n        .ok_or(SpircError::FailedDealerSetup)?;\n\n        debug!(\n            \"successfully put connect state for {} with connection-id {connection_id}\",\n            self.session.device_id()\n        );\n\n        self.connect_established = true;\n\n        let same_session = cluster.player_state.session_id == self.session.session_id()\n            || cluster.player_state.session_id.is_empty();\n        if !cluster.active_device_id.is_empty() || !same_session {\n            info!(\n                \"active device is <{}> with session <{}>\",\n                cluster.active_device_id, cluster.player_state.session_id\n            );\n            return Ok(());\n        } else if cluster.transfer_data.is_empty() {\n            debug!(\"got empty transfer state, do nothing\");\n            return Ok(());\n        } else {\n            info!(\n                \"trying to take over control automatically, session_id: {}\",\n                cluster.player_state.session_id\n            )\n        }\n\n        use protobuf::Message;\n\n        match TransferState::parse_from_bytes(&cluster.transfer_data) {\n            Ok(transfer_state) => self.handle_transfer(transfer_state)?,\n            Err(why) => error!(\"failed to take over control: {why}\"),\n        }\n\n        Ok(())\n    }\n\n    fn handle_user_attributes_update(&mut self, update: UserAttributesUpdate) {\n        trace!(\"Received attributes update: {update:#?}\");\n        let attributes: UserAttributes = update\n            .pairs\n            .iter()\n            .map(|(key, value)| (key.to_owned(), value.to_owned()))\n            .collect();\n        self.session.set_user_attributes(attributes)\n    }\n\n    fn handle_user_attributes_mutation(&mut self, mutation: UserAttributesMutation) {\n        for attribute in mutation.fields.iter() {\n            let key = &attribute.name;\n\n            if key == \"autoplay\" && self.session.config().autoplay.is_some() {\n                trace!(\"Autoplay override active. Ignoring mutation.\");\n                continue;\n            }\n\n            if let Some(old_value) = self.session.user_data().attributes.get(key) {\n                let new_value = match old_value.as_ref() {\n                    \"0\" => \"1\",\n                    \"1\" => \"0\",\n                    _ => old_value,\n                };\n                self.session.set_user_attribute(key, new_value);\n\n                trace!(\"Received attribute mutation, {key} was {old_value} is now {new_value}\");\n\n                if key == \"filter-explicit-content\" && new_value == \"1\" {\n                    self.player\n                        .emit_filter_explicit_content_changed_event(matches!(new_value, \"1\"));\n                }\n\n                if key == \"autoplay\" && old_value != new_value {\n                    self.player\n                        .emit_auto_play_changed_event(matches!(new_value, \"1\"));\n\n                    self.add_autoplay_resolving_when_required()\n                }\n            } else {\n                trace!(\"Received attribute mutation for {key} but key was not found!\");\n            }\n        }\n    }\n\n    async fn handle_cluster_update(\n        &mut self,\n        mut cluster_update: ClusterUpdate,\n    ) -> Result<(), Error> {\n        let reason = cluster_update.update_reason.enum_value();\n\n        let device_ids = cluster_update.devices_that_changed.join(\", \");\n        debug!(\n            \"cluster update: {reason:?} from {device_ids}, active device: {}\",\n            cluster_update.cluster.active_device_id\n        );\n\n        if let Some(cluster) = cluster_update.cluster.take() {\n            let became_inactive = self.connect_state.is_active()\n                && cluster.active_device_id != self.session.device_id();\n            if became_inactive {\n                info!(\"device became inactive\");\n                self.handle_disconnect().await?;\n                self.handle_stop();\n            } else if self.connect_state.is_active() {\n                // fixme: workaround fix, because of missing information why it behaves like it does\n                //  background: when another device sends a connect-state update, some player's position de-syncs\n                //  tried: providing session_id, playback_id, track-metadata \"track_player\"\n                self.update_state = true;\n            }\n        } else if self.connect_state.is_active() {\n            self.connect_state.became_inactive(&self.session).await?;\n        }\n\n        Ok(())\n    }\n\n    async fn handle_connect_state_request(\n        &mut self,\n        (request, sender): RequestReply,\n    ) -> Result<(), Error> {\n        self.connect_state.set_last_command(request.clone());\n\n        debug!(\n            \"handling: '{}' from {}\",\n            request.command, request.sent_by_device_id\n        );\n\n        let response = match self.handle_request(request).await {\n            Ok(_) => Reply::Success,\n            Err(why) => {\n                error!(\"failed to handle request: {why}\");\n                Reply::Failure\n            }\n        };\n\n        sender.send(response).map_err(Into::into)\n    }\n\n    async fn handle_request(&mut self, request: Request) -> Result<(), Error> {\n        use Command::*;\n\n        match request.command {\n            // errors and unknown commands\n            Transfer(transfer) if transfer.data.is_none() => {\n                warn!(\"transfer endpoint didn't contain any data to transfer\");\n                Err(SpircError::NoData)?\n            }\n            Unknown(unknown) => Err(SpircError::UnknownEndpoint(unknown))?,\n            // implicit update of the connect_state\n            UpdateContext(update_context) => {\n                if matches!(update_context.context.uri, Some(ref uri) if uri != self.connect_state.context_uri())\n                {\n                    debug!(\n                        \"ignoring context update for <{:?}>, because it isn't the current context <{}>\",\n                        update_context.context.uri,\n                        self.connect_state.context_uri()\n                    )\n                } else {\n                    self.context_resolver.add(ResolveContext::from_context(\n                        update_context.context,\n                        ContextType::Default,\n                        ContextAction::Replace,\n                    ))\n                }\n                return Ok(());\n            }\n            // modification and update of the connect_state\n            Transfer(transfer) => {\n                self.handle_transfer(transfer.data.expect(\"by condition checked\"))?;\n                return self.notify().await;\n            }\n            Play(mut play) => {\n                if !self.connect_state.is_active() {\n                    self.handle_activate()\n                }\n\n                let context = match play.context.uri {\n                    Some(s) => PlayContext::Uri(s),\n                    None if !play.context.pages.is_empty() => PlayContext::Tracks(\n                        play.context\n                            .pages\n                            .iter()\n                            .cloned()\n                            .flat_map(|p| p.tracks)\n                            .flat_map(|t| t.uri)\n                            .collect(),\n                    ),\n                    None => Err(SpircError::NoUri(\"context\"))?,\n                };\n\n                let context_options = play\n                    .options\n                    .player_options_override\n                    .map(Into::into)\n                    .map(LoadContextOptions::Options);\n\n                let fallback_index = play\n                    .options\n                    .skip_to\n                    .as_ref()\n                    .and_then(|s| s.track_index)\n                    .map(|i| i as usize);\n\n                self.handle_load(\n                    LoadRequest {\n                        context,\n                        options: LoadRequestOptions {\n                            start_playing: true,\n                            seek_to: play.options.seek_to.unwrap_or_default(),\n                            playing_track: play.options.skip_to.and_then(|s| s.try_into().ok()),\n                            context_options,\n                        },\n                    },\n                    play.context.pages.pop(),\n                    fallback_index,\n                )\n                .await?;\n\n                self.connect_state.set_origin(play.play_origin)\n            }\n            Pause(_) => self.handle_pause(),\n            SeekTo(seek_to) => {\n                // for some reason the position is stored in value, not in position\n                trace!(\"seek to {seek_to:?}\");\n                self.handle_seek(seek_to.value)\n            }\n            SetShufflingContext(shuffle) => self.handle_shuffle(shuffle.value)?,\n            SetRepeatingContext(repeat_context) => {\n                self.handle_repeat_context(repeat_context.value)?\n            }\n            SetRepeatingTrack(repeat_track) => self.handle_repeat_track(repeat_track.value),\n            AddToQueue(add_to_queue) => {\n                self.connect_state.add_to_queue(add_to_queue.track, true);\n                self.emit_set_queue_event();\n            }\n            SetQueue(set_queue) => {\n                self.connect_state.handle_set_queue(set_queue);\n                self.emit_set_queue_event();\n            }\n            SetOptions(set_options) => {\n                if let Some(repeat_context) = set_options.repeating_context {\n                    self.handle_repeat_context(repeat_context)?\n                }\n\n                if let Some(repeat_track) = set_options.repeating_track {\n                    self.handle_repeat_track(repeat_track)\n                }\n\n                let shuffle = set_options.shuffling_context;\n                if let Some(shuffle) = shuffle {\n                    self.handle_shuffle(shuffle)?;\n                }\n            }\n            SkipNext(skip_next) => self.handle_next(skip_next.track.map(|t| t.uri))?,\n            SkipPrev(_) => self.handle_prev()?,\n            Resume(_) if matches!(self.play_status, SpircPlayStatus::Stopped) => {\n                self.load_track(true, 0)?\n            }\n            Resume(_) => self.handle_play(),\n        }\n\n        self.update_state = true;\n        Ok(())\n    }\n\n    fn handle_transfer(&mut self, mut transfer: TransferState) -> Result<(), Error> {\n        let mut ctx_uri = match transfer.current_session.context.uri {\n            None => Err(SpircError::NoUri(\"transfer context\"))?,\n            // can apparently happen when a state is transferred and was started with \"uris\" via the api\n            Some(ref uri) if uri == \"-\" || uri.is_empty() => None,\n            Some(ref uri) => Some(uri.clone()),\n        };\n\n        self.connect_state.reset_context(\n            ctx_uri\n                .as_deref()\n                .map(ResetContext::WhenDifferent)\n                .unwrap_or(ResetContext::Completely),\n        );\n\n        match self.connect_state.current_track_from_transfer(&transfer) {\n            Err(why) => warn!(\"didn't find initial track: {why}\"),\n            Ok(track) => {\n                debug!(\"found initial track <{}>\", track.uri);\n                self.connect_state.set_track(track)\n            }\n        };\n\n        let autoplay = self.connect_state.current_track(|t| t.is_autoplay());\n        if autoplay {\n            ctx_uri = ctx_uri.map(|c| c.replace(\"station:\", \"\"));\n        }\n\n        let fallback = self.connect_state.current_track(|t| &t.uri).clone();\n        let load_from_context_uri = ctx_uri.is_some();\n\n        match ctx_uri {\n            Some(ref uri) => {\n                self.context_resolver.add(ResolveContext::from_uri(\n                    uri.clone(),\n                    &fallback,\n                    ContextType::Default,\n                    ContextAction::Replace,\n                ));\n            }\n            None => {\n                let all_tracks = transfer\n                    .current_session\n                    .context\n                    .pages\n                    .iter()\n                    .cloned()\n                    .flat_map(|p| p.tracks)\n                    .collect::<Vec<_>>();\n\n                if !all_tracks.is_empty() {\n                    self.load_context_from_tracks(all_tracks)?;\n                } else {\n                    warn!(\n                        \"tried to transfer with an invalid state, using fallback as ctx_uri ({fallback})\"\n                    );\n                    ctx_uri = Some(fallback.clone())\n                }\n            }\n        };\n\n        self.handle_activate();\n\n        let timestamp = self.now_ms();\n        let state = &mut self.connect_state;\n        state.handle_initial_transfer(&mut transfer, ctx_uri.clone());\n\n        // adjust active context, so resolve knows for which context it should set up the state\n        state.active_context = if autoplay {\n            ContextType::Autoplay\n        } else {\n            ContextType::Default\n        };\n\n        // update position if the track continued playing\n        let transfer_timestamp = transfer.playback.timestamp.unwrap_or_default();\n        let position = match transfer.playback.position_as_of_timestamp {\n            Some(position) if transfer.playback.is_paused.unwrap_or_default() => position.into(),\n            // update position if the track continued playing\n            Some(position) if position > 0 => {\n                let time_since_position_update = timestamp - transfer_timestamp;\n                i64::from(position) + time_since_position_update\n            }\n            _ => 0,\n        };\n\n        let is_playing = !transfer.playback.is_paused();\n\n        if self.connect_state.current_track(|t| t.is_autoplay()) || autoplay {\n            if let Some(ctx_uri) = ctx_uri {\n                debug!(\"currently in autoplay context, async resolving autoplay for {ctx_uri}\");\n                self.context_resolver.add(ResolveContext::from_uri(\n                    ctx_uri,\n                    fallback,\n                    ContextType::Autoplay,\n                    ContextAction::Replace,\n                ))\n            } else {\n                warn!(\"couldn't resolve autoplay context without a context uri\");\n            }\n        }\n\n        if load_from_context_uri {\n            self.transfer_state = Some(transfer);\n        } else {\n            match self.connect_state.get_context(ContextType::Default) {\n                Err(why) => {\n                    warn!(\"continuing transfer in an unknown state. {why}\");\n                    self.transfer_state = Some(transfer);\n                }\n                Ok(ctx) => {\n                    let idx = ConnectState::find_index_in_context(ctx, |pt| {\n                        self.connect_state.current_track(|t| pt.uri == t.uri)\n                    })?;\n                    self.connect_state.reset_playback_to_position(Some(idx))?;\n                }\n            }\n        }\n\n        self.load_track(is_playing, position.try_into()?)\n    }\n\n    async fn handle_disconnect(&mut self) -> Result<(), Error> {\n        self.context_resolver.clear();\n\n        self.play_status = SpircPlayStatus::Stopped {};\n        self.connect_state\n            .update_position_in_relation(self.now_ms());\n        self.notify().await?;\n\n        self.connect_state.became_inactive(&self.session).await?;\n\n        self.player\n            .emit_session_disconnected_event(self.session.connection_id(), self.session.username());\n\n        Ok(())\n    }\n\n    fn handle_stop(&mut self) {\n        self.player.stop();\n        self.connect_state.update_position(0, self.now_ms());\n        self.connect_state.clear_next_tracks();\n\n        if let Err(why) = self.connect_state.reset_playback_to_position(None) {\n            warn!(\"failed filling up next_track during stopping: {why}\")\n        }\n    }\n\n    fn handle_activate(&mut self) {\n        self.connect_state.set_active(true);\n        self.player\n            .emit_session_connected_event(self.session.connection_id(), self.session.username());\n        self.player.emit_session_client_changed_event(\n            self.session.client_id(),\n            self.session.client_name(),\n            self.session.client_brand_name(),\n            self.session.client_model_name(),\n        );\n\n        self.player\n            .emit_volume_changed_event(self.connect_state.device_info().volume as u16);\n\n        self.player\n            .emit_auto_play_changed_event(self.session.autoplay());\n\n        self.player\n            .emit_filter_explicit_content_changed_event(self.session.filter_explicit_content());\n\n        self.player\n            .emit_shuffle_changed_event(self.connect_state.shuffling_context());\n\n        self.player.emit_repeat_changed_event(\n            self.connect_state.repeat_context(),\n            self.connect_state.repeat_track(),\n        );\n    }\n\n    async fn handle_load(\n        &mut self,\n        cmd: LoadRequest,\n        page: Option<ContextPage>,\n        fallback_index: Option<usize>,\n    ) -> Result<(), Error> {\n        self.connect_state\n            .reset_context(if let PlayContext::Uri(ref uri) = cmd.context {\n                ResetContext::WhenDifferent(uri)\n            } else {\n                ResetContext::Completely\n            });\n\n        self.connect_state.reset_options();\n\n        let autoplay = matches!(cmd.context_options, Some(LoadContextOptions::Autoplay));\n        match cmd.context {\n            PlayContext::Uri(uri) => {\n                self.load_context_from_uri(uri, page.as_ref(), autoplay)\n                    .await?\n            }\n            PlayContext::Tracks(tracks) => self.load_context_from_tracks(tracks)?,\n        }\n\n        let cmd_options = cmd.options;\n\n        self.connect_state.set_active_context(ContextType::Default);\n\n        // for play commands with skip by uid, the context of the command contains\n        // tracks with uri and uid, so we merge the new context with the resolved/existing context\n        self.connect_state.merge_context(page);\n\n        // load here, so that we clear the queue only after we definitely retrieved a new context\n        self.connect_state.clear_next_tracks();\n        self.connect_state.clear_restrictions();\n\n        debug!(\"play track <{:?}>\", cmd_options.playing_track);\n\n        let index = match cmd_options.playing_track {\n            None => None,\n            Some(ref playing_track) => Some(match playing_track {\n                PlayingTrack::Index(i) => Ok(*i as usize),\n                PlayingTrack::Uri(uri) => {\n                    let ctx = self.connect_state.get_context(ContextType::Default)?;\n                    ConnectState::find_index_in_context(ctx, |t| &t.uri == uri)\n                }\n                PlayingTrack::Uid(uid) => {\n                    let ctx = self.connect_state.get_context(ContextType::Default)?;\n                    ConnectState::find_index_in_context(ctx, |t| &t.uid == uid)\n                }\n            }),\n        }\n        .map(|i| {\n            i.unwrap_or_else(|why| {\n                warn!(\n                    \"Failed to resolve index by {:?}, using fallback index: {:?} (Error: {why})\",\n                    cmd_options.playing_track, fallback_index\n                );\n                fallback_index.unwrap_or_default()\n            })\n        });\n\n        if let Some(LoadContextOptions::Options(ref options)) = cmd_options.context_options {\n            debug!(\n                \"loading with shuffle: <{}>, repeat track: <{}> context: <{}>\",\n                options.shuffle, options.repeat, options.repeat_track\n            );\n\n            self.connect_state.set_shuffle(options.shuffle);\n            self.connect_state.set_repeat_context(options.repeat);\n            self.connect_state.set_repeat_track(options.repeat_track);\n        }\n\n        if matches!(cmd_options.context_options, Some(LoadContextOptions::Options(ref o)) if o.shuffle)\n        {\n            if let Some(index) = index {\n                self.connect_state.set_current_track(index)?;\n            } else {\n                self.connect_state.set_current_track_random()?;\n            }\n\n            if self.context_resolver.has_next() {\n                self.connect_state.update_queue_revision()\n            } else {\n                self.connect_state.shuffle_new()?;\n                self.add_autoplay_resolving_when_required();\n            }\n        } else {\n            self.connect_state\n                .set_current_track(index.unwrap_or_default())?;\n            self.connect_state.reset_playback_to_position(index)?;\n            self.add_autoplay_resolving_when_required();\n        }\n\n        if self.connect_state.current_track(MessageField::is_some) {\n            self.load_track(cmd_options.start_playing, cmd_options.seek_to)?;\n        } else {\n            info!(\"No active track, stopping\");\n            self.handle_stop()\n        }\n\n        Ok(())\n    }\n\n    async fn load_context_from_uri(\n        &mut self,\n        context_uri: String,\n        page: Option<&ContextPage>,\n        autoplay: bool,\n    ) -> Result<(), Error> {\n        if !self.connect_state.is_active() {\n            self.handle_activate();\n        }\n\n        let update_context = if autoplay {\n            ContextType::Autoplay\n        } else {\n            ContextType::Default\n        };\n\n        self.connect_state.set_active_context(update_context);\n\n        let fallback = match page {\n            // check that the uri is valid or the page has a valid uri that can be used\n            Some(page) => match ConnectState::find_valid_uri(Some(&context_uri), Some(page)) {\n                Some(ctx_uri) => ctx_uri,\n                None => return Err(SpircError::InvalidUri(context_uri).into()),\n            },\n            // when there is no page, the uri should be valid\n            None => &context_uri,\n        };\n\n        let current_context_uri = self.connect_state.context_uri();\n\n        if current_context_uri == &context_uri && fallback == context_uri {\n            debug!(\"context <{current_context_uri}> didn't change, no resolving required\")\n        } else {\n            debug!(\"resolving context for load command\");\n            self.context_resolver.clear();\n            self.context_resolver.add(ResolveContext::from_uri(\n                &context_uri,\n                fallback,\n                update_context,\n                ContextAction::Replace,\n            ));\n            let context = self.context_resolver.get_next_context(Vec::new).await;\n            self.handle_next_context(context);\n        }\n\n        Ok(())\n    }\n\n    fn load_context_from_tracks(&mut self, tracks: impl Into<ContextPage>) -> Result<(), Error> {\n        const WEB_API_URI: &str = \"spotify:web-api\";\n        let ctx = Context {\n            // by providing values for uri/url the player in the official client's isn't frozen\n            uri: Some(WEB_API_URI.into()),\n            url: Some(format!(\"context://{WEB_API_URI}\")),\n            pages: vec![tracks.into()],\n            ..Default::default()\n        };\n\n        let _ = self\n            .connect_state\n            .update_context(ctx, ContextType::Default)?;\n\n        self.emit_set_queue_event();\n\n        Ok(())\n    }\n\n    fn handle_play(&mut self) {\n        match self.play_status {\n            SpircPlayStatus::Paused {\n                position_ms,\n                preloading_of_next_track_triggered,\n            } => {\n                self.player.play();\n                self.connect_state\n                    .update_position(position_ms, self.now_ms());\n                self.play_status = SpircPlayStatus::Playing {\n                    nominal_start_time: self.now_ms() - position_ms as i64,\n                    preloading_of_next_track_triggered,\n                };\n            }\n            SpircPlayStatus::LoadingPause { position_ms } => {\n                self.player.play();\n                self.play_status = SpircPlayStatus::LoadingPlay { position_ms };\n            }\n            _ => return,\n        }\n\n        // Synchronize the volume from the mixer. This is useful on\n        // systems that can switch sources from and back to librespot.\n        let current_volume = self.mixer.volume();\n        self.set_volume(current_volume);\n    }\n\n    fn handle_play_pause(&mut self) {\n        match self.play_status {\n            SpircPlayStatus::Paused { .. } | SpircPlayStatus::LoadingPause { .. } => {\n                self.handle_play()\n            }\n            SpircPlayStatus::Playing { .. } | SpircPlayStatus::LoadingPlay { .. } => {\n                self.handle_pause()\n            }\n            _ => (),\n        }\n    }\n\n    fn handle_pause(&mut self) {\n        match self.play_status {\n            SpircPlayStatus::Playing {\n                nominal_start_time,\n                preloading_of_next_track_triggered,\n            } => {\n                self.player.pause();\n                let position_ms = (self.now_ms() - nominal_start_time) as u32;\n                self.connect_state\n                    .update_position(position_ms, self.now_ms());\n                self.play_status = SpircPlayStatus::Paused {\n                    position_ms,\n                    preloading_of_next_track_triggered,\n                };\n            }\n            SpircPlayStatus::LoadingPlay { position_ms } => {\n                self.player.pause();\n                self.play_status = SpircPlayStatus::LoadingPause { position_ms };\n            }\n            _ => (),\n        }\n    }\n\n    fn handle_seek(&mut self, position_ms: u32) {\n        let duration = self.connect_state.player().duration;\n        if i64::from(position_ms) > duration {\n            warn!(\"tried to seek to {position_ms}ms of {duration}ms\");\n            return;\n        }\n\n        self.connect_state\n            .update_position(position_ms, self.now_ms());\n        self.player.seek(position_ms);\n        let now = self.now_ms();\n        match self.play_status {\n            SpircPlayStatus::Stopped => (),\n            SpircPlayStatus::LoadingPause {\n                position_ms: ref mut position,\n            }\n            | SpircPlayStatus::LoadingPlay {\n                position_ms: ref mut position,\n            }\n            | SpircPlayStatus::Paused {\n                position_ms: ref mut position,\n                ..\n            } => *position = position_ms,\n            SpircPlayStatus::Playing {\n                ref mut nominal_start_time,\n                ..\n            } => *nominal_start_time = now - position_ms as i64,\n        };\n    }\n\n    fn handle_shuffle(&mut self, shuffle: bool) -> Result<(), Error> {\n        self.player.emit_shuffle_changed_event(shuffle);\n        self.connect_state.handle_shuffle(shuffle)\n    }\n\n    fn handle_repeat_context(&mut self, repeat: bool) -> Result<(), Error> {\n        self.player\n            .emit_repeat_changed_event(repeat, self.connect_state.repeat_track());\n        self.connect_state.handle_set_repeat_context(repeat)\n    }\n\n    fn handle_repeat_track(&mut self, repeat: bool) {\n        self.player\n            .emit_repeat_changed_event(self.connect_state.repeat_context(), repeat);\n        self.connect_state.set_repeat_track(repeat);\n    }\n\n    async fn handle_add_to_queue(&mut self, uri: SpotifyUri) {\n        let track_uris: Vec<String> = match uri {\n            SpotifyUri::Track { .. } | SpotifyUri::Episode { .. } => vec![uri.to_uri()],\n            SpotifyUri::Album { .. } | SpotifyUri::Playlist { .. } => {\n                match self.session.spclient().get_context(&uri.to_uri()).await {\n                    Ok(context) => context\n                        .pages\n                        .iter()\n                        .flat_map(|page| page.tracks.iter())\n                        .filter_map(|track| track.uri.clone())\n                        .collect(),\n                    Err(e) => {\n                        error!(\"failed to resolve context for {}: {e}\", uri.item_type());\n                        return;\n                    }\n                }\n            }\n            _ => return,\n        };\n\n        for track_uri in track_uris {\n            let track = ProvidedTrack {\n                uri: track_uri.clone(),\n                ..Default::default()\n            };\n            self.connect_state.add_to_queue(track, true);\n        }\n        self.emit_set_queue_event();\n    }\n\n    fn handle_preload_next_track(&mut self) {\n        // Requests the player thread to preload the next track\n        match self.play_status {\n            SpircPlayStatus::Paused {\n                ref mut preloading_of_next_track_triggered,\n                ..\n            }\n            | SpircPlayStatus::Playing {\n                ref mut preloading_of_next_track_triggered,\n                ..\n            } => {\n                *preloading_of_next_track_triggered = true;\n            }\n            _ => (),\n        }\n\n        if let Some(track_id) = self.connect_state.preview_next_track() {\n            self.player.preload(track_id);\n        }\n    }\n\n    // Mark unavailable tracks so we can skip them later\n    fn handle_unavailable(&mut self, track_id: &SpotifyUri) -> Result<(), Error> {\n        self.connect_state.mark_unavailable(track_id)?;\n        self.handle_preload_next_track();\n\n        Ok(())\n    }\n\n    fn add_autoplay_resolving_when_required(&mut self) {\n        let require_load_new = !self\n            .connect_state\n            .has_next_tracks(Some(CONTEXT_FETCH_THRESHOLD))\n            && self.session.autoplay()\n            && !self.connect_state.context_uri().is_empty();\n\n        if !require_load_new {\n            return;\n        }\n\n        let current_context = self.connect_state.context_uri();\n        let fallback = self.connect_state.current_track(|t| &t.uri);\n\n        let has_tracks = self\n            .connect_state\n            .get_context(ContextType::Autoplay)\n            .map(|c| !c.tracks.is_empty())\n            .unwrap_or_default();\n\n        let resolve = ResolveContext::from_uri(\n            current_context,\n            fallback,\n            ContextType::Autoplay,\n            if has_tracks {\n                ContextAction::Append\n            } else {\n                ContextAction::Replace\n            },\n        );\n\n        self.context_resolver.add(resolve);\n    }\n\n    fn handle_next(&mut self, track_uri: Option<String>) -> Result<(), Error> {\n        let continue_playing = self.connect_state.is_playing();\n\n        let current_uri = self.connect_state.current_track(|t| &t.uri);\n        let mut has_next_track =\n            matches!(track_uri, Some(ref track_uri) if current_uri == track_uri);\n\n        if !has_next_track {\n            has_next_track = loop {\n                let index = self.connect_state.next_track()?;\n\n                let current_uri = self.connect_state.current_track(|t| &t.uri);\n                if matches!(track_uri, Some(ref track_uri) if current_uri != track_uri) {\n                    continue;\n                } else {\n                    break index.is_some();\n                }\n            };\n        };\n\n        if has_next_track {\n            self.add_autoplay_resolving_when_required();\n            self.load_track(continue_playing, 0)\n        } else {\n            info!(\"Not playing next track because there are no more tracks left in queue.\");\n            self.handle_stop();\n            Ok(())\n        }\n    }\n\n    fn handle_prev(&mut self) -> Result<(), Error> {\n        // Previous behaves differently based on the position\n        // Under 3s it goes to the previous song (starts playing)\n        // Over 3s it seeks to zero (retains previous play status)\n        if self.position() < 3000 {\n            let repeat_context = self.connect_state.repeat_context();\n            match self.connect_state.prev_track()? {\n                None if repeat_context => self.connect_state.reset_playback_to_position(None)?,\n                None => {\n                    self.connect_state.reset_playback_to_position(None)?;\n                    self.handle_stop()\n                }\n                Some(_) => self.load_track(self.connect_state.is_playing(), 0)?,\n            }\n        } else {\n            self.handle_seek(0);\n        }\n\n        Ok(())\n    }\n\n    fn handle_volume_up(&mut self) {\n        let volume = (self.connect_state.device_info().volume as u16)\n            .saturating_add(self.connect_state.volume_step_size);\n\n        self.set_volume(volume);\n    }\n\n    fn handle_volume_down(&mut self) {\n        let volume = (self.connect_state.device_info().volume as u16)\n            .saturating_sub(self.connect_state.volume_step_size);\n\n        self.set_volume(volume);\n    }\n\n    fn handle_playlist_modification(\n        &mut self,\n        playlist_modification_info: PlaylistModificationInfo,\n    ) -> Result<(), Error> {\n        let uri = playlist_modification_info\n            .uri\n            .ok_or(SpircError::NoUri(\"playlist modification\"))?;\n        let uri = String::from_utf8(uri)?;\n\n        if self.connect_state.context_uri() != &uri {\n            debug!(\n                \"ignoring playlist modification update for playlist <{uri}>, because it isn't the current context\"\n            );\n            return Ok(());\n        }\n\n        debug!(\"playlist modification for current context: {uri}\");\n        self.context_resolver.add(ResolveContext::from_uri(\n            uri,\n            self.connect_state.current_track(|t| &t.uri),\n            ContextType::Default,\n            ContextAction::Replace,\n        ));\n\n        Ok(())\n    }\n\n    fn handle_session_update(&mut self, session_update: FallbackWrapper<SessionUpdate>) {\n        // we know that this enum value isn't present in our current proto definitions, by that\n        // the json parsing fails because the enum isn't known as proto representation\n        const WBC: &str = \"WIFI_BROADCAST_CHANGED\";\n\n        let mut session_update = match session_update {\n            FallbackWrapper::Inner(update) => update,\n            FallbackWrapper::Fallback(value) => {\n                let fallback_inner = value.to_string();\n                if fallback_inner.contains(WBC) {\n                    log::debug!(\"Received SessionUpdate::{WBC}\");\n                } else {\n                    log::warn!(\"SessionUpdate couldn't be parse correctly: {value:?}\");\n                }\n                return;\n            }\n        };\n\n        let reason = session_update.reason.enum_value();\n\n        let mut session = match session_update.session.take() {\n            None => return,\n            Some(session) => session,\n        };\n\n        let active_device = session.host_active_device_id.take();\n        if matches!(active_device, Some(ref device) if device == self.session.device_id()) {\n            info!(\n                \"session update: <{:?}> for self, current session_id {}, new session_id {}\",\n                reason,\n                self.session.session_id(),\n                session.session_id\n            );\n\n            if self.session.session_id() != session.session_id {\n                self.session.set_session_id(&session.session_id);\n                self.connect_state.set_session_id(session.session_id);\n            }\n        } else {\n            debug!(\"session update: <{reason:?}> from active session host: <{active_device:?}>\");\n        }\n\n        // this seems to be used for jams or handling the current session_id\n        //\n        // handling this event was intended to keep the playback when other clients (primarily\n        // mobile) connects, otherwise they would steel the current playback when there was no\n        // session_id provided on the initial PutStateReason::NEW_DEVICE state update\n        //\n        // by generating an initial session_id from the get-go we prevent that behavior and\n        // currently don't need to handle this event, might still be useful for later \"jam\" support\n    }\n\n    fn position(&mut self) -> u32 {\n        match self.play_status {\n            SpircPlayStatus::Stopped => 0,\n            SpircPlayStatus::LoadingPlay { position_ms }\n            | SpircPlayStatus::LoadingPause { position_ms }\n            | SpircPlayStatus::Paused { position_ms, .. } => position_ms,\n            SpircPlayStatus::Playing {\n                nominal_start_time, ..\n            } => (self.now_ms() - nominal_start_time) as u32,\n        }\n    }\n\n    fn load_track(&mut self, start_playing: bool, position_ms: u32) -> Result<(), Error> {\n        if self.connect_state.current_track(MessageField::is_none) {\n            debug!(\"current track is none, stopping playback\");\n            self.handle_stop();\n            return Ok(());\n        }\n\n        let current_uri = self.connect_state.current_track(|t| &t.uri);\n        let id = SpotifyUri::from_uri(current_uri)?;\n        self.player.load(id, start_playing, position_ms);\n\n        self.connect_state\n            .update_position(position_ms, self.now_ms());\n        if start_playing {\n            self.play_status = SpircPlayStatus::LoadingPlay { position_ms };\n        } else {\n            self.play_status = SpircPlayStatus::LoadingPause { position_ms };\n        }\n        self.connect_state.set_status(&self.play_status);\n\n        Ok(())\n    }\n\n    async fn notify(&mut self) -> Result<(), Error> {\n        self.connect_state.set_status(&self.play_status);\n\n        if self.connect_state.is_playing() {\n            self.connect_state\n                .update_position_in_relation(self.now_ms());\n        }\n\n        self.connect_state.set_now(self.now_ms() as u64);\n\n        self.connect_state\n            .send_state(&self.session)\n            .await\n            .map(|_| ())\n    }\n\n    fn set_volume(&mut self, volume: u16) {\n        debug!(\"SpircTask::set_volume({volume})\");\n\n        let old_volume = self.connect_state.device_info().volume;\n        let new_volume = volume as u32;\n        if old_volume != new_volume || self.mixer.volume() != volume {\n            self.update_volume = true;\n\n            self.connect_state.set_volume(new_volume);\n            self.mixer.set_volume(volume);\n            if let Some(cache) = self.session.cache() {\n                cache.save_volume(volume)\n            }\n            if self.connect_state.is_active() {\n                self.player.emit_volume_changed_event(volume);\n            }\n        }\n    }\n}\n\nimpl Drop for SpircTask {\n    fn drop(&mut self) {\n        debug!(\"drop Spirc[{}]\", self.spirc_id);\n    }\n}\n"
  },
  {
    "path": "connect/src/state/context.rs",
    "content": "use crate::{\n    core::{Error, SpotifyId, SpotifyUri},\n    protocol::{\n        context::Context,\n        context_page::ContextPage,\n        context_track::ContextTrack,\n        player::{ContextIndex, ProvidedTrack},\n        restrictions::Restrictions,\n    },\n    shuffle_vec::ShuffleVec,\n    state::{\n        ConnectState, SPOTIFY_MAX_NEXT_TRACKS_SIZE, StateError,\n        metadata::Metadata,\n        provider::{IsProvider, Provider},\n    },\n};\nuse protobuf::MessageField;\nuse std::collections::HashMap;\nuse uuid::Uuid;\n\nconst LOCAL_FILES_IDENTIFIER: &str = \"spotify:local-files\";\nconst SEARCH_IDENTIFIER: &str = \"spotify:search\";\n\n#[derive(Debug)]\npub struct StateContext {\n    pub tracks: ShuffleVec<ProvidedTrack>,\n    pub metadata: HashMap<String, String>,\n    pub restrictions: Option<Restrictions>,\n    /// is used to keep track which tracks are already loaded into the next_tracks\n    pub index: ContextIndex,\n}\n\n#[derive(Default, Debug, Copy, Clone, PartialEq, Hash, Eq)]\npub enum ContextType {\n    #[default]\n    Default,\n    Autoplay,\n}\n\npub enum ResetContext<'s> {\n    Completely,\n    DefaultIndex,\n    WhenDifferent(&'s str),\n}\n\n/// Extracts the spotify uri from a given page_url\n///\n/// Just extracts \"spotify/album/5LFzwirfFwBKXJQGfwmiMY\" and replaces the slash's with colon's\n///\n/// Expected `page_url` should look something like the following:\n/// `hm://artistplaycontext/v1/page/spotify/album/5LFzwirfFwBKXJQGfwmiMY/km_artist`\nfn page_url_to_uri(page_url: &str) -> String {\n    let split = if let Some(rest) = page_url.strip_prefix(\"hm://\") {\n        rest.split('/')\n    } else {\n        warn!(\"page_url didn't start with hm://. got page_url: {page_url}\");\n        page_url.split('/')\n    };\n\n    split\n        .skip_while(|s| s != &\"spotify\")\n        .take(3)\n        .collect::<Vec<&str>>()\n        .join(\":\")\n}\n\nimpl ConnectState {\n    pub fn find_index_in_context<F: Fn(&ProvidedTrack) -> bool>(\n        ctx: &StateContext,\n        f: F,\n    ) -> Result<usize, StateError> {\n        ctx.tracks\n            .iter()\n            .position(f)\n            .ok_or(StateError::CanNotFindTrackInContext(None, ctx.tracks.len()))\n    }\n\n    pub fn get_context(&self, ty: ContextType) -> Result<&StateContext, StateError> {\n        match ty {\n            ContextType::Default => self.context.as_ref(),\n            ContextType::Autoplay => self.autoplay_context.as_ref(),\n        }\n        .ok_or(StateError::NoContext(ty))\n    }\n\n    pub fn get_context_mut(&mut self, ty: ContextType) -> Result<&mut StateContext, StateError> {\n        match ty {\n            ContextType::Default => self.context.as_mut(),\n            ContextType::Autoplay => self.autoplay_context.as_mut(),\n        }\n        .ok_or(StateError::NoContext(ty))\n    }\n\n    pub fn context_uri(&self) -> &String {\n        &self.player().context_uri\n    }\n\n    fn different_context_uri(&self, uri: &str) -> bool {\n        // search identifier is always different\n        self.context_uri() != uri || uri.starts_with(SEARCH_IDENTIFIER)\n    }\n\n    pub fn reset_context(&mut self, mut reset_as: ResetContext) {\n        if matches!(reset_as, ResetContext::WhenDifferent(ctx) if self.different_context_uri(ctx)) {\n            reset_as = ResetContext::Completely\n        }\n\n        if let Ok(ctx) = self.get_context_mut(ContextType::Default) {\n            ctx.remove_shuffle_seed();\n            ctx.remove_initial_track();\n            ctx.tracks.unshuffle()\n        }\n\n        match reset_as {\n            ResetContext::WhenDifferent(_) => debug!(\"context didn't change, no reset\"),\n            ResetContext::Completely => {\n                self.context = None;\n                self.autoplay_context = None;\n\n                let player = self.player_mut();\n                player.context_uri.clear();\n                player.context_url.clear();\n            }\n            ResetContext::DefaultIndex => {\n                for ctx in [self.context.as_mut(), self.autoplay_context.as_mut()]\n                    .into_iter()\n                    .flatten()\n                {\n                    ctx.index.track = 0;\n                    ctx.index.page = 0;\n                }\n            }\n        }\n\n        self.fill_up_context = ContextType::Default;\n        self.set_active_context(ContextType::Default);\n        self.update_restrictions()\n    }\n\n    pub fn valid_resolve_uri(uri: &str) -> Option<&str> {\n        if uri.is_empty() || uri.starts_with(SEARCH_IDENTIFIER) {\n            None\n        } else {\n            Some(uri)\n        }\n    }\n\n    pub fn find_valid_uri<'s>(\n        context_uri: Option<&'s str>,\n        first_page: Option<&'s ContextPage>,\n    ) -> Option<&'s str> {\n        context_uri\n            .and_then(Self::valid_resolve_uri)\n            .or_else(|| first_page.and_then(|p| p.tracks.first().and_then(|t| t.uri.as_deref())))\n    }\n\n    pub fn set_active_context(&mut self, new_context: ContextType) {\n        self.active_context = new_context;\n\n        let player = self.player_mut();\n\n        player.context_metadata = Default::default();\n        player.context_restrictions = MessageField::some(Default::default());\n        player.restrictions = MessageField::some(Default::default());\n\n        let ctx = match self.get_context(new_context) {\n            Err(why) => {\n                warn!(\"couldn't load context info because: {why}\");\n                return;\n            }\n            Ok(ctx) => ctx,\n        };\n\n        let mut restrictions = ctx.restrictions.clone();\n        let metadata = ctx.metadata.clone();\n\n        let player = self.player_mut();\n\n        if let Some(restrictions) = restrictions.take() {\n            player.restrictions = MessageField::some(restrictions.into());\n        }\n\n        for (key, value) in metadata {\n            player.context_metadata.insert(key, value);\n        }\n    }\n\n    pub fn update_context(\n        &mut self,\n        mut context: Context,\n        ty: ContextType,\n    ) -> Result<Option<Vec<String>>, Error> {\n        if context.pages.iter().all(|p| p.tracks.is_empty()) {\n            error!(\"context didn't have any tracks: {context:#?}\");\n            Err(StateError::ContextHasNoTracks)?;\n        } else if matches!(context.uri, Some(ref uri) if uri.starts_with(LOCAL_FILES_IDENTIFIER)) {\n            Err(StateError::UnsupportedLocalPlayback)?;\n        }\n\n        let mut next_contexts = Vec::new();\n        let mut first_page = None;\n        for page in context.pages {\n            if first_page.is_none() && !page.tracks.is_empty() {\n                first_page = Some(page);\n            } else {\n                next_contexts.push(page)\n            }\n        }\n\n        let page = match first_page {\n            None => Err(StateError::ContextHasNoTracks)?,\n            Some(p) => p,\n        };\n\n        debug!(\n            \"updated context {ty:?} to <{:?}> ({} tracks)\",\n            context.uri,\n            page.tracks.len()\n        );\n\n        match ty {\n            ContextType::Default => {\n                let mut new_context = self.state_context_from_page(\n                    page,\n                    context.metadata,\n                    context.restrictions.take(),\n                    context.uri.as_deref(),\n                    Some(0),\n                    None,\n                );\n\n                // when we update the same context, we should try to preserve the previous position\n                // otherwise we might load the entire context twice, unless it's the search context\n                if !self.context_uri().starts_with(SEARCH_IDENTIFIER)\n                    && matches!(context.uri, Some(ref uri) if uri == self.context_uri())\n                {\n                    if let Some(new_index) = self.find_last_index_in_new_context(&new_context) {\n                        new_context.index.track = match new_index {\n                            Ok(i) => i,\n                            Err(i) => {\n                                self.player_mut().index = MessageField::none();\n                                i\n                            }\n                        };\n\n                        // enforce reloading the context\n                        if let Ok(autoplay_ctx) = self.get_context_mut(ContextType::Autoplay) {\n                            autoplay_ctx.index.track = 0\n                        }\n                        self.clear_next_tracks();\n                    }\n                }\n\n                self.context = Some(new_context);\n\n                if !matches!(context.url, Some(ref url) if url.contains(SEARCH_IDENTIFIER)) {\n                    self.player_mut().context_url = context.url.take().unwrap_or_default();\n                } else {\n                    self.player_mut().context_url.clear()\n                }\n                self.player_mut().context_uri = context.uri.take().unwrap_or_default();\n            }\n            ContextType::Autoplay => {\n                self.autoplay_context = Some(self.state_context_from_page(\n                    page,\n                    context.metadata,\n                    context.restrictions.take(),\n                    context.uri.as_deref(),\n                    None,\n                    Some(Provider::Autoplay),\n                ))\n            }\n        }\n\n        if next_contexts.is_empty() {\n            return Ok(None);\n        }\n\n        // load remaining contexts\n        let next_contexts = next_contexts\n            .into_iter()\n            .flat_map(|page| {\n                if !page.tracks.is_empty() {\n                    self.fill_context_from_page(page).ok()?;\n                    None\n                } else if matches!(page.page_url, Some(ref url) if !url.is_empty()) {\n                    Some(page_url_to_uri(\n                        &page.page_url.expect(\"checked by precondition\"),\n                    ))\n                } else {\n                    warn!(\"unhandled context page: {page:#?}\");\n                    None\n                }\n            })\n            .collect();\n\n        Ok(Some(next_contexts))\n    }\n\n    fn find_first_prev_track_index(&self, ctx: &StateContext) -> Option<usize> {\n        let prev_tracks = self.prev_tracks();\n        for i in (0..prev_tracks.len()).rev() {\n            let prev_track = prev_tracks.get(i)?;\n            if let Ok(idx) = Self::find_index_in_context(ctx, |t| prev_track.uri == t.uri) {\n                return Some(idx);\n            }\n        }\n        None\n    }\n\n    fn find_last_index_in_new_context(\n        &self,\n        new_context: &StateContext,\n    ) -> Option<Result<u32, u32>> {\n        let ctx = self.context.as_ref()?;\n\n        let is_queued_item = self.current_track(|t| t.is_queue() || t.is_from_queue());\n\n        let new_index = if ctx.index.track as usize >= SPOTIFY_MAX_NEXT_TRACKS_SIZE {\n            Some(ctx.index.track as usize - SPOTIFY_MAX_NEXT_TRACKS_SIZE)\n        } else if is_queued_item {\n            self.find_first_prev_track_index(new_context)\n        } else {\n            Self::find_index_in_context(new_context, |current| {\n                self.current_track(|t| t.uri == current.uri)\n            })\n            .ok()\n        }\n        .map(|i| i as u32 + 1);\n\n        Some(new_index.ok_or_else(|| {\n            info!(\n                \"couldn't distinguish index from current or previous tracks in the updated context\"\n            );\n            let fallback_index = self\n                .player()\n                .index\n                .as_ref()\n                .map(|i| i.track)\n                .unwrap_or_default();\n            info!(\"falling back to index {fallback_index}\");\n            fallback_index\n        }))\n    }\n\n    fn state_context_from_page(\n        &mut self,\n        page: ContextPage,\n        metadata: HashMap<String, String>,\n        restrictions: Option<Restrictions>,\n        new_context_uri: Option<&str>,\n        context_length: Option<usize>,\n        provider: Option<Provider>,\n    ) -> StateContext {\n        let new_context_uri = new_context_uri.unwrap_or(self.context_uri());\n\n        let tracks = page\n            .tracks\n            .iter()\n            .enumerate()\n            .flat_map(|(i, track)| {\n                match self.context_to_provided_track(\n                    track,\n                    Some(new_context_uri),\n                    context_length.map(|l| l + i),\n                    Some(&page.metadata),\n                    provider.clone(),\n                ) {\n                    Ok(t) => Some(t),\n                    Err(why) => {\n                        error!(\"couldn't convert {track:#?} into ProvidedTrack: {why}\");\n                        None\n                    }\n                }\n            })\n            .collect::<Vec<_>>();\n\n        StateContext {\n            tracks: tracks.into(),\n            restrictions,\n            metadata,\n            index: ContextIndex::new(),\n        }\n    }\n\n    pub fn is_skip_track(&self, track: &ProvidedTrack, iteration: Option<u32>) -> bool {\n        let ctx = match self.get_context(self.active_context).ok() {\n            None => return false,\n            Some(ctx) => ctx,\n        };\n\n        if ctx.get_initial_track().is_none_or(|uri| uri != &track.uri) {\n            return false;\n        }\n\n        iteration.is_none_or(|i| i == 0)\n    }\n\n    pub fn merge_context(&mut self, new_page: Option<ContextPage>) -> Option<()> {\n        let current_context = self.get_context_mut(ContextType::Default).ok()?;\n\n        for new_track in new_page?.tracks {\n            if new_track.uri.is_none() || matches!(new_track.uri, Some(ref uri) if uri.is_empty()) {\n                continue;\n            }\n\n            let new_track_uri = new_track.uri.unwrap_or_default();\n            if let Ok(position) =\n                Self::find_index_in_context(current_context, |t| t.uri == new_track_uri)\n            {\n                let context_track = current_context.tracks.get_mut(position)?;\n\n                for (key, value) in new_track.metadata {\n                    context_track.metadata.insert(key, value);\n                }\n\n                // the uid provided from another context might be actual uid of an item\n                if new_track.uid.is_some()\n                    || matches!(new_track.uid, Some(ref uid) if uid.is_empty())\n                {\n                    context_track.uid = new_track.uid.unwrap_or_default();\n                }\n            }\n        }\n\n        Some(())\n    }\n\n    pub(super) fn update_context_index(\n        &mut self,\n        ty: ContextType,\n        new_index: usize,\n    ) -> Result<(), StateError> {\n        let context = self.get_context_mut(ty)?;\n\n        context.index.track = new_index as u32;\n        Ok(())\n    }\n\n    pub fn context_to_provided_track(\n        &self,\n        ctx_track: &ContextTrack,\n        context_uri: Option<&str>,\n        context_index: Option<usize>,\n        page_metadata: Option<&HashMap<String, String>>,\n        provider: Option<Provider>,\n    ) -> Result<ProvidedTrack, Error> {\n        let id = match (ctx_track.uri.as_ref(), ctx_track.gid.as_ref()) {\n            (Some(uri), _) if uri.contains(['?']) => {\n                Err(StateError::InvalidTrackUri(Some(uri.clone())))?\n            }\n            (Some(uri), _) if !uri.is_empty() => SpotifyUri::from_uri(uri)?,\n            (_, Some(gid)) if !gid.is_empty() => SpotifyUri::Track {\n                id: SpotifyId::from_raw(gid)?,\n            },\n            _ => Err(StateError::InvalidTrackUri(None))?,\n        };\n\n        let uri = id.to_uri().replace(\"unknown\", \"track\");\n\n        let provider = if self.unavailable_uri.contains(&uri) {\n            Provider::Unavailable\n        } else {\n            provider.unwrap_or(Provider::Context)\n        };\n\n        // assumption: the uid is used as unique-id of any item\n        //  - queue resorting is done by each client and orients itself by the given uid\n        //  - if no uid is present, resorting doesn't work or behaves not as intended\n        let uid = match ctx_track.uid.as_ref() {\n            Some(uid) if !uid.is_empty() => uid.to_string(),\n            // so providing a unique id should allow to resort the queue\n            _ => Uuid::new_v4().as_simple().to_string(),\n        };\n\n        let mut metadata = page_metadata.cloned().unwrap_or_default();\n        for (k, v) in &ctx_track.metadata {\n            metadata.insert(k.to_string(), v.to_string());\n        }\n\n        let mut track = ProvidedTrack {\n            uri,\n            uid,\n            metadata,\n            provider: provider.to_string(),\n            ..Default::default()\n        };\n\n        if let Some(context_uri) = context_uri {\n            track.set_entity_uri(context_uri);\n            track.set_context_uri(context_uri);\n        }\n\n        if let Some(index) = context_index {\n            track.set_context_index(index);\n        }\n\n        if matches!(provider, Provider::Autoplay) {\n            track.set_from_autoplay(true)\n        }\n\n        Ok(track)\n    }\n\n    pub fn fill_context_from_page(&mut self, page: ContextPage) -> Result<(), Error> {\n        let ctx_len = self.context.as_ref().map(|c| c.tracks.len());\n        let context = self.state_context_from_page(page, HashMap::new(), None, None, ctx_len, None);\n\n        let ctx = self\n            .context\n            .as_mut()\n            .ok_or(StateError::NoContext(ContextType::Default))?;\n\n        for t in context.tracks {\n            ctx.tracks.push(t)\n        }\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "connect/src/state/handle.rs",
    "content": "use crate::{\n    core::{Error, dealer::protocol::SetQueueCommand},\n    state::{\n        ConnectState,\n        context::{ContextType, ResetContext},\n        metadata::Metadata,\n    },\n};\nuse protobuf::MessageField;\n\nimpl ConnectState {\n    pub fn handle_shuffle(&mut self, shuffle: bool) -> Result<(), Error> {\n        self.set_shuffle(shuffle);\n\n        if shuffle {\n            return self.shuffle_new();\n        }\n\n        self.reset_context(ResetContext::DefaultIndex);\n\n        if self.current_track(MessageField::is_none) {\n            return Ok(());\n        }\n\n        match self.current_track(|t| t.get_context_index()) {\n            Some(current_index) => self.reset_playback_to_position(Some(current_index)),\n            None => {\n                let ctx = self.get_context(ContextType::Default)?;\n                let current_index = ConnectState::find_index_in_context(ctx, |c| {\n                    self.current_track(|t| c.uri == t.uri)\n                })?;\n                self.reset_playback_to_position(Some(current_index))\n            }\n        }\n    }\n\n    pub fn handle_set_queue(&mut self, set_queue: SetQueueCommand) {\n        self.set_next_tracks(set_queue.next_tracks);\n        self.set_prev_tracks(set_queue.prev_tracks);\n        self.update_queue_revision();\n    }\n\n    pub fn handle_set_repeat_context(&mut self, repeat: bool) -> Result<(), Error> {\n        self.set_repeat_context(repeat);\n\n        if repeat {\n            if let ContextType::Autoplay = self.fill_up_context {\n                self.fill_up_context = ContextType::Default;\n            }\n        }\n\n        let ctx = self.get_context(ContextType::Default)?;\n        let current_track =\n            ConnectState::find_index_in_context(ctx, |t| self.current_track(|t| &t.uri) == &t.uri)?;\n        self.reset_playback_to_position(Some(current_track))\n    }\n}\n"
  },
  {
    "path": "connect/src/state/metadata.rs",
    "content": "use crate::{\n    protocol::{context::Context, context_track::ContextTrack, player::ProvidedTrack},\n    state::context::StateContext,\n};\nuse std::collections::HashMap;\nuse std::fmt::Display;\n\nconst CONTEXT_URI: &str = \"context_uri\";\nconst ENTITY_URI: &str = \"entity_uri\";\nconst IS_QUEUED: &str = \"is_queued\";\nconst IS_AUTOPLAY: &str = \"autoplay.is_autoplay\";\nconst HIDDEN: &str = \"hidden\";\nconst ITERATION: &str = \"iteration\";\n\nconst CUSTOM_CONTEXT_INDEX: &str = \"context_index\";\nconst CUSTOM_SHUFFLE_SEED: &str = \"shuffle_seed\";\nconst CUSTOM_INITIAL_TRACK: &str = \"initial_track\";\n\nmacro_rules! metadata_entry {\n    ( $get:ident, $set:ident, $clear:ident ($key:ident: $entry:ident)) => {\n        metadata_entry!( $get use get, $set, $clear ($key: $entry) -> Option<&String> );\n    };\n    ( $get_key:ident use $get:ident, $set:ident, $clear:ident ($key:ident: $entry:ident) -> $ty:ty ) => {\n        fn $get_key (&self) -> $ty {\n            self.$get($entry)\n        }\n\n\n        fn $set (&mut self, $key: impl Display) {\n            self.metadata_mut().insert($entry.to_string(), $key.to_string());\n        }\n\n        fn $clear(&mut self) {\n            self.metadata_mut().remove($entry);\n        }\n    };\n}\n\n/// Allows easy access of known metadata fields\n#[allow(dead_code)]\npub(super) trait Metadata {\n    fn metadata(&self) -> &HashMap<String, String>;\n\n    fn metadata_mut(&mut self) -> &mut HashMap<String, String>;\n\n    fn get_bool(&self, entry: &str) -> bool {\n        matches!(self.metadata().get(entry), Some(entry) if entry.eq(\"true\"))\n    }\n\n    fn get_usize(&self, entry: &str) -> Option<usize> {\n        self.metadata().get(entry)?.parse().ok()\n    }\n\n    fn get(&self, entry: &str) -> Option<&String> {\n        self.metadata().get(entry)\n    }\n\n    metadata_entry!(is_from_queue use get_bool, set_from_queue, remove_from_queue (is_queued: IS_QUEUED) -> bool);\n    metadata_entry!(is_from_autoplay use get_bool, set_from_autoplay, remove_from_autoplay (is_autoplay: IS_AUTOPLAY) -> bool);\n    metadata_entry!(is_hidden use get_bool, set_hidden, remove_hidden (is_hidden: HIDDEN) -> bool);\n\n    metadata_entry!(get_context_index use get_usize, set_context_index, remove_context_index (context_index: CUSTOM_CONTEXT_INDEX) -> Option<usize>);\n    metadata_entry!(get_context_uri, set_context_uri, remove_context_uri (context_uri: CONTEXT_URI));\n    metadata_entry!(get_entity_uri, set_entity_uri, remove_entity_uri (entity_uri: ENTITY_URI));\n    metadata_entry!(get_iteration, set_iteration, remove_iteration (iteration: ITERATION));\n    metadata_entry!(get_shuffle_seed, set_shuffle_seed, remove_shuffle_seed (shuffle_seed: CUSTOM_SHUFFLE_SEED));\n    metadata_entry!(get_initial_track, set_initial_track, remove_initial_track (initial_track: CUSTOM_INITIAL_TRACK));\n}\n\nmacro_rules! impl_metadata {\n    ($impl_for:ident) => {\n        impl Metadata for $impl_for {\n            fn metadata(&self) -> &HashMap<String, String> {\n                &self.metadata\n            }\n\n            fn metadata_mut(&mut self) -> &mut HashMap<String, String> {\n                &mut self.metadata\n            }\n        }\n    };\n}\n\nimpl_metadata!(ContextTrack);\nimpl_metadata!(ProvidedTrack);\nimpl_metadata!(Context);\nimpl_metadata!(StateContext);\n"
  },
  {
    "path": "connect/src/state/options.rs",
    "content": "use crate::{\n    core::Error,\n    protocol::player::ContextPlayerOptions,\n    state::{\n        ConnectState, StateError,\n        context::{ContextType, ResetContext},\n        metadata::Metadata,\n    },\n};\nuse protobuf::MessageField;\nuse rand::Rng;\n\n#[derive(Default, Debug)]\npub(crate) struct ShuffleState {\n    pub seed: u64,\n    pub initial_track: String,\n}\n\nimpl ConnectState {\n    fn add_options_if_empty(&mut self) {\n        if self.player().options.is_none() {\n            self.player_mut().options = MessageField::some(ContextPlayerOptions::new())\n        }\n    }\n\n    pub fn set_repeat_context(&mut self, repeat: bool) {\n        self.add_options_if_empty();\n        if let Some(options) = self.player_mut().options.as_mut() {\n            options.repeating_context = repeat;\n        }\n    }\n\n    pub fn set_repeat_track(&mut self, repeat: bool) {\n        self.add_options_if_empty();\n        if let Some(options) = self.player_mut().options.as_mut() {\n            options.repeating_track = repeat;\n        }\n    }\n\n    pub fn set_shuffle(&mut self, shuffle: bool) {\n        self.add_options_if_empty();\n        if let Some(options) = self.player_mut().options.as_mut() {\n            options.shuffling_context = shuffle;\n        }\n    }\n\n    pub fn reset_options(&mut self) {\n        self.set_shuffle(false);\n        self.set_repeat_track(false);\n        self.set_repeat_context(false);\n    }\n\n    fn validate_shuffle_allowed(&self) -> Result<(), Error> {\n        if let Some(reason) = self\n            .player()\n            .restrictions\n            .disallow_toggling_shuffle_reasons\n            .first()\n        {\n            Err(StateError::CurrentlyDisallowed {\n                action: \"shuffle\",\n                reason: reason.clone(),\n            })?\n        } else {\n            Ok(())\n        }\n    }\n\n    pub fn shuffle_restore(&mut self, shuffle_state: ShuffleState) -> Result<(), Error> {\n        self.validate_shuffle_allowed()?;\n\n        self.shuffle(shuffle_state.seed, &shuffle_state.initial_track)\n    }\n\n    pub fn shuffle_new(&mut self) -> Result<(), Error> {\n        self.validate_shuffle_allowed()?;\n\n        let new_seed = rand::rng().random_range(100_000_000_000..1_000_000_000_000);\n        let current_track = self.current_track(|t| t.uri.clone());\n\n        self.shuffle(new_seed, &current_track)\n    }\n\n    fn shuffle(&mut self, seed: u64, initial_track: &str) -> Result<(), Error> {\n        self.clear_prev_track();\n        self.clear_next_tracks();\n\n        self.reset_context(ResetContext::DefaultIndex);\n\n        let ctx = self.get_context_mut(ContextType::Default)?;\n        ctx.tracks\n            .shuffle_with_seed(seed, |f| f.uri == initial_track);\n\n        ctx.set_initial_track(initial_track);\n        ctx.set_shuffle_seed(seed);\n\n        self.fill_up_next_tracks()?;\n\n        Ok(())\n    }\n\n    pub fn shuffling_context(&self) -> bool {\n        self.player().options.shuffling_context\n    }\n\n    pub fn repeat_context(&self) -> bool {\n        self.player().options.repeating_context\n    }\n\n    pub fn repeat_track(&self) -> bool {\n        self.player().options.repeating_track\n    }\n}\n"
  },
  {
    "path": "connect/src/state/provider.rs",
    "content": "use librespot_protocol::player::ProvidedTrack;\nuse std::fmt::{Display, Formatter};\n\n// providers used by spotify\nconst PROVIDER_CONTEXT: &str = \"context\";\nconst PROVIDER_QUEUE: &str = \"queue\";\nconst PROVIDER_AUTOPLAY: &str = \"autoplay\";\n\n// custom providers, used to identify certain states that we can't handle preemptively, yet\n/// it seems like spotify just knows that the track isn't available, currently we don't have an\n/// option to do the same, so we stay with the old solution for now\nconst PROVIDER_UNAVAILABLE: &str = \"unavailable\";\n\n#[derive(Debug, Clone)]\npub enum Provider {\n    Context,\n    Queue,\n    Autoplay,\n    Unavailable,\n}\n\nimpl Display for Provider {\n    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {\n        write!(\n            f,\n            \"{}\",\n            match self {\n                Provider::Context => PROVIDER_CONTEXT,\n                Provider::Queue => PROVIDER_QUEUE,\n                Provider::Autoplay => PROVIDER_AUTOPLAY,\n                Provider::Unavailable => PROVIDER_UNAVAILABLE,\n            }\n        )\n    }\n}\n\npub trait IsProvider {\n    fn is_autoplay(&self) -> bool;\n    fn is_context(&self) -> bool;\n    fn is_queue(&self) -> bool;\n    fn is_unavailable(&self) -> bool;\n\n    fn set_provider(&mut self, provider: Provider);\n}\n\nimpl IsProvider for ProvidedTrack {\n    fn is_autoplay(&self) -> bool {\n        self.provider == PROVIDER_AUTOPLAY\n    }\n\n    fn is_context(&self) -> bool {\n        self.provider == PROVIDER_CONTEXT\n    }\n\n    fn is_queue(&self) -> bool {\n        self.provider == PROVIDER_QUEUE\n    }\n\n    fn is_unavailable(&self) -> bool {\n        self.provider == PROVIDER_UNAVAILABLE\n    }\n\n    fn set_provider(&mut self, provider: Provider) {\n        self.provider = provider.to_string()\n    }\n}\n"
  },
  {
    "path": "connect/src/state/restrictions.rs",
    "content": "use crate::state::ConnectState;\nuse crate::state::provider::IsProvider;\nuse librespot_protocol::player::Restrictions;\nuse protobuf::MessageField;\n\nimpl ConnectState {\n    pub fn clear_restrictions(&mut self) {\n        let player = self.player_mut();\n\n        player.context_restrictions = Some(Default::default()).into();\n        player.restrictions = Some(Default::default()).into();\n    }\n\n    pub fn update_restrictions(&mut self) {\n        const NO_PREV: &str = \"no previous tracks\";\n        const AUTOPLAY: &str = \"autoplay\";\n\n        let prev_tracks_is_empty = self.prev_tracks().is_empty();\n\n        let is_paused = self.is_pause();\n        let is_playing = self.is_playing();\n\n        let player = self.player_mut();\n        if let Some(restrictions) = player.restrictions.as_mut() {\n            if is_playing {\n                restrictions.disallow_pausing_reasons.clear();\n                restrictions.disallow_resuming_reasons = vec![\"not_paused\".to_string()]\n            }\n\n            if is_paused {\n                restrictions.disallow_resuming_reasons.clear();\n                restrictions.disallow_pausing_reasons = vec![\"not_playing\".to_string()]\n            }\n        }\n\n        if player.restrictions.is_none() {\n            player.restrictions = MessageField::some(Restrictions::new())\n        }\n\n        if let Some(restrictions) = player.restrictions.as_mut() {\n            if prev_tracks_is_empty {\n                restrictions.disallow_peeking_prev_reasons = vec![NO_PREV.to_string()];\n                restrictions.disallow_skipping_prev_reasons = vec![NO_PREV.to_string()];\n            } else {\n                restrictions.disallow_peeking_prev_reasons.clear();\n                restrictions.disallow_skipping_prev_reasons.clear();\n            }\n\n            if player.track.is_autoplay() {\n                restrictions.disallow_toggling_shuffle_reasons = vec![AUTOPLAY.to_string()];\n                restrictions.disallow_toggling_repeat_context_reasons = vec![AUTOPLAY.to_string()];\n                restrictions.disallow_toggling_repeat_track_reasons = vec![AUTOPLAY.to_string()];\n            } else {\n                restrictions.disallow_toggling_shuffle_reasons.clear();\n                restrictions\n                    .disallow_toggling_repeat_context_reasons\n                    .clear();\n                restrictions.disallow_toggling_repeat_track_reasons.clear();\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "connect/src/state/tracks.rs",
    "content": "use crate::{\n    core::{Error, SpotifyUri},\n    protocol::player::ProvidedTrack,\n    state::{\n        ConnectState, SPOTIFY_MAX_NEXT_TRACKS_SIZE, SPOTIFY_MAX_PREV_TRACKS_SIZE, StateError,\n        context::ContextType,\n        metadata::Metadata,\n        provider::{IsProvider, Provider},\n    },\n};\nuse protobuf::MessageField;\nuse rand::Rng;\n\n// identifier used as part of the uid\npub const IDENTIFIER_DELIMITER: &str = \"delimiter\";\n\nimpl<'ct> ConnectState {\n    fn new_delimiter(iteration: i64) -> ProvidedTrack {\n        let mut delimiter = ProvidedTrack {\n            uri: format!(\"spotify:{IDENTIFIER_DELIMITER}\"),\n            uid: format!(\"{IDENTIFIER_DELIMITER}{iteration}\"),\n            provider: Provider::Context.to_string(),\n            ..Default::default()\n        };\n        delimiter.set_hidden(true);\n        delimiter.set_iteration(iteration);\n\n        delimiter\n    }\n\n    fn push_prev(&mut self, prev: ProvidedTrack) {\n        let prev_tracks = self.prev_tracks_mut();\n        // add prev track, while preserving a length of 10\n        if prev_tracks.len() >= SPOTIFY_MAX_PREV_TRACKS_SIZE {\n            // todo: O(n), but technically only maximal O(SPOTIFY_MAX_PREV_TRACKS_SIZE) aka O(10)\n            let _ = prev_tracks.remove(0);\n        }\n        prev_tracks.push(prev)\n    }\n\n    fn get_next_track(&mut self) -> Option<ProvidedTrack> {\n        if self.next_tracks().is_empty() {\n            None\n        } else {\n            // todo: O(n), but technically only maximal O(SPOTIFY_MAX_NEXT_TRACKS_SIZE) aka O(80)\n            Some(self.next_tracks_mut().remove(0))\n        }\n    }\n\n    /// bottom => top, aka the last track of the list is the prev track\n    fn prev_tracks_mut(&mut self) -> &mut Vec<ProvidedTrack> {\n        &mut self.player_mut().prev_tracks\n    }\n\n    /// bottom => top, aka the last track of the list is the prev track\n    pub(super) fn prev_tracks(&self) -> &Vec<ProvidedTrack> {\n        &self.player().prev_tracks\n    }\n\n    /// top => bottom, aka the first track of the list is the next track\n    fn next_tracks_mut(&mut self) -> &mut Vec<ProvidedTrack> {\n        &mut self.player_mut().next_tracks\n    }\n\n    /// top => bottom, aka the first track of the list is the next track\n    pub(super) fn next_tracks(&self) -> &Vec<ProvidedTrack> {\n        &self.player().next_tracks\n    }\n\n    pub fn set_current_track_random(&mut self) -> Result<(), Error> {\n        let max_tracks = self.get_context(self.active_context)?.tracks.len();\n        let rng_track = rand::rng().random_range(0..max_tracks);\n        self.set_current_track(rng_track)\n    }\n\n    pub fn set_current_track(&mut self, index: usize) -> Result<(), Error> {\n        let context = self.get_context(self.active_context)?;\n\n        let new_track = context\n            .tracks\n            .get(index)\n            .ok_or(StateError::CanNotFindTrackInContext(\n                Some(index),\n                context.tracks.len(),\n            ))?;\n\n        debug!(\n            \"set track to: {} at {} of {} tracks\",\n            new_track.uri,\n            index,\n            context.tracks.len()\n        );\n\n        self.set_track(new_track.clone());\n\n        self.update_current_index(|i| i.track = index as u32);\n\n        Ok(())\n    }\n\n    /// Move to the next track\n    ///\n    /// Updates the current track to the next track. Adds the old track\n    /// to prev tracks and fills up the next tracks from the current context\n    pub fn next_track(&mut self) -> Result<Option<u32>, Error> {\n        // when we skip in repeat track, we don't repeat the current track anymore\n        if self.repeat_track() {\n            self.set_repeat_track(false);\n        }\n\n        let old_track = self.player_mut().track.take();\n\n        if let Some(old_track) = old_track {\n            // only add songs from our context to our previous tracks\n            if old_track.is_context() || old_track.is_autoplay() {\n                self.push_prev(old_track)\n            }\n        }\n\n        let new_track = loop {\n            match self.get_next_track() {\n                Some(next) if next.uid.starts_with(IDENTIFIER_DELIMITER) => {\n                    self.push_prev(next);\n                    continue;\n                }\n                Some(next) if next.is_unavailable() => continue,\n                other => break other,\n            };\n        };\n\n        let new_track = match new_track {\n            None => return Ok(None),\n            Some(t) => t,\n        };\n\n        self.fill_up_next_tracks()?;\n\n        let update_index = if new_track.is_queue() {\n            None\n        } else if new_track.is_autoplay() {\n            self.set_active_context(ContextType::Autoplay);\n            None\n        } else {\n            match new_track.get_context_index() {\n                Some(new_index) => Some(new_index as u32),\n                None => {\n                    error!(\"the given context track had no set context_index\");\n                    None\n                }\n            }\n        };\n\n        if let Some(update_index) = update_index {\n            self.update_current_index(|i| i.track = update_index)\n        } else {\n            self.player_mut().index.clear()\n        }\n\n        self.set_track(new_track);\n        self.update_restrictions();\n\n        Ok(Some(self.player().index.track))\n    }\n\n    /// Move to the prev track\n    ///\n    /// Updates the current track to the prev track. Adds the old track\n    /// to next tracks (when from the context) and fills up the prev tracks from the\n    /// current context\n    pub fn prev_track(&mut self) -> Result<Option<&MessageField<ProvidedTrack>>, Error> {\n        let old_track = self.player_mut().track.take();\n\n        if let Some(old_track) = old_track {\n            if old_track.is_context() || old_track.is_autoplay() {\n                // todo: O(n)\n                self.next_tracks_mut().insert(0, old_track);\n            }\n        }\n\n        // handle possible delimiter\n        if matches!(self.prev_tracks().last(), Some(prev) if prev.uid.starts_with(IDENTIFIER_DELIMITER))\n        {\n            let delimiter = self\n                .prev_tracks_mut()\n                .pop()\n                .expect(\"item that was prechecked\");\n\n            let next_tracks = self.next_tracks_mut();\n            if next_tracks.len() >= SPOTIFY_MAX_NEXT_TRACKS_SIZE {\n                let _ = next_tracks.pop();\n            }\n            // todo: O(n)\n            next_tracks.insert(0, delimiter)\n        }\n\n        while self.next_tracks().len() > SPOTIFY_MAX_NEXT_TRACKS_SIZE {\n            let _ = self.next_tracks_mut().pop();\n        }\n\n        let new_track = match self.prev_tracks_mut().pop() {\n            None => return Ok(None),\n            Some(t) => t,\n        };\n\n        if matches!(self.active_context, ContextType::Autoplay if new_track.is_context()) {\n            // transition back to default context\n            self.set_active_context(ContextType::Default);\n        }\n\n        self.fill_up_next_tracks()?;\n        self.set_track(new_track);\n\n        if self.player().index.track == 0 {\n            warn!(\"prev: trying to skip into negative, index update skipped\")\n        } else {\n            self.update_current_index(|i| i.track -= 1)\n        }\n\n        self.update_restrictions();\n\n        Ok(Some(self.current_track(|t| t)))\n    }\n\n    pub fn current_track<F: Fn(&'ct MessageField<ProvidedTrack>) -> R, R>(\n        &'ct self,\n        access: F,\n    ) -> R {\n        access(&self.player().track)\n    }\n\n    pub fn set_track(&mut self, track: ProvidedTrack) {\n        self.player_mut().track = MessageField::some(track)\n    }\n\n    pub fn set_next_tracks(&mut self, mut tracks: Vec<ProvidedTrack>) {\n        // mobile only sends a set_queue command instead of an add_to_queue command\n        // in addition to handling the mobile add_to_queue handling, this should also handle\n        // a mass queue addition\n        tracks\n            .iter_mut()\n            .filter(|t| t.is_from_queue())\n            .for_each(|t| {\n                t.set_provider(Provider::Queue);\n                // technically we could preserve the queue-uid here,\n                // but it seems to work without that, so we just override it\n                t.uid = format!(\"q{}\", self.queue_count);\n                self.queue_count += 1;\n            });\n\n        // when you drag 'n drop the current track in the queue view into the \"Next from: ...\"\n        // section, it is only send as an empty item with just the provider and metadata, so we have\n        // to provide set the uri from the current track manually\n        tracks\n            .iter_mut()\n            .filter(|t| t.uri.is_empty())\n            .for_each(|t| t.uri = self.current_track(|ct| ct.uri.clone()));\n\n        self.player_mut().next_tracks = tracks;\n    }\n\n    pub fn set_prev_tracks(&mut self, tracks: Vec<ProvidedTrack>) {\n        self.player_mut().prev_tracks = tracks;\n    }\n\n    pub fn clear_prev_track(&mut self) {\n        self.prev_tracks_mut().clear()\n    }\n\n    pub fn clear_next_tracks(&mut self) {\n        // respect queued track and don't throw them out of our next played tracks\n        let first_non_queued_track = self\n            .next_tracks()\n            .iter()\n            .enumerate()\n            .find(|(_, track)| !track.is_queue());\n\n        if let Some((non_queued_track, _)) = first_non_queued_track {\n            while self.next_tracks().len() > non_queued_track\n                && self.next_tracks_mut().pop().is_some()\n            {}\n        }\n    }\n\n    pub fn fill_up_next_tracks(&mut self) -> Result<(), Error> {\n        let ctx = self.get_context(self.fill_up_context)?;\n        let mut new_index = ctx.index.track as usize;\n        let mut iteration = ctx.index.page;\n\n        while self.next_tracks().len() < SPOTIFY_MAX_NEXT_TRACKS_SIZE {\n            let ctx = self.get_context(self.fill_up_context)?;\n            let track = match ctx.tracks.get(new_index) {\n                None if self.repeat_context() => {\n                    let delimiter = Self::new_delimiter(iteration.into());\n                    iteration += 1;\n                    new_index = 0;\n                    delimiter\n                }\n                None if !matches!(self.fill_up_context, ContextType::Autoplay)\n                    && self.autoplay_context.is_some()\n                    && !self.repeat_context() =>\n                {\n                    self.update_context_index(self.fill_up_context, new_index)?;\n\n                    // transition to autoplay as fill up context\n                    self.fill_up_context = ContextType::Autoplay;\n                    new_index = self.get_context(ContextType::Autoplay)?.index.track as usize;\n\n                    // add delimiter to only display the current context\n                    Self::new_delimiter(iteration.into())\n                }\n                None if self.autoplay_context.is_some() => {\n                    match self\n                        .get_context(ContextType::Autoplay)?\n                        .tracks\n                        .get(new_index)\n                    {\n                        None => break,\n                        Some(ct) => {\n                            new_index += 1;\n                            ct.clone()\n                        }\n                    }\n                }\n                None => break,\n                Some(ct) if ct.is_unavailable() || self.is_skip_track(ct, Some(iteration)) => {\n                    debug!(\n                        \"skipped track {} during fillup as it's unavailable or should be skipped\",\n                        ct.uri\n                    );\n                    new_index += 1;\n                    continue;\n                }\n                Some(ct) => {\n                    new_index += 1;\n                    ct.clone()\n                }\n            };\n\n            self.next_tracks_mut().push(track);\n        }\n\n        debug!(\n            \"finished filling up next_tracks ({})\",\n            self.next_tracks().len()\n        );\n\n        self.update_context_index(self.fill_up_context, new_index)?;\n\n        // the web-player needs a revision update, otherwise the queue isn't updated in the ui\n        self.update_queue_revision();\n\n        Ok(())\n    }\n\n    pub fn preview_next_track(&mut self) -> Option<SpotifyUri> {\n        let next = if self.repeat_track() {\n            self.current_track(|t| &t.uri)\n        } else {\n            &self.next_tracks().first()?.uri\n        };\n\n        SpotifyUri::from_uri(next).ok()\n    }\n\n    pub fn has_next_tracks(&self, min: Option<usize>) -> bool {\n        if let Some(min) = min {\n            self.next_tracks().len() >= min\n        } else {\n            !self.next_tracks().is_empty()\n        }\n    }\n\n    pub fn recent_track_uris(&self) -> Vec<String> {\n        let mut prev = self\n            .prev_tracks()\n            .iter()\n            .map(|t| t.uri.clone())\n            .collect::<Vec<_>>();\n\n        prev.push(self.current_track(|t| t.uri.clone()));\n        prev\n    }\n\n    pub fn mark_unavailable(&mut self, id: &SpotifyUri) -> Result<(), Error> {\n        let uri = id.to_uri();\n\n        debug!(\"marking {uri} as unavailable\");\n\n        let next_tracks = self.next_tracks_mut();\n        while let Some(pos) = next_tracks.iter().position(|t| t.uri == uri) {\n            let _ = next_tracks.remove(pos);\n        }\n\n        for next_track in next_tracks {\n            Self::mark_as_unavailable_for_match(next_track, &uri)\n        }\n\n        let prev_tracks = self.prev_tracks_mut();\n        while let Some(pos) = prev_tracks.iter().position(|t| t.uri == uri) {\n            let _ = prev_tracks.remove(pos);\n        }\n\n        for prev_track in prev_tracks {\n            Self::mark_as_unavailable_for_match(prev_track, &uri)\n        }\n\n        self.unavailable_uri.push(uri);\n        self.fill_up_next_tracks()?;\n        self.update_queue_revision();\n\n        Ok(())\n    }\n\n    pub fn add_to_queue(&mut self, mut track: ProvidedTrack, rev_update: bool) {\n        track.uid = format!(\"q{}\", self.queue_count);\n        self.queue_count += 1;\n\n        track.set_provider(Provider::Queue);\n        if !track.is_from_queue() {\n            track.set_from_queue(true);\n        }\n\n        let next_tracks = self.next_tracks_mut();\n        if let Some(next_not_queued_track) = next_tracks.iter().position(|t| !t.is_queue()) {\n            next_tracks.insert(next_not_queued_track, track);\n        } else {\n            next_tracks.push(track)\n        }\n\n        while next_tracks.len() > SPOTIFY_MAX_NEXT_TRACKS_SIZE {\n            next_tracks.pop();\n        }\n\n        if rev_update {\n            self.update_queue_revision();\n        }\n        self.update_restrictions();\n    }\n}\n"
  },
  {
    "path": "connect/src/state/transfer.rs",
    "content": "use crate::{\n    core::Error,\n    protocol::{player::ProvidedTrack, transfer_state::TransferState},\n    state::{\n        context::ContextType,\n        metadata::Metadata,\n        options::ShuffleState,\n        provider::{IsProvider, Provider},\n        {ConnectState, StateError},\n    },\n};\nuse protobuf::MessageField;\n\nimpl ConnectState {\n    pub fn current_track_from_transfer(\n        &self,\n        transfer: &TransferState,\n    ) -> Result<ProvidedTrack, Error> {\n        let track = if transfer.queue.is_playing_queue.unwrap_or_default() {\n            debug!(\"transfer track was used from the queue\");\n            transfer.queue.tracks.first()\n        } else {\n            debug!(\"transfer track was the current track\");\n            transfer.playback.current_track.as_ref()\n        }\n        .ok_or(StateError::CouldNotResolveTrackFromTransfer)?;\n\n        self.context_to_provided_track(\n            track,\n            transfer.current_session.context.uri.as_deref(),\n            None,\n            None,\n            transfer\n                .queue\n                .is_playing_queue\n                .unwrap_or_default()\n                .then_some(Provider::Queue),\n        )\n    }\n\n    /// handles the initially transferable data\n    pub fn handle_initial_transfer(\n        &mut self,\n        transfer: &mut TransferState,\n        ctx_uri: Option<String>,\n    ) {\n        let current_context_metadata = self.context.as_ref().map(|c| c.metadata.clone());\n        let player = self.player_mut();\n\n        player.is_buffering = false;\n\n        if let Some(options) = transfer.options.take() {\n            player.options = MessageField::some(options.into());\n        }\n        player.is_paused = transfer.playback.is_paused.unwrap_or_default();\n        player.is_playing = !player.is_paused;\n\n        match transfer.playback.playback_speed {\n            Some(speed) if speed != 0. => player.playback_speed = speed,\n            _ => player.playback_speed = 1.,\n        }\n\n        let mut shuffle_seed = None;\n        let mut initial_track = None;\n        if let Some(session) = transfer.current_session.as_mut() {\n            player.play_origin = session.play_origin.take().map(Into::into).into();\n            player.suppressions = session.suppressions.take().map(Into::into).into();\n\n            // maybe at some point we can use the shuffle seed provided by spotify,\n            // but I doubt it, as spotify doesn't use true randomness but rather an algorithm\n            // based shuffle\n            trace!(\n                \"shuffle_seed: <{:?}> (spotify), <{:?}> (own)\",\n                session.shuffle_seed,\n                session.context.get_shuffle_seed()\n            );\n\n            shuffle_seed = session\n                .context\n                .get_shuffle_seed()\n                .and_then(|seed| seed.parse().ok());\n\n            initial_track = session.context.get_initial_track().cloned();\n\n            if let Some(mut ctx) = session.context.take() {\n                player.restrictions = ctx.restrictions.take().map(Into::into).into();\n                for (key, value) in ctx.metadata {\n                    player.context_metadata.insert(key, value);\n                }\n            }\n        }\n\n        const UNKNOWN_URI: &str = \"spotify:unknown\";\n        // it's important to always set the url/uri to a value\n        // so that the player doesn't go into an inactive state\n        let uri = ctx_uri.unwrap_or(UNKNOWN_URI.into());\n\n        player.context_url = format!(\"context://{uri}\");\n        player.context_uri = uri;\n\n        if let Some(metadata) = current_context_metadata {\n            for (key, value) in metadata {\n                player.context_metadata.insert(key, value);\n            }\n        }\n\n        self.transfer_shuffle = match (shuffle_seed, initial_track) {\n            (Some(seed), Some(initial_track)) => Some(ShuffleState {\n                seed,\n                initial_track,\n            }),\n            _ => None,\n        };\n\n        self.clear_prev_track();\n        self.clear_next_tracks();\n        self.update_queue_revision()\n    }\n\n    /// completes the transfer, loading the queue and updating metadata\n    pub fn finish_transfer(&mut self, transfer: TransferState) -> Result<(), Error> {\n        let track = match self.player().track.as_ref() {\n            None => self.current_track_from_transfer(&transfer)?,\n            Some(track) => track.clone(),\n        };\n\n        let context_ty = if self.current_track(|t| t.is_from_autoplay()) {\n            ContextType::Autoplay\n        } else {\n            ContextType::Default\n        };\n\n        self.set_active_context(context_ty);\n        self.fill_up_context = context_ty;\n\n        let ctx = self.get_context(self.active_context)?;\n\n        let current_index = match transfer.current_session.current_uid.as_ref() {\n            Some(uid) if track.is_queue() => Self::find_index_in_context(ctx, |c| &c.uid == uid)\n                .map(|i| if i > 0 { i - 1 } else { i }),\n            _ => Self::find_index_in_context(ctx, |c| c.uri == track.uri || c.uid == track.uid),\n        };\n\n        debug!(\n            \"active track is <{}> with index {current_index:?} in {:?} context, has {} tracks\",\n            track.uri,\n            self.active_context,\n            ctx.tracks.len()\n        );\n\n        if self.player().track.is_none() {\n            self.set_track(track);\n        }\n\n        let current_index = current_index.ok();\n        if let Some(current_index) = current_index {\n            self.update_current_index(|i| i.track = current_index as u32);\n        }\n\n        debug!(\n            \"setting up next and prev: index is at {current_index:?} while shuffle {}\",\n            self.shuffling_context()\n        );\n\n        for (i, track) in transfer.queue.tracks.iter().enumerate() {\n            if transfer.queue.is_playing_queue.unwrap_or_default() && i == 0 {\n                // if we are currently playing from the queue,\n                // don't add the first queued item, because we are currently playing that item\n                continue;\n            }\n\n            if let Ok(queued_track) = self.context_to_provided_track(\n                track,\n                Some(self.context_uri()),\n                None,\n                None,\n                Some(Provider::Queue),\n            ) {\n                self.add_to_queue(queued_track, false);\n            }\n        }\n\n        if self.shuffling_context() {\n            self.set_current_track(current_index.unwrap_or_default())?;\n            self.set_shuffle(true);\n\n            match self.transfer_shuffle.take() {\n                None => self.shuffle_new(),\n                Some(state) => self.shuffle_restore(state),\n            }?\n        } else {\n            self.reset_playback_to_position(current_index)?;\n        }\n\n        self.update_restrictions();\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "connect/src/state.rs",
    "content": "pub(super) mod context;\nmod handle;\nmod metadata;\nmod options;\npub(super) mod provider;\nmod restrictions;\nmod tracks;\nmod transfer;\n\nuse crate::{\n    core::{\n        Error, Session, config::DeviceType, date::Date, dealer::protocol::Request,\n        spclient::SpClientResult, version,\n    },\n    model::SpircPlayStatus,\n    protocol::{\n        connect::{Capabilities, Device, DeviceInfo, MemberType, PutStateReason, PutStateRequest},\n        media::AudioQuality,\n        player::{\n            ContextIndex, ContextPlayerOptions, PlayOrigin, PlayerState, ProvidedTrack,\n            Suppressions,\n        },\n    },\n    state::{\n        context::{ContextType, ResetContext, StateContext},\n        options::ShuffleState,\n        provider::{IsProvider, Provider},\n    },\n};\nuse log::LevelFilter;\nuse protobuf::{EnumOrUnknown, MessageField};\nuse std::{\n    collections::hash_map::DefaultHasher,\n    hash::{Hash, Hasher},\n    time::{Duration, SystemTime, UNIX_EPOCH},\n};\nuse thiserror::Error;\n\n// these limitations are essential, otherwise to many tracks will overload the web-player\nconst SPOTIFY_MAX_PREV_TRACKS_SIZE: usize = 10;\nconst SPOTIFY_MAX_NEXT_TRACKS_SIZE: usize = 80;\n\n#[derive(Debug, Error)]\npub(super) enum StateError {\n    #[error(\"the current track couldn't be resolved from the transfer state\")]\n    CouldNotResolveTrackFromTransfer,\n    #[error(\"context is not available. type: {0:?}\")]\n    NoContext(ContextType),\n    #[error(\"could not find track {0:?} in context of {1}\")]\n    CanNotFindTrackInContext(Option<usize>, usize),\n    #[error(\"currently {action} is not allowed because {reason}\")]\n    CurrentlyDisallowed {\n        action: &'static str,\n        reason: String,\n    },\n    #[error(\"the provided context has no tracks\")]\n    ContextHasNoTracks,\n    #[error(\"playback of local files is not supported\")]\n    UnsupportedLocalPlayback,\n    #[error(\"track uri <{0:?}> contains invalid characters\")]\n    InvalidTrackUri(Option<String>),\n}\n\nimpl From<StateError> for Error {\n    fn from(err: StateError) -> Self {\n        use StateError::*;\n        match err {\n            CouldNotResolveTrackFromTransfer\n            | NoContext(_)\n            | CanNotFindTrackInContext(_, _)\n            | ContextHasNoTracks\n            | InvalidTrackUri(_) => Error::failed_precondition(err),\n            CurrentlyDisallowed { .. } | UnsupportedLocalPlayback => Error::unavailable(err),\n        }\n    }\n}\n\n/// Configuration of the connect device\n#[derive(Debug, Clone)]\npub struct ConnectConfig {\n    /// The name of the connect device (default: librespot)\n    pub name: String,\n    /// The icon type of the connect device (default: [DeviceType::Speaker])\n    pub device_type: DeviceType,\n    /// Displays the [DeviceType] twice in the ui to show up as a group (default: false)\n    pub is_group: bool,\n    /// The volume with which the connect device will be initialized (default: 50%)\n    pub initial_volume: u16,\n    /// Disables the option to control the volume remotely (default: false)\n    pub disable_volume: bool,\n    /// Number of incremental steps (default: 64)\n    pub volume_steps: u16,\n    /// Emit `SetQueue` player events when the queue changes (default: false)\n    pub emit_set_queue_events: bool,\n}\n\nimpl Default for ConnectConfig {\n    fn default() -> Self {\n        Self {\n            name: \"librespot\".to_string(),\n            device_type: DeviceType::Speaker,\n            is_group: false,\n            initial_volume: u16::MAX / 2,\n            disable_volume: false,\n            volume_steps: 64,\n            emit_set_queue_events: false,\n        }\n    }\n}\n\n#[derive(Default, Debug)]\npub(super) struct ConnectState {\n    /// the entire state that is updated to the remote server\n    request: PutStateRequest,\n\n    unavailable_uri: Vec<String>,\n\n    active_since: Option<SystemTime>,\n    queue_count: u64,\n\n    // separation is necessary because we could have already loaded\n    // the autoplay context but are still playing from the default context\n    /// to update the active context use [switch_active_context](ConnectState::set_active_context)\n    pub active_context: ContextType,\n    fill_up_context: ContextType,\n\n    /// the context from which we play, is used to top up prev and next tracks\n    context: Option<StateContext>,\n    /// seed extracted in [ConnectState::handle_initial_transfer] and used in [ConnectState::finish_transfer]\n    transfer_shuffle: Option<ShuffleState>,\n\n    /// a context to keep track of the autoplay context\n    autoplay_context: Option<StateContext>,\n\n    /// The volume adjustment per step when handling individual volume adjustments.\n    pub volume_step_size: u16,\n}\n\nimpl ConnectState {\n    pub fn new(cfg: ConnectConfig, session: &Session) -> Self {\n        let volume_step_size = u16::MAX.checked_div(cfg.volume_steps).unwrap_or(1024);\n\n        let device_info = DeviceInfo {\n            can_play: true,\n            volume: cfg.initial_volume.into(),\n            name: cfg.name,\n            device_id: session.device_id().to_string(),\n            device_type: EnumOrUnknown::new(cfg.device_type.into()),\n            device_software_version: version::SEMVER.to_string(),\n            spirc_version: version::SPOTIFY_SPIRC_VERSION.to_string(),\n            client_id: session.client_id(),\n            is_group: cfg.is_group,\n            capabilities: MessageField::some(Capabilities {\n                volume_steps: cfg.volume_steps.into(),\n                disable_volume: cfg.disable_volume,\n\n                gaia_eq_connect_id: true,\n                can_be_player: true,\n                needs_full_player_state: true,\n                is_observable: true,\n                is_controllable: true,\n                hidden: false,\n\n                supports_gzip_pushes: true,\n                // todo: enable after logout handling is implemented, see spirc logout_request\n                supports_logout: false,\n                supported_types: vec![\n                    \"audio/episode\".into(),\n                    \"audio/track\".into(),\n                    \"audio/local\".into(),\n                ],\n                supports_playlist_v2: true,\n                supports_transfer_command: true,\n                supports_command_request: true,\n                supports_set_options_command: true,\n\n                is_voice_enabled: false,\n                restrict_to_local: false,\n                connect_disabled: false,\n                supports_rename: false,\n                supports_external_episodes: false,\n                supports_set_backend_metadata: false,\n                supports_hifi: MessageField::none(),\n                // that \"AI\" dj thingy only available to specific regions/users\n                supports_dj: false,\n                supports_rooms: false,\n                // AudioQuality::HIFI is available, further investigation necessary\n                supported_audio_quality: EnumOrUnknown::new(AudioQuality::VERY_HIGH),\n\n                command_acks: true,\n\n                ..Default::default()\n            }),\n            ..Default::default()\n        };\n\n        let mut state = Self {\n            request: PutStateRequest {\n                member_type: EnumOrUnknown::new(MemberType::CONNECT_STATE),\n                put_state_reason: EnumOrUnknown::new(PutStateReason::PLAYER_STATE_CHANGED),\n                device: MessageField::some(Device {\n                    device_info: MessageField::some(device_info),\n                    player_state: MessageField::some(PlayerState {\n                        session_id: session.session_id(),\n                        ..Default::default()\n                    }),\n                    ..Default::default()\n                }),\n                ..Default::default()\n            },\n            volume_step_size,\n            ..Default::default()\n        };\n        state.reset();\n        state\n    }\n\n    fn reset(&mut self) {\n        self.set_active(false);\n        self.queue_count = 0;\n\n        // preserve the session_id\n        let session_id = self.player().session_id.clone();\n\n        self.device_mut().player_state = MessageField::some(PlayerState {\n            session_id,\n            is_system_initiated: true,\n            playback_speed: 1.,\n            play_origin: MessageField::some(PlayOrigin::new()),\n            suppressions: MessageField::some(Suppressions::new()),\n            options: MessageField::some(ContextPlayerOptions::new()),\n            // + 1, so that we have a buffer where we can swap elements\n            prev_tracks: Vec::with_capacity(SPOTIFY_MAX_PREV_TRACKS_SIZE + 1),\n            next_tracks: Vec::with_capacity(SPOTIFY_MAX_NEXT_TRACKS_SIZE + 1),\n            ..Default::default()\n        });\n    }\n\n    fn device_mut(&mut self) -> &mut Device {\n        self.request\n            .device\n            .as_mut()\n            .expect(\"the request is always available\")\n    }\n\n    fn player_mut(&mut self) -> &mut PlayerState {\n        self.device_mut()\n            .player_state\n            .as_mut()\n            .expect(\"the player_state has to be always given\")\n    }\n\n    pub fn device_info(&self) -> &DeviceInfo {\n        &self.request.device.device_info\n    }\n\n    pub fn player(&self) -> &PlayerState {\n        &self.request.device.player_state\n    }\n\n    pub fn is_active(&self) -> bool {\n        self.request.is_active\n    }\n\n    /// Returns the `is_playing` value as perceived by other connect devices\n    ///\n    /// see [ConnectState::set_status]\n    pub fn is_playing(&self) -> bool {\n        let player = self.player();\n        player.is_playing && !player.is_paused\n    }\n\n    /// Returns the `is_paused` state value as perceived by other connect devices\n    ///\n    /// see [ConnectState::set_status]\n    pub fn is_pause(&self) -> bool {\n        let player = self.player();\n        player.is_playing && player.is_paused && player.is_buffering\n    }\n\n    pub fn set_volume(&mut self, volume: u32) {\n        self.device_mut()\n            .device_info\n            .as_mut()\n            .expect(\"the device_info has to be always given\")\n            .volume = volume;\n    }\n\n    pub fn set_last_command(&mut self, command: Request) {\n        self.request.last_command_message_id = command.message_id;\n        self.request.last_command_sent_by_device_id = command.sent_by_device_id;\n    }\n\n    pub fn set_now(&mut self, now: u64) {\n        self.request.client_side_timestamp = now;\n\n        if let Some(active_since) = self.active_since {\n            if let Ok(active_since_duration) = active_since.duration_since(UNIX_EPOCH) {\n                match active_since_duration.as_millis().try_into() {\n                    Ok(active_since_ms) => self.request.started_playing_at = active_since_ms,\n                    Err(why) => warn!(\"couldn't update active since because {why}\"),\n                }\n            }\n        }\n    }\n\n    pub fn set_active(&mut self, value: bool) {\n        if value {\n            if self.request.is_active {\n                return;\n            }\n\n            self.request.is_active = true;\n            self.active_since = Some(SystemTime::now())\n        } else {\n            self.request.is_active = false;\n            self.active_since = None\n        }\n    }\n\n    pub fn set_origin(&mut self, origin: PlayOrigin) {\n        self.player_mut().play_origin = MessageField::some(origin)\n    }\n\n    pub fn set_session_id(&mut self, session_id: String) {\n        self.player_mut().session_id = session_id;\n    }\n\n    pub(crate) fn set_status(&mut self, status: &SpircPlayStatus) {\n        let player = self.player_mut();\n        player.is_paused = matches!(\n            status,\n            SpircPlayStatus::LoadingPause { .. }\n                | SpircPlayStatus::Paused { .. }\n                | SpircPlayStatus::Stopped\n        );\n\n        if player.is_paused {\n            player.playback_speed = 0.;\n        } else {\n            player.playback_speed = 1.;\n        }\n\n        // desktop and mobile require all 'states' set to true, when we are paused,\n        // otherwise the play button (desktop) is grayed out or the preview (mobile) can't be opened\n        player.is_buffering = player.is_paused\n            || matches!(\n                status,\n                SpircPlayStatus::LoadingPause { .. } | SpircPlayStatus::LoadingPlay { .. }\n            );\n        player.is_playing = player.is_paused\n            || matches!(\n                status,\n                SpircPlayStatus::LoadingPlay { .. } | SpircPlayStatus::Playing { .. }\n            );\n\n        debug!(\n            \"updated connect play status playing: {}, paused: {}, buffering: {}\",\n            player.is_playing, player.is_paused, player.is_buffering\n        );\n\n        self.update_restrictions()\n    }\n\n    /// index is 0 based, so the first track is index 0\n    pub fn update_current_index(&mut self, f: impl Fn(&mut ContextIndex)) {\n        match self.player_mut().index.as_mut() {\n            Some(player_index) => f(player_index),\n            None => {\n                let mut new_index = ContextIndex::new();\n                f(&mut new_index);\n                self.player_mut().index = MessageField::some(new_index)\n            }\n        }\n    }\n\n    pub fn update_position(&mut self, position_ms: u32, timestamp: i64) {\n        let player = self.player_mut();\n        player.position_as_of_timestamp = position_ms.into();\n        player.timestamp = timestamp;\n    }\n\n    pub fn update_duration(&mut self, duration: u32) {\n        self.player_mut().duration = duration.into()\n    }\n\n    pub fn update_queue_revision(&mut self) {\n        let mut state = DefaultHasher::new();\n        self.next_tracks()\n            .iter()\n            .for_each(|t| t.uri.hash(&mut state));\n        self.player_mut().queue_revision = state.finish().to_string()\n    }\n\n    pub fn reset_playback_to_position(&mut self, new_index: Option<usize>) -> Result<(), Error> {\n        debug!(\n            \"reset_playback with active ctx <{:?}> fill_up ctx <{:?}>\",\n            self.active_context, self.fill_up_context\n        );\n\n        let new_index = new_index.unwrap_or(0);\n        self.update_current_index(|i| i.track = new_index as u32);\n        self.update_context_index(self.active_context, new_index + 1)?;\n        self.fill_up_context = self.active_context;\n\n        if !self.current_track(|t| t.is_queue() || self.is_skip_track(t, None)) {\n            self.set_current_track(new_index)?;\n        }\n\n        self.clear_prev_track();\n\n        if new_index > 0 {\n            let context = self.get_context(self.active_context)?;\n\n            let before_new_track = context.tracks.len() - new_index;\n            self.player_mut().prev_tracks = context\n                .tracks\n                .iter()\n                .rev()\n                .skip(before_new_track)\n                .take(SPOTIFY_MAX_PREV_TRACKS_SIZE)\n                .rev()\n                .cloned()\n                .collect();\n            debug!(\"has {} prev tracks\", self.prev_tracks().len())\n        }\n\n        self.clear_next_tracks();\n        self.fill_up_next_tracks()?;\n        self.update_restrictions();\n\n        Ok(())\n    }\n\n    fn mark_as_unavailable_for_match(track: &mut ProvidedTrack, uri: &str) {\n        if track.uri == uri {\n            debug!(\"Marked <{}:{}> as unavailable\", track.provider, track.uri);\n            track.set_provider(Provider::Unavailable);\n        }\n    }\n\n    pub fn update_position_in_relation(&mut self, timestamp: i64) {\n        let player = self.player_mut();\n\n        let diff = timestamp - player.timestamp;\n        player.position_as_of_timestamp += diff;\n\n        if log::max_level() >= LevelFilter::Debug {\n            let pos = Duration::from_millis(player.position_as_of_timestamp as u64);\n            let time = Date::from_timestamp_ms(timestamp)\n                .map(|d| d.time().to_string())\n                .unwrap_or_else(|_| timestamp.to_string());\n\n            let sec = pos.as_secs();\n            let (min, sec) = (sec / 60, sec % 60);\n            debug!(\"update position to {min}:{sec:0>2} at {time}\");\n        }\n\n        player.timestamp = timestamp;\n    }\n\n    pub async fn became_inactive(&mut self, session: &Session) -> SpClientResult {\n        self.reset();\n        self.reset_context(ResetContext::Completely);\n\n        session.spclient().put_connect_state_inactive(false).await\n    }\n\n    async fn send_with_reason(\n        &mut self,\n        session: &Session,\n        reason: PutStateReason,\n    ) -> SpClientResult {\n        let prev_reason = self.request.put_state_reason;\n\n        self.request.put_state_reason = EnumOrUnknown::new(reason);\n        let res = self.send_state(session).await;\n\n        self.request.put_state_reason = prev_reason;\n        res\n    }\n\n    /// Notifies the remote server about a new device\n    pub async fn notify_new_device_appeared(&mut self, session: &Session) -> SpClientResult {\n        self.send_with_reason(session, PutStateReason::NEW_DEVICE)\n            .await\n    }\n\n    /// Notifies the remote server about a new volume\n    pub async fn notify_volume_changed(&mut self, session: &Session) -> SpClientResult {\n        self.send_with_reason(session, PutStateReason::VOLUME_CHANGED)\n            .await\n    }\n\n    /// Sends the connect state for the connect session to the remote server\n    pub async fn send_state(&self, session: &Session) -> SpClientResult {\n        session\n            .spclient()\n            .put_connect_state_request(&self.request)\n            .await\n    }\n}\n"
  },
  {
    "path": "contrib/Dockerfile",
    "content": "# Cross compilation environment for librespot\n# Build the docker image from the root of the project with the following command :\n# $ docker build -t librespot-cross -f contrib/Dockerfile .\n#\n# The resulting image can be used to build librespot for linux x86_64, armhf, armel, aarch64\n# $ docker run -v /tmp/librespot-build:/build librespot-cross\n#\n# The compiled binaries will be located in /tmp/librespot-build\n#\n# If only one architecture is desired, cargo can be invoked directly with the appropriate options :\n# $ docker run -v /tmp/librespot-build:/build librespot-cross cargo build --release --no-default-features --features \"alsa-backend with-libmdns native-tls\"\n# $ 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 native-tls\"\n# $ 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 native-tls\"\n# $ 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 native-tls\"\n\nFROM debian:trixie\n\nRUN dpkg --add-architecture arm64 && \\\n\tdpkg --add-architecture armhf && \\\n\tdpkg --add-architecture armel && \\\n\tapt-get update && \\\n\tapt-get install -y \\\n\tbuild-essential \\\n\tcmake \\\n\tcrossbuild-essential-arm64 \\\n\tcrossbuild-essential-armel \\\n\tcrossbuild-essential-armhf \\\n\tcurl \\\n\tgit \\\n\tlibasound2-dev \\\n\tlibasound2-dev:arm64 \\\n\tlibasound2-dev:armel \\\n\tlibasound2-dev:armhf \\\n\tlibclang-dev \\\n\tlibpulse0 \\\n\tlibpulse0:arm64 \\\n\tlibpulse0:armel \\\n\tlibpulse0:armhf \\\n\tlibssl-dev \\\n\tlibssl-dev:arm64 \\\n\tlibssl-dev:armel \\\n\tlibssl-dev:armhf \\\n\tpkg-config \\\n\trustup\n\nENV PATH=\"/root/.cargo/bin/:${PATH}\"\nRUN rustup default stable && \\\n\trustup target add aarch64-unknown-linux-gnu && \\\n\trustup target add arm-unknown-linux-gnueabi && \\\n\trustup target add arm-unknown-linux-gnueabihf && \\\n\tcargo install bindgen-cli && \\\n\tmkdir /.cargo && \\\n\techo '[target.aarch64-unknown-linux-gnu]\\nlinker = \"aarch64-linux-gnu-gcc\"' > /.cargo/config && \\\n\techo '[target.arm-unknown-linux-gnueabihf]\\nlinker = \"arm-linux-gnueabihf-gcc\"' >> /.cargo/config && \\\n\techo '[target.arm-unknown-linux-gnueabi]\\nlinker = \"arm-linux-gnueabi-gcc\"' >> /.cargo/config\n\nENV CARGO_TARGET_DIR=/build\nENV CARGO_HOME=/build/cache\nENV PKG_CONFIG_ALLOW_CROSS=1\nENV PKG_CONFIG_PATH_aarch64-unknown-linux-gnu=/usr/lib/aarch64-linux-gnu/pkgconfig/\nENV PKG_CONFIG_PATH_arm-unknown-linux-gnueabihf=/usr/lib/arm-linux-gnueabihf/pkgconfig/\nENV PKG_CONFIG_PATH_arm-unknown-linux-gnueabi=/usr/lib/arm-linux-gnueabi/pkgconfig/\n\nADD . /src\nWORKDIR /src\nCMD [\"/src/contrib/docker-build.sh\"]\n"
  },
  {
    "path": "contrib/Dockerfile.Rpi",
    "content": "# Create a docker image for the RPI\n# Build the docker image from the root of the project with the following command :\n# $ docker build -t librespot-rpi -f .\\contrib\\Dockerfile.Rpi .\n#\n# This builds a docker image which is usable when running docker on the rpi.\n# \n# This Dockerfile builds in windows without any requirements, for linux based systems you might need to run the following line:\n# docker run --rm --privileged multiarch/qemu-user-static:register --reset\n# (see here for more info: https://gist.github.com/PieterScheffers/d50f609d9628383e4c9d8d7d269b7643 )\n#\n# Save the docker image to a file:\n# $ docker save -o contrib/librespot-rpi librespot-rpi\n#\n# Move it to the rpi and import it with:\n# docker load -i librespot-rpi\n#\n# Run it with:\n# docker run -d --restart unless-stopped $(for DEV in $(find /dev/snd -type c); do echo --device=$DEV:$DEV; done) --net=host --name librespot-rpi librespot-rpi --name {devicename} \n\nFROM debian:stretch\n\nRUN dpkg --add-architecture armhf\nRUN apt-get update\n\nRUN apt-get install -y curl git build-essential crossbuild-essential-armhf\nRUN apt-get install -y libasound2-dev libasound2-dev:armhf\nRUN apt-get install -y pkg-config\n\nRUN curl https://sh.rustup.rs -sSf | sh -s -- -y\nENV PATH=\"/root/.cargo/bin/:${PATH}\"\nRUN rustup target add arm-unknown-linux-gnueabihf\n\nRUN mkdir /.cargo && \\\n    echo '[target.arm-unknown-linux-gnueabihf]\\nlinker = \"arm-linux-gnueabihf-gcc\"' >> /.cargo/config\n\nRUN mkdir /build\nENV CARGO_TARGET_DIR /build\nENV CARGO_HOME /build/cache\nENV PKG_CONFIG_PATH /usr/lib/arm-linux-gnueabihf/pkgconfig/\nENV PKG_CONFIG_ALLOW_CROSS 1\n\nADD . /src\nWORKDIR /src\n\nRUN cargo build --release --target arm-unknown-linux-gnueabihf --no-default-features --features \"alsa-backend\"\n\n\nFROM resin/rpi-raspbian\nRUN apt-get update && \\\n    apt-get install libasound2 && \\\n    rm -rf /var/lib/apt/lists/*\n\nRUN mkdir /librespot\nWORKDIR /librespot\n\nCOPY --from=0 /build/arm-unknown-linux-gnueabihf/release/librespot .\nRUN chmod +x librespot\nENTRYPOINT [\"./librespot\"]"
  },
  {
    "path": "contrib/cross-compile-armv6hf/Dockerfile",
    "content": "# Cross compilation environment for librespot in armv6hf.\n# Build the docker image from the root of the project with the following command:\n# $ docker build -t librespot-cross-armv6hf -f contrib/cross-compile-armv6hf/Dockerfile .\n#\n# The resulting image can be used to build librespot for armv6hf:\n# $ docker run -v /tmp/librespot-build-armv6hf:/build librespot-cross-armv6hf\n#\n# The compiled binary will be located in /tmp/librespot-build-armv6hf/arm-unknown-linux-gnueabihf/release/librespot\n\nFROM --platform=linux/amd64 ubuntu:18.04\n\n# Install common packages.\nRUN apt-get update\nRUN apt-get install -y -qq git curl build-essential cmake clang libclang-dev libasound2-dev libpulse-dev\n\n# Install armhf packages.\nRUN echo \"deb [arch=armhf] http://ports.ubuntu.com/ubuntu-ports/ bionic main\" | tee -a /etc/apt/sources.list\nRUN apt-get update\nRUN apt-get download libasound2:armhf libasound2-dev:armhf\nRUN mkdir /sysroot && \\\n    dpkg -x libasound2_*.deb /sysroot/ && \\\n    dpkg -x libasound2-dev*.deb /sysroot/\n\n# Install rust.\nRUN curl https://sh.rustup.rs -sSf | sh -s -- --default-toolchain 1.85 -y\nENV PATH=\"/root/.cargo/bin/:${PATH}\"\nRUN rustup target add arm-unknown-linux-gnueabihf\nRUN mkdir /.cargo && \\\n    echo '[target.arm-unknown-linux-gnueabihf]\\nlinker = \"arm-linux-gnueabihf-gcc\"' >> /.cargo/config\n\n# Install Pi tools for armv6.\nRUN mkdir /pi && \\\n    git -C /pi clone --depth=1 https://github.com/raspberrypi/tools.git\n\n# Build env variables.\nENV CARGO_TARGET_DIR=/build\nENV CARGO_HOME=/build/cache\nENV PATH=\"/pi/tools/arm-bcm2708/arm-linux-gnueabihf/bin:${PATH}\"\nENV PKG_CONFIG_ALLOW_CROSS=1\nENV PKG_CONFIG_PATH_arm-unknown-linux-gnueabihf=/usr/lib/arm-linux-gnueabihf/pkgconfig/\n\nADD . /src\nWORKDIR /src\nCMD [\"/src/contrib/cross-compile-armv6hf/docker-build.sh\"]\n"
  },
  {
    "path": "contrib/cross-compile-armv6hf/docker-build.sh",
    "content": "#!/usr/bin/env bash\nset -eux\n\ncargo install --force --locked bindgen-cli\n\nPI1_TOOLS_DIR=/pi/tools/arm-bcm2708/arm-linux-gnueabihf\nPI1_TOOLS_SYSROOT_DIR=$PI1_TOOLS_DIR/arm-linux-gnueabihf/sysroot\n\nPI1_LIB_DIRS=(\n  \"$PI1_TOOLS_SYSROOT_DIR/lib\"\n  \"$PI1_TOOLS_SYSROOT_DIR/usr/lib\"\n  \"/sysroot/usr/lib/arm-linux-gnueabihf\"\n)\nexport RUSTFLAGS=\"-C linker=$PI1_TOOLS_DIR/bin/arm-linux-gnueabihf-gcc ${PI1_LIB_DIRS[*]/#/-L}\"\nexport BINDGEN_EXTRA_CLANG_ARGS=--sysroot=$PI1_TOOLS_SYSROOT_DIR\n\ncargo build --release --target arm-unknown-linux-gnueabihf --no-default-features --features \"alsa-backend with-libmdns rustls-tls-native-roots\"\n"
  },
  {
    "path": "contrib/docker-build.sh",
    "content": "#!/usr/bin/env bash\nset -eux\n\ncargo build --release --no-default-features --features \"alsa-backend with-libmdns native-tls\"\ncargo build --release --target aarch64-unknown-linux-gnu --no-default-features --features \"alsa-backend with-libmdns native-tls\"\ncargo build --release --target arm-unknown-linux-gnueabihf --no-default-features --features \"alsa-backend with-libmdns native-tls\"\ncargo build --release --target arm-unknown-linux-gnueabi --no-default-features --features \"alsa-backend with-libmdns native-tls\"\n"
  },
  {
    "path": "contrib/event_handler_example.py",
    "content": "#!/usr/bin/python3\nimport os\nimport json\nfrom datetime import datetime\n\nplayer_event = os.getenv('PLAYER_EVENT')\n\njson_dict = {\n   'event_time': str(datetime.now()),\n   'event': player_event,\n} \n\nif player_event in ('session_connected', 'session_disconnected'):\n    json_dict['user_name'] = os.environ['USER_NAME']\n    json_dict['connection_id'] = os.environ['CONNECTION_ID']\n\nelif player_event == 'session_client_changed':\n    json_dict['client_id'] = os.environ['CLIENT_ID']\n    json_dict['client_name'] = os.environ['CLIENT_NAME']\n    json_dict['client_brand_name'] = os.environ['CLIENT_BRAND_NAME']\n    json_dict['client_model_name'] = os.environ['CLIENT_MODEL_NAME']\n\nelif player_event == 'shuffle_changed':\n    json_dict['shuffle'] = os.environ['SHUFFLE']\n\nelif player_event == 'repeat_changed':\n    json_dict['repeat'] = os.environ['REPEAT']\n\nelif player_event == 'auto_play_changed':\n    json_dict['auto_play'] = os.environ['AUTO_PLAY']\n\nelif player_event == 'filter_explicit_content_changed':\n    json_dict['filter'] = os.environ['FILTER']\n\nelif player_event == 'volume_changed':\n    json_dict['volume'] = os.environ['VOLUME']\n\nelif player_event in ('seeked', 'position_correction', 'playing', 'paused'):\n    json_dict['track_id'] = os.environ['TRACK_ID']\n    json_dict['position_ms'] = os.environ['POSITION_MS']\n\nelif player_event in ('unavailable', 'end_of_track', 'preload_next', 'preloading', 'loading', 'stopped'): \n    json_dict['track_id'] = os.environ['TRACK_ID']\n\nelif player_event == 'track_changed':\n    common_metadata_fields = {}\n    item_type = os.environ['ITEM_TYPE']\n    common_metadata_fields['item_type'] = item_type\n    common_metadata_fields['track_id'] = os.environ['TRACK_ID']\n    common_metadata_fields['uri'] = os.environ['URI']\n    common_metadata_fields['name'] = os.environ['NAME']\n    common_metadata_fields['duration_ms'] = os.environ['DURATION_MS']\n    common_metadata_fields['is_explicit'] = os.environ['IS_EXPLICIT']\n    common_metadata_fields['language'] = os.environ['LANGUAGE'].split('\\n')\n    common_metadata_fields['covers'] = os.environ['COVERS'].split('\\n')\n    json_dict['common_metadata_fields'] = common_metadata_fields\n    \n\n    if item_type == 'Track':\n        track_metadata_fields = {}\n        track_metadata_fields['number'] = os.environ['NUMBER']\n        track_metadata_fields['disc_number'] = os.environ['DISC_NUMBER']\n        track_metadata_fields['popularity'] = os.environ['POPULARITY']\n        track_metadata_fields['album'] = os.environ['ALBUM']\n        track_metadata_fields['artists'] = os.environ['ARTISTS'].split('\\n')\n        track_metadata_fields['album_artists'] = os.environ['ALBUM_ARTISTS'].split('\\n')\n        json_dict['track_metadata_fields'] = track_metadata_fields\n\n    elif item_type == 'Episode':\n        episode_metadata_fields = {}\n        episode_metadata_fields['show_name'] = os.environ['SHOW_NAME']\n        publish_time = datetime.utcfromtimestamp(int(os.environ['PUBLISH_TIME'])).strftime('%Y-%m-%d')\n        episode_metadata_fields['publish_time'] = publish_time\n        episode_metadata_fields['description'] = os.environ['DESCRIPTION']\n        json_dict['episode_metadata_fields'] = episode_metadata_fields\n\nprint(json.dumps(json_dict, indent = 4))\n"
  },
  {
    "path": "contrib/librespot.service",
    "content": "[Unit]\nDescription=Librespot (an open source Spotify client)\nDocumentation=https://github.com/librespot-org/librespot\nDocumentation=https://github.com/librespot-org/librespot/wiki/Options\nWants=network.target sound.target\nAfter=network.target sound.target\n\n[Service]\nDynamicUser=yes\nSupplementaryGroups=audio\nRestart=always\nRestartSec=10\nExecStart=/usr/bin/librespot --name \"%p@%H\"\n\n[Install]\nWantedBy=multi-user.target\n"
  },
  {
    "path": "contrib/librespot.user.service",
    "content": "[Unit]\nDescription=Librespot (an open source Spotify client)\nDocumentation=https://github.com/librespot-org/librespot\nDocumentation=https://github.com/librespot-org/librespot/wiki/Options\nWants=network.target sound.target\nAfter=network.target sound.target\n\n[Service]\nRestart=always\nRestartSec=10\nExecStart=/usr/bin/librespot --name \"%u@%H\"\n\n[Install]\nWantedBy=default.target\n"
  },
  {
    "path": "core/Cargo.toml",
    "content": "[package]\nname = \"librespot-core\"\nversion = \"0.8.0\"\nrust-version.workspace = true\nauthors = [\"Paul Lietar <paul@lietar.net>\"]\nlicense.workspace = true\ndescription = \"The core functionality provided by librespot\"\nrepository.workspace = true\nedition.workspace = true\nbuild = \"build.rs\"\n\n[features]\n# Refer to the workspace Cargo.toml for the list of features\ndefault = [\"native-tls\"]\n\n# TLS backends (mutually exclusive - see oauth/src/lib.rs for compile-time checks)\n# Note: Validation is in oauth since it's compiled first in the dependency tree.\nnative-tls = [\n    \"dep:hyper-tls\",\n    \"hyper-proxy2/tls\",\n    \"librespot-oauth/native-tls\",\n    \"tokio-tungstenite/native-tls\",\n]\nrustls-tls-native-roots = [\n    \"__rustls\",\n    \"hyper-proxy2/rustls\",\n    \"hyper-rustls/native-tokio\",\n    \"librespot-oauth/rustls-tls-native-roots\",\n    \"tokio-tungstenite/rustls-tls-native-roots\",\n]\nrustls-tls-webpki-roots = [\n    \"__rustls\",\n    \"hyper-proxy2/rustls-webpki\",\n    \"hyper-rustls/webpki-tokio\",\n    \"librespot-oauth/rustls-tls-webpki-roots\",\n    \"tokio-tungstenite/rustls-tls-webpki-roots\",\n]\n\n# Internal features - these are not meant to be used by end users\n__rustls = []\n\n[dependencies]\nlibrespot-oauth = { version = \"0.8.0\", path = \"../oauth\", default-features = false }\nlibrespot-protocol = { version = \"0.8.0\", path = \"../protocol\", default-features = false }\n\naes = \"0.8\"\nbase64 = \"0.22\"\nbyteorder = \"1.5\"\nbytes = \"1\"\ndata-encoding = \"2.9\"\nflate2 = \"1.1\"\nform_urlencoded = \"1.2\"\nfutures-core = \"0.3\"\nfutures-util = { version = \"0.3\", default-features = false, features = [\n    \"alloc\",\n    \"bilock\",\n    \"unstable\",\n] }\ngovernor = { version = \"0.10\", default-features = false, features = [\"std\"] }\nhmac = \"0.12\"\nhttparse = \"1.10\"\nhttp = \"1.3\"\nhttp-body-util = \"0.1\"\nhyper = { version = \"1.6\", features = [\"http1\", \"http2\"] }\nhyper-proxy2 = { version = \"0.1\", default-features = false }\nhyper-rustls = { version = \"0.27\", default-features = false, features = [\n    \"http1\",\n    \"http2\",\n    \"ring\",\n], optional = true }\nhyper-tls = { version = \"0.6\", optional = true }\nhyper-util = { version = \"0.1\", default-features = false, features = [\n    \"client\",\n    \"http1\",\n    \"http2\",\n] }\nlog = \"0.4\"\nnonzero_ext = \"0.3\"\nnum-bigint = \"0.4\"\nnum-derive = \"0.4\"\nnum-integer = \"0.1\"\nnum-traits = \"0.2\"\npbkdf2 = { version = \"0.12\", default-features = false, features = [\"hmac\"] }\npin-project-lite = \"0.2\"\npriority-queue = \"2.5\"\nprotobuf = \"3.7\"\nprotobuf-json-mapping = \"3.7\"\nquick-xml = { version = \"0.38\", features = [\"serialize\"] }\nrand = { version = \"0.9\", default-features = false, features = [\"thread_rng\"] }\nrsa = \"0.9\"\nserde = { version = \"1.0\", features = [\"derive\"] }\nserde_json = \"1.0\"\nsha1 = { version = \"0.10\", features = [\"oid\"] }\nshannon = \"0.2\"\nsysinfo = { version = \"0.36\", default-features = false, features = [\"system\"] }\nthiserror = \"2\"\ntime = { version = \"0.3\", features = [\"formatting\", \"parsing\"] }\ntokio = { version = \"1\", features = [\n    \"io-util\",\n    \"macros\",\n    \"net\",\n    \"rt\",\n    \"sync\",\n    \"time\",\n] }\ntokio-stream = { version = \"0.1\", default-features = false }\ntokio-tungstenite = { version = \"0.28\", default-features = false }\ntokio-util = { version = \"0.7\", default-features = false }\nurl = \"2\"\nuuid = { version = \"1\", default-features = false, features = [\"v4\"] }\n\n[build-dependencies]\nrand = { version = \"0.9\", default-features = false, features = [\"thread_rng\"] }\nrand_distr = \"0.5\"\nvergen-gitcl = { version = \"1.0.8\", default-features = false, features = [\n    \"build\",\n] }\n# fix for https://github.com/rustyhorde/vergen/issues/478#issuecomment-3769340357\nvergen = \"=9.0.6\"\n\n[dev-dependencies]\ntokio = { version = \"1\", features = [\"macros\"] }\n\n[lints]\nworkspace = true\n"
  },
  {
    "path": "core/build.rs",
    "content": "use rand::Rng;\nuse rand_distr::Alphanumeric;\nuse vergen_gitcl::{BuildBuilder, Emitter, GitclBuilder};\n\nfn main() -> Result<(), Box<dyn std::error::Error>> {\n    let gitcl = GitclBuilder::default()\n        .sha(true) // outputs 'VERGEN_GIT_SHA', and sets the 'short' flag true\n        .commit_date(true) // outputs 'VERGEN_GIT_COMMIT_DATE'\n        .build()?;\n\n    let build = BuildBuilder::default()\n        .build_date(true) // outputs 'VERGEN_BUILD_DATE'\n        .build()?;\n\n    Emitter::default()\n        .add_instructions(&build)?\n        .add_instructions(&gitcl)?\n        .emit()\n        .expect(\"Unable to generate the cargo keys!\");\n    let build_id = match std::env::var(\"SOURCE_DATE_EPOCH\") {\n        Ok(val) => val,\n        Err(_) => rand::rng()\n            .sample_iter(Alphanumeric)\n            .take(8)\n            .map(char::from)\n            .collect(),\n    };\n\n    println!(\"cargo:rustc-env=LIBRESPOT_BUILD_ID={build_id}\");\n    Ok(())\n}\n"
  },
  {
    "path": "core/src/apresolve.rs",
    "content": "use std::collections::VecDeque;\n\nuse bytes::Bytes;\nuse hyper::{Method, Request};\nuse serde::Deserialize;\n\nuse crate::Error;\n\npub type SocketAddress = (String, u16);\n\n#[derive(Default)]\npub struct AccessPoints {\n    accesspoint: VecDeque<SocketAddress>,\n    dealer: VecDeque<SocketAddress>,\n    spclient: VecDeque<SocketAddress>,\n}\n\n#[derive(Deserialize, Default)]\npub struct ApResolveData {\n    accesspoint: Vec<String>,\n    dealer: Vec<String>,\n    spclient: Vec<String>,\n}\n\nimpl ApResolveData {\n    // These addresses probably do some geo-location based traffic management or at least DNS-based\n    // load balancing. They are known to fail when the normal resolvers are up, so that's why they\n    // should only be used as fallback.\n    fn fallback() -> Self {\n        Self {\n            accesspoint: vec![String::from(\"ap.spotify.com:443\")],\n            dealer: vec![String::from(\"dealer.spotify.com:443\")],\n            spclient: vec![String::from(\"spclient.wg.spotify.com:443\")],\n        }\n    }\n}\n\nimpl AccessPoints {\n    fn is_any_empty(&self) -> bool {\n        self.accesspoint.is_empty() || self.dealer.is_empty() || self.spclient.is_empty()\n    }\n}\n\ncomponent! {\n    ApResolver : ApResolverInner {\n        data: AccessPoints = AccessPoints::default(),\n    }\n}\n\nimpl ApResolver {\n    // return a port if a proxy URL and/or a proxy port was specified. This is useful even when\n    // there is no proxy, but firewalls only allow certain ports (e.g. 443 and not 4070).\n    pub fn port_config(&self) -> Option<u16> {\n        if self.session().config().proxy.is_some() || self.session().config().ap_port.is_some() {\n            Some(self.session().config().ap_port.unwrap_or(443))\n        } else {\n            None\n        }\n    }\n\n    fn process_ap_strings(&self, data: Vec<String>) -> VecDeque<SocketAddress> {\n        let filter_port = self.port_config();\n        data.into_iter()\n            .filter_map(|ap| {\n                let mut split = ap.rsplitn(2, ':');\n                let port = split.next()?;\n                let port: u16 = port.parse().ok()?;\n                let host = split.next()?.to_owned();\n                match filter_port {\n                    Some(filter_port) if filter_port != port => None,\n                    _ => Some((host, port)),\n                }\n            })\n            .collect()\n    }\n\n    fn parse_resolve_to_access_points(&self, resolve: ApResolveData) -> AccessPoints {\n        AccessPoints {\n            accesspoint: self.process_ap_strings(resolve.accesspoint),\n            dealer: self.process_ap_strings(resolve.dealer),\n            spclient: self.process_ap_strings(resolve.spclient),\n        }\n    }\n\n    pub async fn try_apresolve(&self) -> Result<ApResolveData, Error> {\n        let req = Request::builder()\n            .method(Method::GET)\n            .uri(\"https://apresolve.spotify.com/?type=accesspoint&type=dealer&type=spclient\")\n            .body(Bytes::new())?;\n\n        let body = self.session().http_client().request_body(req).await?;\n        let data: ApResolveData = serde_json::from_slice(body.as_ref())?;\n\n        Ok(data)\n    }\n\n    async fn apresolve(&self) {\n        let result = self.try_apresolve().await;\n\n        self.lock(|inner| {\n            let (data, error) = match result {\n                Ok(data) => (data, None),\n                Err(e) => (ApResolveData::default(), Some(e)),\n            };\n\n            inner.data = self.parse_resolve_to_access_points(data);\n\n            if inner.data.is_any_empty() {\n                warn!(\"Failed to resolve all access points, using fallbacks\");\n                if let Some(error) = error {\n                    warn!(\"Resolve access points error: {error}\");\n                }\n\n                let fallback = self.parse_resolve_to_access_points(ApResolveData::fallback());\n                inner.data.accesspoint.extend(fallback.accesspoint);\n                inner.data.dealer.extend(fallback.dealer);\n                inner.data.spclient.extend(fallback.spclient);\n            }\n        })\n    }\n\n    fn is_any_empty(&self) -> bool {\n        self.lock(|inner| inner.data.is_any_empty())\n    }\n\n    pub async fn resolve(&self, endpoint: &str) -> Result<SocketAddress, Error> {\n        if self.is_any_empty() {\n            self.apresolve().await;\n        }\n\n        self.lock(|inner| {\n            let access_point = match endpoint {\n                // take the first position instead of the last with `pop`, because Spotify returns\n                // access points with ports 4070, 443 and 80 in order of preference from highest\n                // to lowest.\n                \"accesspoint\" => inner.data.accesspoint.pop_front(),\n                \"dealer\" => inner.data.dealer.pop_front(),\n                \"spclient\" => inner.data.spclient.pop_front(),\n                _ => {\n                    return Err(Error::unimplemented(format!(\n                        \"No implementation to resolve access point {endpoint}\"\n                    )));\n                }\n            };\n\n            let access_point = access_point.ok_or_else(|| {\n                Error::unavailable(format!(\"No access point available for endpoint {endpoint}\"))\n            })?;\n\n            Ok(access_point)\n        })\n    }\n}\n"
  },
  {
    "path": "core/src/audio_key.rs",
    "content": "use std::{collections::HashMap, io::Write, time::Duration};\n\nuse byteorder::{BigEndian, ByteOrder, WriteBytesExt};\nuse bytes::Bytes;\nuse thiserror::Error;\nuse tokio::sync::oneshot;\n\nuse crate::{Error, FileId, SpotifyId, packet::PacketType, util::SeqGenerator};\n\n#[derive(Debug, Hash, PartialEq, Eq, Copy, Clone)]\npub struct AudioKey(pub [u8; 16]);\n\n#[derive(Debug, Error)]\npub enum AudioKeyError {\n    #[error(\"audio key error\")]\n    AesKey,\n    #[error(\"other end of channel disconnected\")]\n    Channel,\n    #[error(\"unexpected packet type {0}\")]\n    Packet(u8),\n    #[error(\"sequence {0} not pending\")]\n    Sequence(u32),\n    #[error(\"audio key response timeout\")]\n    Timeout,\n}\n\nimpl From<AudioKeyError> for Error {\n    fn from(err: AudioKeyError) -> Self {\n        match err {\n            AudioKeyError::AesKey => Error::unavailable(err),\n            AudioKeyError::Channel => Error::aborted(err),\n            AudioKeyError::Sequence(_) => Error::aborted(err),\n            AudioKeyError::Packet(_) => Error::unimplemented(err),\n            AudioKeyError::Timeout => Error::aborted(err),\n        }\n    }\n}\n\ncomponent! {\n    AudioKeyManager : AudioKeyManagerInner {\n        sequence: SeqGenerator<u32> = SeqGenerator::new(0),\n        pending: HashMap<u32, oneshot::Sender<Result<AudioKey, Error>>> = HashMap::new(),\n    }\n}\n\nimpl AudioKeyManager {\n    pub(crate) fn dispatch(&self, cmd: PacketType, mut data: Bytes) -> Result<(), Error> {\n        let seq = BigEndian::read_u32(data.split_to(4).as_ref());\n\n        let sender = self\n            .lock(|inner| inner.pending.remove(&seq))\n            .ok_or(AudioKeyError::Sequence(seq))?;\n\n        match cmd {\n            PacketType::AesKey => {\n                let mut key = [0u8; 16];\n                key.copy_from_slice(data.as_ref());\n                sender\n                    .send(Ok(AudioKey(key)))\n                    .map_err(|_| AudioKeyError::Channel)?\n            }\n            PacketType::AesKeyError => {\n                error!(\n                    \"error audio key {:x} {:x}\",\n                    data.as_ref()[0],\n                    data.as_ref()[1]\n                );\n                sender\n                    .send(Err(AudioKeyError::AesKey.into()))\n                    .map_err(|_| AudioKeyError::Channel)?\n            }\n            _ => {\n                trace!(\"Did not expect {cmd:?} AES key packet with data {data:#?}\");\n                return Err(AudioKeyError::Packet(cmd as u8).into());\n            }\n        }\n\n        Ok(())\n    }\n\n    pub async fn request(&self, track: SpotifyId, file: FileId) -> Result<AudioKey, Error> {\n        let (tx, rx) = oneshot::channel();\n\n        let seq = self.lock(move |inner| {\n            let seq = inner.sequence.get();\n            inner.pending.insert(seq, tx);\n            seq\n        });\n\n        self.send_key_request(seq, track, file)?;\n        const KEY_RESPONSE_TIMEOUT: Duration = Duration::from_millis(1500);\n        match tokio::time::timeout(KEY_RESPONSE_TIMEOUT, rx).await {\n            Err(_) => {\n                error!(\"Audio key response timeout\");\n                Err(AudioKeyError::Timeout.into())\n            }\n            Ok(k) => k?,\n        }\n    }\n\n    fn send_key_request(&self, seq: u32, track: SpotifyId, file: FileId) -> Result<(), Error> {\n        let mut data: Vec<u8> = Vec::new();\n        data.write_all(&file.0)?;\n        data.write_all(&track.to_raw())?;\n        data.write_u32::<BigEndian>(seq)?;\n        data.write_u16::<BigEndian>(0x0000)?;\n\n        self.session().send_packet(PacketType::RequestKey, data)\n    }\n}\n"
  },
  {
    "path": "core/src/authentication.rs",
    "content": "use std::io::{self, Read};\n\nuse aes::Aes192;\nuse base64::engine::Engine as _;\nuse base64::engine::general_purpose::STANDARD as BASE64;\nuse byteorder::{BigEndian, ByteOrder};\nuse pbkdf2::pbkdf2_hmac;\nuse protobuf::Enum;\nuse serde::{Deserialize, Serialize};\nuse sha1::{Digest, Sha1};\nuse thiserror::Error;\n\nuse crate::{Error, protocol::authentication::AuthenticationType};\n\n#[derive(Debug, Error)]\npub enum AuthenticationError {\n    #[error(\"unknown authentication type {0}\")]\n    AuthType(u32),\n    #[error(\"invalid key\")]\n    Key,\n}\n\nimpl From<AuthenticationError> for Error {\n    fn from(err: AuthenticationError) -> Self {\n        Error::invalid_argument(err)\n    }\n}\n\n/// The credentials are used to log into the Spotify API.\n#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]\npub struct Credentials {\n    pub username: Option<String>,\n\n    #[serde(serialize_with = \"serialize_protobuf_enum\")]\n    #[serde(deserialize_with = \"deserialize_protobuf_enum\")]\n    pub auth_type: AuthenticationType,\n\n    #[serde(alias = \"encoded_auth_blob\")]\n    #[serde(serialize_with = \"serialize_base64\")]\n    #[serde(deserialize_with = \"deserialize_base64\")]\n    pub auth_data: Vec<u8>,\n}\n\nimpl Credentials {\n    /// Intialize these credentials from a username and a password.\n    ///\n    /// ### Example\n    /// ```rust\n    /// use librespot_core::authentication::Credentials;\n    ///\n    /// let creds = Credentials::with_password(\"my account\", \"my password\");\n    /// ```\n    pub fn with_password(username: impl Into<String>, password: impl Into<String>) -> Self {\n        Self {\n            username: Some(username.into()),\n            auth_type: AuthenticationType::AUTHENTICATION_USER_PASS,\n            auth_data: password.into().into_bytes(),\n        }\n    }\n\n    pub fn with_access_token(token: impl Into<String>) -> Self {\n        Self {\n            username: None,\n            auth_type: AuthenticationType::AUTHENTICATION_SPOTIFY_TOKEN,\n            auth_data: token.into().into_bytes(),\n        }\n    }\n\n    #[expect(deprecated)]\n    pub fn with_blob(\n        username: impl Into<String>,\n        encrypted_blob: impl AsRef<[u8]>,\n        device_id: impl AsRef<[u8]>,\n    ) -> Result<Self, Error> {\n        fn read_u8<R: Read>(stream: &mut R) -> io::Result<u8> {\n            let mut data = [0u8];\n            stream.read_exact(&mut data)?;\n            Ok(data[0])\n        }\n\n        fn read_int<R: Read>(stream: &mut R) -> io::Result<u32> {\n            let lo = read_u8(stream)? as u32;\n            if lo & 0x80 == 0 {\n                return Ok(lo);\n            }\n\n            let hi = read_u8(stream)? as u32;\n            Ok(lo & 0x7f | (hi << 7))\n        }\n\n        fn read_bytes<R: Read>(stream: &mut R) -> io::Result<Vec<u8>> {\n            let length = read_int(stream)?;\n            let mut data = vec![0u8; length as usize];\n            stream.read_exact(&mut data)?;\n\n            Ok(data)\n        }\n\n        let username = username.into();\n\n        let secret = Sha1::digest(device_id.as_ref());\n\n        let key = {\n            let mut key = [0u8; 24];\n            if key.len() < 20 {\n                return Err(AuthenticationError::Key.into());\n            }\n\n            pbkdf2_hmac::<Sha1>(&secret, username.as_bytes(), 0x100, &mut key[0..20]);\n\n            let hash = &Sha1::digest(&key[..20]);\n            key[..20].copy_from_slice(hash);\n            BigEndian::write_u32(&mut key[20..], 20);\n            key\n        };\n\n        // decrypt data using ECB mode without padding\n        let blob = {\n            use aes::cipher::generic_array::GenericArray;\n            use aes::cipher::{BlockDecrypt, BlockSizeUser, KeyInit};\n\n            let mut data = BASE64.decode(encrypted_blob)?;\n            let cipher = Aes192::new(GenericArray::from_slice(&key));\n            let block_size = Aes192::block_size();\n\n            for chunk in data.chunks_exact_mut(block_size) {\n                cipher.decrypt_block(GenericArray::from_mut_slice(chunk));\n            }\n\n            let l = data.len();\n            for i in 0..l - 0x10 {\n                data[l - i - 1] ^= data[l - i - 0x11];\n            }\n\n            data\n        };\n\n        let mut cursor = io::Cursor::new(blob.as_slice());\n        read_u8(&mut cursor)?;\n        read_bytes(&mut cursor)?;\n        read_u8(&mut cursor)?;\n        let auth_type = read_int(&mut cursor)?;\n        let auth_type = AuthenticationType::from_i32(auth_type as i32)\n            .ok_or(AuthenticationError::AuthType(auth_type))?;\n        read_u8(&mut cursor)?;\n        let auth_data = read_bytes(&mut cursor)?;\n\n        Ok(Self {\n            username: Some(username),\n            auth_type,\n            auth_data,\n        })\n    }\n}\n\nfn serialize_protobuf_enum<T, S>(v: &T, ser: S) -> Result<S::Ok, S::Error>\nwhere\n    T: Enum,\n    S: serde::Serializer,\n{\n    serde::Serialize::serialize(&v.value(), ser)\n}\n\nfn deserialize_protobuf_enum<'de, T, D>(de: D) -> Result<T, D::Error>\nwhere\n    T: Enum,\n    D: serde::Deserializer<'de>,\n{\n    let v: i32 = serde::Deserialize::deserialize(de)?;\n    T::from_i32(v).ok_or_else(|| serde::de::Error::custom(\"Invalid enum value\"))\n}\n\nfn serialize_base64<T, S>(v: &T, ser: S) -> Result<S::Ok, S::Error>\nwhere\n    T: AsRef<[u8]>,\n    S: serde::Serializer,\n{\n    serde::Serialize::serialize(&BASE64.encode(v.as_ref()), ser)\n}\n\nfn deserialize_base64<'de, D>(de: D) -> Result<Vec<u8>, D::Error>\nwhere\n    D: serde::Deserializer<'de>,\n{\n    let v: String = serde::Deserialize::deserialize(de)?;\n    BASE64\n        .decode(v)\n        .map_err(|e| serde::de::Error::custom(e.to_string()))\n}\n"
  },
  {
    "path": "core/src/cache.rs",
    "content": "#[cfg(unix)]\nuse std::os::unix::fs::{MetadataExt, OpenOptionsExt};\nuse std::{\n    cmp::Reverse,\n    collections::HashMap,\n    fs::{self, File},\n    io::{self, Read, Write},\n    path::{Path, PathBuf},\n    sync::{Arc, Mutex},\n    time::SystemTime,\n};\n\nuse priority_queue::PriorityQueue;\nuse thiserror::Error;\n\nuse crate::{Error, FileId, authentication::Credentials, error::ErrorKind};\n\nconst CACHE_LIMITER_POISON_MSG: &str = \"cache limiter mutex should not be poisoned\";\n\n#[derive(Debug, Error)]\npub enum CacheError {\n    #[error(\"audio cache location is not configured\")]\n    Path,\n}\n\nimpl From<CacheError> for Error {\n    fn from(err: CacheError) -> Self {\n        Error::failed_precondition(err)\n    }\n}\n\n/// Some kind of data structure that holds some paths, the size of these files and a timestamp.\n/// It keeps track of the file sizes and is able to pop the path with the oldest timestamp if\n/// a given limit is exceeded.\nstruct SizeLimiter {\n    queue: PriorityQueue<PathBuf, Reverse<SystemTime>>,\n    sizes: HashMap<PathBuf, u64>,\n    size_limit: u64,\n    in_use: u64,\n}\n\nimpl SizeLimiter {\n    /// Creates a new instance with the given size limit.\n    fn new(limit: u64) -> Self {\n        Self {\n            queue: PriorityQueue::new(),\n            sizes: HashMap::new(),\n            size_limit: limit,\n            in_use: 0,\n        }\n    }\n\n    /// Adds an entry to this data structure.\n    ///\n    /// If this file is already contained, it will be updated accordingly.\n    fn add(&mut self, file: &Path, size: u64, accessed: SystemTime) {\n        self.in_use += size;\n        self.queue.push(file.to_owned(), Reverse(accessed));\n        if let Some(old_size) = self.sizes.insert(file.to_owned(), size) {\n            // It's important that decreasing happens after\n            // increasing the size, to prevent an overflow.\n            self.in_use -= old_size;\n        }\n    }\n\n    /// Returns true if the limit is exceeded.\n    fn exceeds_limit(&self) -> bool {\n        self.in_use > self.size_limit\n    }\n\n    /// Returns the least recently accessed file if the size of the cache exceeds\n    /// the limit.\n    ///\n    /// The entry is removed from the data structure, but the caller is responsible\n    /// to delete the file in the file system.\n    fn pop(&mut self) -> Option<PathBuf> {\n        if self.exceeds_limit() {\n            if let Some((next, _)) = self.queue.pop() {\n                if let Some(size) = self.sizes.remove(&next) {\n                    self.in_use -= size;\n                } else {\n                    error!(\"`queue` and `sizes` should have the same keys.\");\n                }\n                Some(next)\n            } else {\n                error!(\"in_use was > 0, so the queue should have contained an item.\");\n                None\n            }\n        } else {\n            None\n        }\n    }\n\n    /// Updates the timestamp of an existing element. Returns `true` if the item did exist.\n    fn update(&mut self, file: &Path, access_time: SystemTime) -> bool {\n        self.queue\n            .change_priority(file, Reverse(access_time))\n            .is_some()\n    }\n\n    /// Removes an element with the specified path. Returns `true` if the item did exist.\n    fn remove(&mut self, file: &Path) -> bool {\n        if self.queue.remove(file).is_none() {\n            return false;\n        }\n\n        if let Some(size) = self.sizes.remove(file) {\n            self.in_use -= size;\n        } else {\n            error!(\"`queue` and `sizes` should have the same keys.\");\n        }\n\n        true\n    }\n}\n\nstruct FsSizeLimiter {\n    limiter: Mutex<SizeLimiter>,\n}\n\nimpl FsSizeLimiter {\n    /// Returns access time and file size of a given path.\n    fn get_metadata(file: &Path) -> io::Result<(SystemTime, u64)> {\n        let metadata = file.metadata()?;\n\n        // The first of the following timestamps which is available will be chosen as access time:\n        // 1. Access time\n        // 2. Modification time\n        // 3. Creation time\n        // 4. Current time\n        let access_time = metadata\n            .accessed()\n            .or_else(|_| metadata.modified())\n            .or_else(|_| metadata.created())\n            .unwrap_or_else(|_| SystemTime::now());\n\n        let size = metadata.len();\n\n        Ok((access_time, size))\n    }\n\n    /// Recursively search a directory for files and add them to the `limiter` struct.\n    fn init_dir(limiter: &mut SizeLimiter, path: &Path) {\n        let list_dir = match fs::read_dir(path) {\n            Ok(list_dir) => list_dir,\n            Err(e) => {\n                warn!(\"Could not read directory {path:?} in cache dir: {e}\");\n                return;\n            }\n        };\n\n        for entry in list_dir {\n            let entry = match entry {\n                Ok(entry) => entry,\n                Err(e) => {\n                    warn!(\"Could not directory {path:?} in cache dir: {e}\");\n                    return;\n                }\n            };\n\n            match entry.file_type() {\n                Ok(file_type) if file_type.is_dir() || file_type.is_symlink() => {\n                    Self::init_dir(limiter, &entry.path())\n                }\n                Ok(file_type) if file_type.is_file() => {\n                    let path = entry.path();\n                    match Self::get_metadata(&path) {\n                        Ok((access_time, size)) => {\n                            limiter.add(&path, size, access_time);\n                        }\n                        Err(e) => {\n                            warn!(\"Could not read file {path:?} in cache dir: {e}\")\n                        }\n                    }\n                }\n                Ok(ft) => {\n                    warn!(\n                        \"File {:?} in cache dir has unsupported type {:?}\",\n                        entry.path(),\n                        ft\n                    )\n                }\n                Err(e) => {\n                    warn!(\n                        \"Could not get type of file {:?} in cache dir: {}\",\n                        entry.path(),\n                        e\n                    )\n                }\n            };\n        }\n    }\n\n    fn add(&self, file: &Path, size: u64) {\n        self.limiter\n            .lock()\n            .expect(CACHE_LIMITER_POISON_MSG)\n            .add(file, size, SystemTime::now())\n    }\n\n    fn touch(&self, file: &Path) -> bool {\n        self.limiter\n            .lock()\n            .expect(CACHE_LIMITER_POISON_MSG)\n            .update(file, SystemTime::now())\n    }\n\n    fn remove(&self, file: &Path) -> bool {\n        self.limiter\n            .lock()\n            .expect(CACHE_LIMITER_POISON_MSG)\n            .remove(file)\n    }\n\n    fn prune_internal<F: FnMut() -> Option<PathBuf>>(mut pop: F) -> Result<(), Error> {\n        let mut first = true;\n        let mut count = 0;\n        let mut last_error = None;\n\n        while let Some(file) = pop() {\n            if first {\n                debug!(\"Cache dir exceeds limit, removing least recently used files.\");\n                first = false;\n            }\n\n            let res = fs::remove_file(&file);\n            if let Err(e) = res {\n                warn!(\"Could not remove file {file:?} from cache dir: {e}\");\n                last_error = Some(e);\n            } else {\n                count += 1;\n            }\n        }\n\n        if count > 0 {\n            info!(\"Removed {count} cache files.\");\n        }\n\n        if let Some(err) = last_error {\n            Err(err.into())\n        } else {\n            Ok(())\n        }\n    }\n\n    fn prune(&self) -> Result<(), Error> {\n        Self::prune_internal(|| self.limiter.lock().expect(CACHE_LIMITER_POISON_MSG).pop())\n    }\n\n    fn new(path: &Path, limit: u64) -> Result<Self, Error> {\n        let mut limiter = SizeLimiter::new(limit);\n\n        Self::init_dir(&mut limiter, path);\n        Self::prune_internal(|| limiter.pop())?;\n\n        Ok(Self {\n            limiter: Mutex::new(limiter),\n        })\n    }\n}\n\n/// A cache for volume, credentials and audio files.\n#[derive(Clone)]\npub struct Cache {\n    credentials_location: Option<PathBuf>,\n    volume_location: Option<PathBuf>,\n    audio_location: Option<PathBuf>,\n    size_limiter: Option<Arc<FsSizeLimiter>>,\n}\n\nimpl Cache {\n    pub fn new<P: AsRef<Path>>(\n        credentials_path: Option<P>,\n        volume_path: Option<P>,\n        audio_path: Option<P>,\n        size_limit: Option<u64>,\n    ) -> Result<Self, Error> {\n        let mut size_limiter = None;\n\n        if let Some(location) = &credentials_path {\n            fs::create_dir_all(location)?;\n        }\n\n        let credentials_location = credentials_path\n            .as_ref()\n            .map(|p| p.as_ref().join(\"credentials.json\"));\n\n        if let Some(location) = &volume_path {\n            fs::create_dir_all(location)?;\n        }\n\n        let volume_location = volume_path.as_ref().map(|p| p.as_ref().join(\"volume\"));\n\n        if let Some(location) = &audio_path {\n            fs::create_dir_all(location)?;\n\n            if let Some(limit) = size_limit {\n                let limiter = FsSizeLimiter::new(location.as_ref(), limit)?;\n                size_limiter = Some(Arc::new(limiter));\n            }\n        }\n\n        let audio_location = audio_path.map(|p| p.as_ref().to_owned());\n\n        let cache = Cache {\n            credentials_location,\n            volume_location,\n            audio_location,\n            size_limiter,\n        };\n\n        Ok(cache)\n    }\n\n    pub fn credentials(&self) -> Option<Credentials> {\n        let location = self.credentials_location.as_ref()?;\n\n        // This closure is just convencience to enable the question mark operator\n        let read = || -> Result<Credentials, Error> {\n            let file = File::open(location)?;\n            #[cfg(unix)]\n            if file.metadata()?.mode() & 0o004 != 0 {\n                warn!(\n                    \"credential file {location:?} is currently world readable, consider using  chmod 600 {location:?} to fix this\"\n                )\n            }\n            Ok(serde_json::from_reader(file)?)\n        };\n\n        match read() {\n            Ok(c) => Some(c),\n            Err(e) => {\n                // If the file did not exist, the file was probably not written\n                // before. Otherwise, log the error.\n                if e.kind != ErrorKind::NotFound {\n                    warn!(\"Error reading credentials from cache: {e}\");\n                }\n                None\n            }\n        }\n    }\n\n    pub fn save_credentials(&self, cred: &Credentials) {\n        if let Some(location) = &self.credentials_location {\n            let mut file = File::options();\n            #[cfg(unix)]\n            let file = file.mode(0o600);\n            let result = file\n                .create(true)\n                .write(true)\n                .truncate(true)\n                .open(location)\n                .and_then(|file| Ok(serde_json::to_writer(file, cred)?));\n\n            if let Err(e) = result {\n                warn!(\"Cannot save credentials to cache: {e}\")\n            }\n        }\n    }\n\n    pub fn volume(&self) -> Option<u16> {\n        let location = self.volume_location.as_ref()?;\n\n        let read = || -> Result<u16, Error> {\n            let mut file = File::open(location)?;\n            let mut contents = String::new();\n            file.read_to_string(&mut contents)?;\n            Ok(contents.parse()?)\n        };\n\n        match read() {\n            Ok(v) => Some(v),\n            Err(e) => {\n                if e.kind != ErrorKind::NotFound {\n                    warn!(\"Error reading volume from cache: {e}\");\n                }\n                None\n            }\n        }\n    }\n\n    pub fn save_volume(&self, volume: u16) {\n        if let Some(ref location) = self.volume_location {\n            let result = File::create(location).and_then(|mut file| write!(file, \"{volume}\"));\n            if let Err(e) = result {\n                warn!(\"Cannot save volume to cache: {e}\");\n            }\n        }\n    }\n\n    pub fn file_path(&self, file: FileId) -> Option<PathBuf> {\n        self.audio_location.as_ref().map(|location| {\n            let name = file.to_base16();\n            let mut path = location.join(&name[0..2]);\n            path.push(&name[2..]);\n            path\n        })\n    }\n\n    pub fn file(&self, file: FileId) -> Option<File> {\n        let path = self.file_path(file)?;\n        match File::open(&path) {\n            Ok(file) => {\n                if let Some(limiter) = self.size_limiter.as_deref() {\n                    if !limiter.touch(&path) {\n                        error!(\"limiter could not touch {path:?}\");\n                    }\n                }\n                Some(file)\n            }\n            Err(e) => {\n                if e.kind() != io::ErrorKind::NotFound {\n                    warn!(\"Error reading file from cache: {e}\")\n                }\n                None\n            }\n        }\n    }\n\n    pub fn save_file<F: Read>(&self, file: FileId, contents: &mut F) -> Result<PathBuf, Error> {\n        if let Some(path) = self.file_path(file) {\n            if let Some(parent) = path.parent() {\n                if let Ok(size) = fs::create_dir_all(parent)\n                    .and_then(|_| File::create(&path))\n                    .and_then(|mut file| io::copy(contents, &mut file))\n                {\n                    if let Some(limiter) = self.size_limiter.as_deref() {\n                        limiter.add(&path, size);\n                        limiter.prune()?;\n                    }\n                    return Ok(path);\n                }\n            }\n        }\n        Err(CacheError::Path.into())\n    }\n\n    pub fn remove_file(&self, file: FileId) -> Result<(), Error> {\n        let path = self.file_path(file).ok_or(CacheError::Path)?;\n\n        fs::remove_file(&path)?;\n        if let Some(limiter) = self.size_limiter.as_deref() {\n            limiter.remove(&path);\n        }\n\n        Ok(())\n    }\n}\n\n#[cfg(test)]\nmod test {\n    use super::*;\n    use std::time::Duration;\n\n    fn ordered_time(v: u64) -> SystemTime {\n        SystemTime::UNIX_EPOCH + Duration::from_secs(v)\n    }\n\n    #[test]\n    fn test_size_limiter() {\n        let mut limiter = SizeLimiter::new(1000);\n\n        limiter.add(Path::new(\"a\"), 500, ordered_time(2));\n        limiter.add(Path::new(\"b\"), 500, ordered_time(1));\n\n        // b (500) -> a (500)  => sum: 1000 <= 1000\n        assert!(!limiter.exceeds_limit());\n        assert_eq!(limiter.pop(), None);\n\n        limiter.add(Path::new(\"c\"), 1000, ordered_time(3));\n\n        // b (500) -> a (500) -> c (1000)  => sum: 2000 > 1000\n        assert!(limiter.exceeds_limit());\n        assert_eq!(limiter.pop().as_deref(), Some(Path::new(\"b\")));\n        // a (500) -> c (1000)  => sum: 1500 > 1000\n        assert_eq!(limiter.pop().as_deref(), Some(Path::new(\"a\")));\n        // c (1000)   => sum: 1000 <= 1000\n        assert_eq!(limiter.pop().as_deref(), None);\n\n        limiter.add(Path::new(\"d\"), 5, ordered_time(2));\n        // d (5) -> c (1000) => sum: 1005 > 1000\n        assert_eq!(limiter.pop().as_deref(), Some(Path::new(\"d\")));\n        // c (1000)   => sum: 1000 <= 1000\n        assert_eq!(limiter.pop().as_deref(), None);\n\n        // Test updating\n\n        limiter.add(Path::new(\"e\"), 500, ordered_time(3));\n        //  c (1000) -> e (500)  => sum: 1500 > 1000\n        assert!(limiter.update(Path::new(\"c\"), ordered_time(4)));\n        // e (500) -> c (1000)  => sum: 1500 > 1000\n        assert_eq!(limiter.pop().as_deref(), Some(Path::new(\"e\")));\n        // c (1000)  => sum: 1000 <= 1000\n\n        // Test removing\n        limiter.add(Path::new(\"f\"), 500, ordered_time(2));\n        assert!(limiter.remove(Path::new(\"c\")));\n        assert!(!limiter.exceeds_limit());\n    }\n}\n"
  },
  {
    "path": "core/src/cdn_url.rs",
    "content": "use std::ops::{Deref, DerefMut};\n\nuse protobuf::Message;\nuse thiserror::Error;\nuse time::Duration;\nuse url::Url;\n\nuse super::{Error, FileId, Session, date::Date};\n\nuse librespot_protocol as protocol;\nuse protocol::storage_resolve::StorageResolveResponse as CdnUrlMessage;\nuse protocol::storage_resolve::storage_resolve_response::Result as StorageResolveResponse_Result;\n\n#[derive(Debug, Clone)]\npub struct MaybeExpiringUrl(pub String, pub Option<Date>);\n\nconst CDN_URL_EXPIRY_MARGIN: Duration = Duration::seconds(5 * 60);\n\n#[derive(Debug, Clone)]\npub struct MaybeExpiringUrls(pub Vec<MaybeExpiringUrl>);\n\nimpl Deref for MaybeExpiringUrls {\n    type Target = Vec<MaybeExpiringUrl>;\n    fn deref(&self) -> &Self::Target {\n        &self.0\n    }\n}\n\nimpl DerefMut for MaybeExpiringUrls {\n    fn deref_mut(&mut self) -> &mut Self::Target {\n        &mut self.0\n    }\n}\n\n#[derive(Debug, Error)]\npub enum CdnUrlError {\n    #[error(\"all URLs expired\")]\n    Expired,\n    #[error(\"resolved storage is not for CDN\")]\n    Storage,\n    #[error(\"no URLs resolved\")]\n    Unresolved,\n}\n\nimpl From<CdnUrlError> for Error {\n    fn from(err: CdnUrlError) -> Self {\n        match err {\n            CdnUrlError::Expired => Error::deadline_exceeded(err),\n            CdnUrlError::Storage | CdnUrlError::Unresolved => Error::unavailable(err),\n        }\n    }\n}\n\n#[derive(Debug, Clone)]\npub struct CdnUrl {\n    pub file_id: FileId,\n    urls: MaybeExpiringUrls,\n}\n\nimpl CdnUrl {\n    pub fn new(file_id: FileId) -> Self {\n        Self {\n            file_id,\n            urls: MaybeExpiringUrls(Vec::new()),\n        }\n    }\n\n    pub async fn resolve_audio(&self, session: &Session) -> Result<Self, Error> {\n        let file_id = self.file_id;\n        let response = session.spclient().get_audio_storage(&file_id).await?;\n        let msg = CdnUrlMessage::parse_from_bytes(&response)?;\n        let urls = MaybeExpiringUrls::try_from(msg)?;\n\n        let cdn_url = Self { file_id, urls };\n\n        trace!(\"Resolved CDN storage: {cdn_url:#?}\");\n\n        Ok(cdn_url)\n    }\n\n    #[deprecated = \"This function only returns the first valid URL. Use try_get_urls instead, which allows for fallback logic.\"]\n    pub fn try_get_url(&self) -> Result<&str, Error> {\n        if self.urls.is_empty() {\n            return Err(CdnUrlError::Unresolved.into());\n        }\n\n        let now = Date::now_utc();\n        let url = self.urls.iter().find(|url| match url.1 {\n            Some(expiry) => now < expiry,\n            None => true,\n        });\n\n        if let Some(url) = url {\n            Ok(&url.0)\n        } else {\n            Err(CdnUrlError::Expired.into())\n        }\n    }\n\n    pub fn try_get_urls(&self) -> Result<Vec<&str>, Error> {\n        if self.urls.is_empty() {\n            return Err(CdnUrlError::Unresolved.into());\n        }\n\n        let now = Date::now_utc();\n        let urls: Vec<&str> = self\n            .urls\n            .iter()\n            .filter_map(|MaybeExpiringUrl(url, expiry)| match *expiry {\n                Some(expiry) => {\n                    if now < expiry {\n                        Some(url.as_str())\n                    } else {\n                        None\n                    }\n                }\n                None => Some(url.as_str()),\n            })\n            .collect();\n\n        if urls.is_empty() {\n            Err(CdnUrlError::Expired.into())\n        } else {\n            Ok(urls)\n        }\n    }\n}\n\nimpl TryFrom<CdnUrlMessage> for MaybeExpiringUrls {\n    type Error = crate::Error;\n    fn try_from(msg: CdnUrlMessage) -> Result<Self, Self::Error> {\n        if !matches!(\n            msg.result.enum_value_or_default(),\n            StorageResolveResponse_Result::CDN\n        ) {\n            return Err(CdnUrlError::Storage.into());\n        }\n\n        let is_expiring = !msg.fileid.is_empty();\n\n        let result = msg\n            .cdnurl\n            .iter()\n            .map(|cdn_url| {\n                let url = Url::parse(cdn_url)?;\n                let mut expiry: Option<Date> = None;\n\n                if is_expiring {\n                    let mut expiry_str: Option<String> = None;\n                    if let Some(token) = url\n                        .query_pairs()\n                        .into_iter()\n                        .find(|(key, _value)| key == \"verify\")\n                    {\n                        // https://audio-cf.spotifycdn.com/audio/844ecdb297a87ebfee4399f28892ef85d9ba725f?verify=1750549951-4R3I2w2q7OfNkR%2FGH8qH7xtIKUPlDxywBuADY%2BsvMeU%3D\n                        if let Some((expiry_str_candidate, _)) = token.1.split_once('-') {\n                            expiry_str = Some(expiry_str_candidate.to_string());\n                        }\n                    } else if let Some(token) = url\n                        .query_pairs()\n                        .into_iter()\n                        .find(|(key, _value)| key == \"__token__\")\n                    {\n                        //\"https://audio-ak-spotify-com.akamaized.net/audio/4712bc9e47f7feb4ee3450ef2bb545e1d83c3d54?__token__=exp=1688165560~hmac=4e661527574fab5793adb99cf04e1c2ce12294c71fe1d39ffbfabdcfe8ce3b41\",\n                        if let Some(mut start) = token.1.find(\"exp=\") {\n                            start += 4;\n                            if token.1.len() >= start {\n                                let slice = &token.1[start..];\n                                if let Some(end) = slice.find('~') {\n                                    // this is the only valid invariant for akamaized.net\n                                    expiry_str = Some(String::from(&slice[..end]));\n                                } else {\n                                    expiry_str = Some(String::from(slice));\n                                }\n                            }\n                        }\n                    } else if let Some(token) = url\n                        .query_pairs()\n                        .into_iter()\n                        .find(|(key, _value)| key == \"Expires\")\n                    {\n                        //\"https://audio-gm-off.spotifycdn.com/audio/4712bc9e47f7feb4ee3450ef2bb545e1d83c3d54?Expires=1688165560~FullPath~hmac=IIZA28qptl8cuGLq15-SjHKHtLoxzpy_6r_JpAU4MfM=\",\n                        if let Some(end) = token.1.find('~') {\n                            // this is the only valid invariant for spotifycdn.com\n                            let slice = &token.1[..end];\n                            expiry_str = Some(String::from(&slice[..end]));\n                        }\n                    } else if let Some(query) = url.query() {\n                        //\"https://audio4-fa.scdn.co/audio/4712bc9e47f7feb4ee3450ef2bb545e1d83c3d54?1688165560_0GKSyXjLaTW1BksFOyI4J7Tf9tZDbBUNNPu9Mt4mhH4=\",\n                        let mut items = query.split('_');\n                        if let Some(first) = items.next() {\n                            // this is the only valid invariant for scdn.co\n                            expiry_str = Some(String::from(first));\n                        }\n                    }\n\n                    if let Some(exp_str) = expiry_str {\n                        if let Ok(expiry_parsed) = exp_str.parse::<i64>() {\n                            if let Ok(expiry_at) = Date::from_timestamp_ms(expiry_parsed * 1_000) {\n                                let with_margin = expiry_at.saturating_sub(CDN_URL_EXPIRY_MARGIN);\n                                expiry = Some(Date::from(with_margin));\n                            }\n                        } else {\n                            warn!(\n                                \"Cannot parse CDN URL expiry timestamp '{exp_str}' from '{cdn_url}'\"\n                            );\n                        }\n                    } else {\n                        warn!(\"Unknown CDN URL format: {cdn_url}\");\n                    }\n                }\n                Ok(MaybeExpiringUrl(cdn_url.to_owned(), expiry))\n            })\n            .collect::<Result<Vec<MaybeExpiringUrl>, Error>>()?;\n\n        Ok(Self(result))\n    }\n}\n\n#[cfg(test)]\nmod test {\n    use super::*;\n\n    #[test]\n    fn test_maybe_expiring_urls() {\n        let timestamp = 1688165560;\n        let mut msg = CdnUrlMessage::new();\n        msg.result = StorageResolveResponse_Result::CDN.into();\n        msg.cdnurl = vec![\n            format!(\n                \"https://audio-cf.spotifycdn.com/audio/844ecdb297a87ebfee4399f28892ef85d9ba725f?verify={timestamp}-4R3I2w2q7OfNkR%2FGH8qH7xtIKUPlDxywBuADY%2BsvMeU%3D\"\n            ),\n            format!(\n                \"https://audio-ak-spotify-com.akamaized.net/audio/foo?__token__=exp={timestamp}~hmac=4e661527574fab5793adb99cf04e1c2ce12294c71fe1d39ffbfabdcfe8ce3b41\"\n            ),\n            format!(\n                \"https://audio-gm-off.spotifycdn.com/audio/foo?Expires={timestamp}~FullPath~hmac=IIZA28qptl8cuGLq15-SjHKHtLoxzpy_6r_JpAU4MfM=\"\n            ),\n            format!(\n                \"https://audio4-fa.scdn.co/audio/foo?{timestamp}_0GKSyXjLaTW1BksFOyI4J7Tf9tZDbBUNNPu9Mt4mhH4=\"\n            ),\n            \"https://audio4-fa.scdn.co/foo?baz\".to_string(),\n        ];\n        msg.fileid = vec![0];\n\n        let urls = MaybeExpiringUrls::try_from(msg).expect(\"valid urls\");\n        assert_eq!(urls.len(), 5);\n        assert!(urls[0].1.is_some());\n        assert!(urls[1].1.is_some());\n        assert!(urls[2].1.is_some());\n        assert!(urls[3].1.is_some());\n        assert!(urls[4].1.is_none());\n        let timestamp_margin = Duration::seconds(timestamp) - CDN_URL_EXPIRY_MARGIN;\n        assert_eq!(\n            urls[0].1.unwrap().as_timestamp_ms() as i128,\n            timestamp_margin.whole_milliseconds()\n        );\n    }\n}\n"
  },
  {
    "path": "core/src/channel.rs",
    "content": "use std::{\n    collections::HashMap,\n    fmt,\n    pin::Pin,\n    task::{Context, Poll},\n    time::{Duration, Instant},\n};\n\nuse byteorder::{BigEndian, ByteOrder};\nuse bytes::Bytes;\nuse futures_core::Stream;\nuse futures_util::{StreamExt, lock::BiLock, ready};\nuse num_traits::FromPrimitive;\nuse thiserror::Error;\nuse tokio::sync::mpsc;\n\nuse crate::{Error, packet::PacketType, util::SeqGenerator};\n\ncomponent! {\n    ChannelManager : ChannelManagerInner {\n        sequence: SeqGenerator<u16> = SeqGenerator::new(0),\n        channels: HashMap<u16, mpsc::UnboundedSender<(u8, Bytes)>> = HashMap::new(),\n        download_rate_estimate: usize = 0,\n        download_measurement_start: Option<Instant> = None,\n        download_measurement_bytes: usize = 0,\n        invalid: bool = false,\n    }\n}\n\nconst ONE_SECOND: Duration = Duration::from_secs(1);\n\n#[derive(Debug, Error, Hash, PartialEq, Eq, Copy, Clone)]\npub struct ChannelError;\n\nimpl From<ChannelError> for Error {\n    fn from(err: ChannelError) -> Self {\n        Error::aborted(err)\n    }\n}\n\nimpl fmt::Display for ChannelError {\n    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n        write!(f, \"channel error\")\n    }\n}\n\npub struct Channel {\n    receiver: mpsc::UnboundedReceiver<(u8, Bytes)>,\n    state: ChannelState,\n}\n\npub struct ChannelHeaders(BiLock<Channel>);\npub struct ChannelData(BiLock<Channel>);\n\npub enum ChannelEvent {\n    Header(u8, Vec<u8>),\n    Data(Bytes),\n}\n\n#[derive(Clone)]\nenum ChannelState {\n    Header(Bytes),\n    Data,\n    Closed,\n}\n\nimpl ChannelManager {\n    pub fn allocate(&self) -> (u16, Channel) {\n        let (tx, rx) = mpsc::unbounded_channel();\n\n        let seq = self.lock(|inner| {\n            let seq = inner.sequence.get();\n            if !inner.invalid {\n                inner.channels.insert(seq, tx);\n            }\n            seq\n        });\n\n        let channel = Channel {\n            receiver: rx,\n            state: ChannelState::Header(Bytes::new()),\n        };\n\n        (seq, channel)\n    }\n\n    pub(crate) fn dispatch(&self, cmd: PacketType, mut data: Bytes) -> Result<(), Error> {\n        use std::collections::hash_map::Entry;\n\n        let id: u16 = BigEndian::read_u16(data.split_to(2).as_ref());\n\n        self.lock(|inner| {\n            let current_time = Instant::now();\n            if let Some(download_measurement_start) = inner.download_measurement_start {\n                if (current_time - download_measurement_start) > ONE_SECOND {\n                    inner.download_rate_estimate = ONE_SECOND.as_millis() as usize\n                        * inner.download_measurement_bytes\n                        / (current_time - download_measurement_start).as_millis() as usize;\n                    inner.download_measurement_start = Some(current_time);\n                    inner.download_measurement_bytes = 0;\n                }\n            } else {\n                inner.download_measurement_start = Some(current_time);\n            }\n\n            inner.download_measurement_bytes += data.len();\n\n            if let Entry::Occupied(entry) = inner.channels.entry(id) {\n                entry\n                    .get()\n                    .send((cmd as u8, data))\n                    .map_err(|_| ChannelError)?;\n            }\n\n            Ok(())\n        })\n    }\n\n    pub fn get_download_rate_estimate(&self) -> usize {\n        self.lock(|inner| inner.download_rate_estimate)\n    }\n\n    pub(crate) fn shutdown(&self) {\n        self.lock(|inner| {\n            inner.invalid = true;\n            // destroy the sending halves of the channels to signal everyone who is waiting for something.\n            inner.channels.clear();\n        });\n    }\n}\n\nimpl Channel {\n    fn recv_packet(&mut self, cx: &mut Context<'_>) -> Poll<Result<Bytes, ChannelError>> {\n        let (cmd, packet) = ready!(self.receiver.poll_recv(cx)).ok_or(ChannelError)?;\n\n        let packet_type = FromPrimitive::from_u8(cmd);\n        if let Some(PacketType::ChannelError) = packet_type {\n            let code = BigEndian::read_u16(&packet.as_ref()[..2]);\n            error!(\"channel error: {} {}\", packet.len(), code);\n\n            self.state = ChannelState::Closed;\n\n            Poll::Ready(Err(ChannelError))\n        } else {\n            Poll::Ready(Ok(packet))\n        }\n    }\n\n    pub fn split(self) -> (ChannelHeaders, ChannelData) {\n        let (headers, data) = BiLock::new(self);\n\n        (ChannelHeaders(headers), ChannelData(data))\n    }\n}\n\nimpl Stream for Channel {\n    type Item = Result<ChannelEvent, ChannelError>;\n\n    fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {\n        loop {\n            match self.state.clone() {\n                ChannelState::Closed => {\n                    error!(\"Polling already terminated channel\");\n                    return Poll::Ready(None);\n                }\n\n                ChannelState::Header(mut data) => {\n                    if data.is_empty() {\n                        data = ready!(self.recv_packet(cx))?;\n                    }\n\n                    let length = BigEndian::read_u16(data.split_to(2).as_ref()) as usize;\n                    if length == 0 {\n                        self.state = ChannelState::Data;\n                    } else {\n                        let header_id = data.split_to(1).as_ref()[0];\n                        let header_data = data.split_to(length - 1).as_ref().to_owned();\n\n                        self.state = ChannelState::Header(data);\n\n                        let event = ChannelEvent::Header(header_id, header_data);\n                        return Poll::Ready(Some(Ok(event)));\n                    }\n                }\n\n                ChannelState::Data => {\n                    let data = ready!(self.recv_packet(cx))?;\n                    if data.is_empty() {\n                        self.receiver.close();\n                        self.state = ChannelState::Closed;\n                        return Poll::Ready(None);\n                    } else {\n                        let event = ChannelEvent::Data(data);\n                        return Poll::Ready(Some(Ok(event)));\n                    }\n                }\n            }\n        }\n    }\n}\n\nimpl Stream for ChannelData {\n    type Item = Result<Bytes, ChannelError>;\n\n    fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {\n        let mut channel = ready!(self.0.poll_lock(cx));\n\n        loop {\n            match ready!(channel.poll_next_unpin(cx)?) {\n                Some(ChannelEvent::Header(..)) => (),\n                Some(ChannelEvent::Data(data)) => return Poll::Ready(Some(Ok(data))),\n                None => return Poll::Ready(None),\n            }\n        }\n    }\n}\n\nimpl Stream for ChannelHeaders {\n    type Item = Result<(u8, Vec<u8>), ChannelError>;\n\n    fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {\n        let mut channel = ready!(self.0.poll_lock(cx));\n\n        match ready!(channel.poll_next_unpin(cx)?) {\n            Some(ChannelEvent::Header(id, data)) => Poll::Ready(Some(Ok((id, data)))),\n            _ => Poll::Ready(None),\n        }\n    }\n}\n"
  },
  {
    "path": "core/src/component.rs",
    "content": "pub(crate) const COMPONENT_POISON_MSG: &str = \"component mutex should not be poisoned\";\n\nmacro_rules! component {\n    ($name:ident : $inner:ident { $($key:ident : $ty:ty = $value:expr,)* }) => {\n        #[derive(Clone)]\n        pub struct $name(::std::sync::Arc<($crate::session::SessionWeak, ::std::sync::Mutex<$inner>)>);\n        impl $name {\n            #[allow(dead_code)]\n            pub(crate) fn new(session: $crate::session::SessionWeak) -> $name {\n                debug!(target:\"librespot::component\", \"new {}\", stringify!($name));\n\n                $name(::std::sync::Arc::new((session, ::std::sync::Mutex::new($inner {\n                    $($key : $value,)*\n                }))))\n            }\n\n            #[allow(dead_code)]\n            fn lock<F: FnOnce(&mut $inner) -> R, R>(&self, f: F) -> R {\n                let mut inner = (self.0).1.lock()\n                    .expect($crate::component::COMPONENT_POISON_MSG);\n                f(&mut inner)\n            }\n\n            #[allow(dead_code)]\n            fn session(&self) -> $crate::session::Session {\n                (self.0).0.upgrade()\n            }\n        }\n\n        struct $inner {\n            $($key : $ty,)*\n        }\n\n        impl Drop for $inner {\n            fn drop(&mut self) {\n                debug!(target:\"librespot::component\", \"drop {}\", stringify!($name));\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "core/src/config.rs",
    "content": "use std::{fmt, path::PathBuf, str::FromStr};\n\nuse librespot_protocol::devices::DeviceType as ProtoDeviceType;\nuse url::Url;\n\npub(crate) const KEYMASTER_CLIENT_ID: &str = \"65b708073fc0480ea92a077233ca87bd\";\npub(crate) const ANDROID_CLIENT_ID: &str = \"9a8d2f0ce77a4e248bb71fefcb557637\";\npub(crate) const IOS_CLIENT_ID: &str = \"58bd3c95768941ea9eb4350aaa033eb3\";\n\n// Easily adjust the current platform to mock the behavior on it. If for example\n// android or ios needs to be mocked, the `os_version` has to be set to a valid version.\n// Otherwise, client-token or login5 requests will fail with a generic invalid-credential error.\n/// See [std::env::consts::OS]\npub const OS: &str = std::env::consts::OS;\n\n// valid versions for some os:\n// 'android': 30\n// 'ios': 17\n/// See [sysinfo::System::os_version]\npub fn os_version() -> String {\n    sysinfo::System::os_version().unwrap_or(\"0\".into())\n}\n\n#[derive(Clone, Debug)]\npub struct SessionConfig {\n    pub client_id: String,\n    pub device_id: String,\n    pub proxy: Option<Url>,\n    pub ap_port: Option<u16>,\n    pub tmp_dir: PathBuf,\n    pub autoplay: Option<bool>,\n}\n\nimpl SessionConfig {\n    pub(crate) fn default_for_os(os: &str) -> Self {\n        let device_id = uuid::Uuid::new_v4().as_hyphenated().to_string();\n        let client_id = match os {\n            \"android\" => ANDROID_CLIENT_ID,\n            \"ios\" => IOS_CLIENT_ID,\n            _ => KEYMASTER_CLIENT_ID,\n        }\n        .to_owned();\n\n        Self {\n            client_id,\n            device_id,\n            proxy: None,\n            ap_port: None,\n            tmp_dir: std::env::temp_dir(),\n            autoplay: None,\n        }\n    }\n}\n\nimpl Default for SessionConfig {\n    fn default() -> Self {\n        Self::default_for_os(OS)\n    }\n}\n\n#[derive(Clone, Copy, Debug, Hash, PartialOrd, Ord, PartialEq, Eq, Default)]\npub enum DeviceType {\n    Unknown = 0,\n    Computer = 1,\n    Tablet = 2,\n    Smartphone = 3,\n    #[default]\n    Speaker = 4,\n    Tv = 5,\n    Avr = 6,\n    Stb = 7,\n    AudioDongle = 8,\n    GameConsole = 9,\n    CastAudio = 10,\n    CastVideo = 11,\n    Automobile = 12,\n    Smartwatch = 13,\n    Chromebook = 14,\n    UnknownSpotify = 100,\n    CarThing = 101,\n    Observer = 102,\n}\n\nimpl FromStr for DeviceType {\n    type Err = ();\n    fn from_str(s: &str) -> Result<Self, Self::Err> {\n        use self::DeviceType::*;\n        match s.to_lowercase().as_ref() {\n            \"computer\" => Ok(Computer),\n            \"tablet\" => Ok(Tablet),\n            \"smartphone\" => Ok(Smartphone),\n            \"speaker\" => Ok(Speaker),\n            \"tv\" => Ok(Tv),\n            \"avr\" => Ok(Avr),\n            \"stb\" => Ok(Stb),\n            \"audiodongle\" => Ok(AudioDongle),\n            \"gameconsole\" => Ok(GameConsole),\n            \"castaudio\" => Ok(CastAudio),\n            \"castvideo\" => Ok(CastVideo),\n            \"automobile\" => Ok(Automobile),\n            \"smartwatch\" => Ok(Smartwatch),\n            \"chromebook\" => Ok(Chromebook),\n            \"carthing\" => Ok(CarThing),\n            _ => Err(()),\n        }\n    }\n}\n\nimpl From<&DeviceType> for &str {\n    fn from(d: &DeviceType) -> &'static str {\n        use self::DeviceType::*;\n        match d {\n            Unknown => \"Unknown\",\n            Computer => \"Computer\",\n            Tablet => \"Tablet\",\n            Smartphone => \"Smartphone\",\n            Speaker => \"Speaker\",\n            Tv => \"TV\",\n            Avr => \"AVR\",\n            Stb => \"STB\",\n            AudioDongle => \"AudioDongle\",\n            GameConsole => \"GameConsole\",\n            CastAudio => \"CastAudio\",\n            CastVideo => \"CastVideo\",\n            Automobile => \"Automobile\",\n            Smartwatch => \"Smartwatch\",\n            Chromebook => \"Chromebook\",\n            UnknownSpotify => \"UnknownSpotify\",\n            CarThing => \"CarThing\",\n            Observer => \"Observer\",\n        }\n    }\n}\n\nimpl From<DeviceType> for &str {\n    fn from(d: DeviceType) -> &'static str {\n        (&d).into()\n    }\n}\n\nimpl fmt::Display for DeviceType {\n    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n        let str: &str = self.into();\n        f.write_str(str)\n    }\n}\n\nimpl From<DeviceType> for ProtoDeviceType {\n    fn from(value: DeviceType) -> Self {\n        match value {\n            DeviceType::Unknown => ProtoDeviceType::UNKNOWN,\n            DeviceType::Computer => ProtoDeviceType::COMPUTER,\n            DeviceType::Tablet => ProtoDeviceType::TABLET,\n            DeviceType::Smartphone => ProtoDeviceType::SMARTPHONE,\n            DeviceType::Speaker => ProtoDeviceType::SPEAKER,\n            DeviceType::Tv => ProtoDeviceType::TV,\n            DeviceType::Avr => ProtoDeviceType::AVR,\n            DeviceType::Stb => ProtoDeviceType::STB,\n            DeviceType::AudioDongle => ProtoDeviceType::AUDIO_DONGLE,\n            DeviceType::GameConsole => ProtoDeviceType::GAME_CONSOLE,\n            DeviceType::CastAudio => ProtoDeviceType::CAST_VIDEO,\n            DeviceType::CastVideo => ProtoDeviceType::CAST_AUDIO,\n            DeviceType::Automobile => ProtoDeviceType::AUTOMOBILE,\n            DeviceType::Smartwatch => ProtoDeviceType::SMARTWATCH,\n            DeviceType::Chromebook => ProtoDeviceType::CHROMEBOOK,\n            DeviceType::UnknownSpotify => ProtoDeviceType::UNKNOWN_SPOTIFY,\n            DeviceType::CarThing => ProtoDeviceType::CAR_THING,\n            DeviceType::Observer => ProtoDeviceType::OBSERVER,\n        }\n    }\n}\n"
  },
  {
    "path": "core/src/connection/codec.rs",
    "content": "use std::io;\n\nuse byteorder::{BigEndian, ByteOrder};\nuse bytes::{BufMut, Bytes, BytesMut};\nuse shannon::Shannon;\nuse thiserror::Error;\nuse tokio_util::codec::{Decoder, Encoder};\n\nconst HEADER_SIZE: usize = 3;\nconst MAC_SIZE: usize = 4;\n\n#[derive(Debug, Error)]\npub enum ApCodecError {\n    #[error(\"payload was malformed\")]\n    Payload,\n}\n\n#[derive(Debug)]\nenum DecodeState {\n    Header,\n    Payload(u8, usize),\n}\n\npub struct ApCodec {\n    encode_nonce: u32,\n    encode_cipher: Shannon,\n\n    decode_nonce: u32,\n    decode_cipher: Shannon,\n    decode_state: DecodeState,\n}\n\nimpl ApCodec {\n    pub fn new(send_key: &[u8], recv_key: &[u8]) -> ApCodec {\n        ApCodec {\n            encode_nonce: 0,\n            encode_cipher: Shannon::new(send_key),\n\n            decode_nonce: 0,\n            decode_cipher: Shannon::new(recv_key),\n            decode_state: DecodeState::Header,\n        }\n    }\n}\n\nimpl Encoder<(u8, Vec<u8>)> for ApCodec {\n    type Error = io::Error;\n\n    fn encode(&mut self, item: (u8, Vec<u8>), buf: &mut BytesMut) -> io::Result<()> {\n        let (cmd, payload) = item;\n        let offset = buf.len();\n\n        buf.reserve(3 + payload.len());\n        buf.put_u8(cmd);\n        buf.put_u16(payload.len() as u16);\n        buf.extend_from_slice(&payload);\n\n        self.encode_cipher.nonce_u32(self.encode_nonce);\n        self.encode_nonce += 1;\n\n        self.encode_cipher.encrypt(&mut buf[offset..]);\n\n        let mut mac = [0u8; MAC_SIZE];\n        self.encode_cipher.finish(&mut mac);\n        buf.extend_from_slice(&mac);\n\n        Ok(())\n    }\n}\n\nimpl Decoder for ApCodec {\n    type Item = (u8, Bytes);\n    type Error = io::Error;\n\n    fn decode(&mut self, buf: &mut BytesMut) -> io::Result<Option<(u8, Bytes)>> {\n        if let DecodeState::Header = self.decode_state {\n            if buf.len() >= HEADER_SIZE {\n                let mut header = [0u8; HEADER_SIZE];\n                header.copy_from_slice(buf.split_to(HEADER_SIZE).as_ref());\n\n                self.decode_cipher.nonce_u32(self.decode_nonce);\n                self.decode_nonce += 1;\n\n                self.decode_cipher.decrypt(&mut header);\n\n                let cmd = header[0];\n                let size = BigEndian::read_u16(&header[1..]) as usize;\n                self.decode_state = DecodeState::Payload(cmd, size);\n            }\n        }\n\n        if let DecodeState::Payload(cmd, size) = self.decode_state {\n            if buf.len() >= size + MAC_SIZE {\n                self.decode_state = DecodeState::Header;\n\n                let mut payload = buf.split_to(size + MAC_SIZE);\n\n                self.decode_cipher\n                    .decrypt(payload.get_mut(..size).ok_or_else(|| {\n                        io::Error::new(io::ErrorKind::InvalidData, ApCodecError::Payload)\n                    })?);\n                let mac = payload.split_off(size);\n                self.decode_cipher.check_mac(mac.as_ref())?;\n\n                return Ok(Some((cmd, payload.freeze())));\n            }\n        }\n\n        Ok(None)\n    }\n}\n"
  },
  {
    "path": "core/src/connection/handshake.rs",
    "content": "use std::{env::consts::ARCH, io};\n\nuse byteorder::{BigEndian, ByteOrder, WriteBytesExt};\nuse hmac::{Hmac, Mac};\nuse protobuf::Message;\nuse rand::RngCore;\nuse rsa::{BigUint, Pkcs1v15Sign, RsaPublicKey};\nuse sha1::{Digest, Sha1};\nuse thiserror::Error;\nuse tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};\nuse tokio_util::codec::{Decoder, Framed};\n\nuse super::codec::ApCodec;\n\nuse crate::{diffie_hellman::DhLocalKeys, version};\n\nuse crate::protocol;\nuse crate::protocol::keyexchange::{\n    APResponseMessage, ClientHello, ClientResponsePlaintext, Platform, ProductFlags,\n};\n\nconst SERVER_KEY: [u8; 256] = [\n    0xac, 0xe0, 0x46, 0x0b, 0xff, 0xc2, 0x30, 0xaf, 0xf4, 0x6b, 0xfe, 0xc3, 0xbf, 0xbf, 0x86, 0x3d,\n    0xa1, 0x91, 0xc6, 0xcc, 0x33, 0x6c, 0x93, 0xa1, 0x4f, 0xb3, 0xb0, 0x16, 0x12, 0xac, 0xac, 0x6a,\n    0xf1, 0x80, 0xe7, 0xf6, 0x14, 0xd9, 0x42, 0x9d, 0xbe, 0x2e, 0x34, 0x66, 0x43, 0xe3, 0x62, 0xd2,\n    0x32, 0x7a, 0x1a, 0x0d, 0x92, 0x3b, 0xae, 0xdd, 0x14, 0x02, 0xb1, 0x81, 0x55, 0x05, 0x61, 0x04,\n    0xd5, 0x2c, 0x96, 0xa4, 0x4c, 0x1e, 0xcc, 0x02, 0x4a, 0xd4, 0xb2, 0x0c, 0x00, 0x1f, 0x17, 0xed,\n    0xc2, 0x2f, 0xc4, 0x35, 0x21, 0xc8, 0xf0, 0xcb, 0xae, 0xd2, 0xad, 0xd7, 0x2b, 0x0f, 0x9d, 0xb3,\n    0xc5, 0x32, 0x1a, 0x2a, 0xfe, 0x59, 0xf3, 0x5a, 0x0d, 0xac, 0x68, 0xf1, 0xfa, 0x62, 0x1e, 0xfb,\n    0x2c, 0x8d, 0x0c, 0xb7, 0x39, 0x2d, 0x92, 0x47, 0xe3, 0xd7, 0x35, 0x1a, 0x6d, 0xbd, 0x24, 0xc2,\n    0xae, 0x25, 0x5b, 0x88, 0xff, 0xab, 0x73, 0x29, 0x8a, 0x0b, 0xcc, 0xcd, 0x0c, 0x58, 0x67, 0x31,\n    0x89, 0xe8, 0xbd, 0x34, 0x80, 0x78, 0x4a, 0x5f, 0xc9, 0x6b, 0x89, 0x9d, 0x95, 0x6b, 0xfc, 0x86,\n    0xd7, 0x4f, 0x33, 0xa6, 0x78, 0x17, 0x96, 0xc9, 0xc3, 0x2d, 0x0d, 0x32, 0xa5, 0xab, 0xcd, 0x05,\n    0x27, 0xe2, 0xf7, 0x10, 0xa3, 0x96, 0x13, 0xc4, 0x2f, 0x99, 0xc0, 0x27, 0xbf, 0xed, 0x04, 0x9c,\n    0x3c, 0x27, 0x58, 0x04, 0xb6, 0xb2, 0x19, 0xf9, 0xc1, 0x2f, 0x02, 0xe9, 0x48, 0x63, 0xec, 0xa1,\n    0xb6, 0x42, 0xa0, 0x9d, 0x48, 0x25, 0xf8, 0xb3, 0x9d, 0xd0, 0xe8, 0x6a, 0xf9, 0x48, 0x4d, 0xa1,\n    0xc2, 0xba, 0x86, 0x30, 0x42, 0xea, 0x9d, 0xb3, 0x08, 0x6c, 0x19, 0x0e, 0x48, 0xb3, 0x9d, 0x66,\n    0xeb, 0x00, 0x06, 0xa2, 0x5a, 0xee, 0xa1, 0x1b, 0x13, 0x87, 0x3c, 0xd7, 0x19, 0xe6, 0x55, 0xbd,\n];\n\n#[derive(Debug, Error)]\npub enum HandshakeError {\n    #[error(\"invalid key length\")]\n    InvalidLength,\n    #[error(\"server key verification failed\")]\n    VerificationFailed,\n}\n\npub async fn handshake<T: AsyncRead + AsyncWrite + Unpin>(\n    mut connection: T,\n) -> io::Result<Framed<T, ApCodec>> {\n    let local_keys = DhLocalKeys::random(&mut rand::rng());\n    let gc = local_keys.public_key();\n    let mut accumulator = client_hello(&mut connection, gc).await?;\n    let message: APResponseMessage = recv_packet(&mut connection, &mut accumulator).await?;\n    let remote_key = message\n        .challenge\n        .get_or_default()\n        .login_crypto_challenge\n        .get_or_default()\n        .diffie_hellman\n        .get_or_default()\n        .gs()\n        .to_owned();\n    let remote_signature = message\n        .challenge\n        .get_or_default()\n        .login_crypto_challenge\n        .get_or_default()\n        .diffie_hellman\n        .get_or_default()\n        .gs_signature()\n        .to_owned();\n\n    // Prevent man-in-the-middle attacks: check server signature\n    let n = BigUint::from_bytes_be(&SERVER_KEY);\n    let e = BigUint::new(vec![65537]);\n    let public_key = RsaPublicKey::new(n, e).map_err(|_| {\n        io::Error::new(\n            io::ErrorKind::InvalidData,\n            HandshakeError::VerificationFailed,\n        )\n    })?;\n\n    let hash = Sha1::digest(&remote_key);\n    let padding = Pkcs1v15Sign::new::<Sha1>();\n    public_key\n        .verify(padding, &hash, &remote_signature)\n        .map_err(|_| {\n            io::Error::new(\n                io::ErrorKind::InvalidData,\n                HandshakeError::VerificationFailed,\n            )\n        })?;\n\n    // OK to proceed\n    let shared_secret = local_keys.shared_secret(&remote_key);\n    let (challenge, send_key, recv_key) = compute_keys(&shared_secret, &accumulator)?;\n    let codec = ApCodec::new(&send_key, &recv_key);\n\n    client_response(&mut connection, challenge).await?;\n\n    Ok(codec.framed(connection))\n}\n\nasync fn client_hello<T>(connection: &mut T, gc: Vec<u8>) -> io::Result<Vec<u8>>\nwhere\n    T: AsyncWrite + Unpin,\n{\n    let mut client_nonce = vec![0; 0x10];\n    rand::rng().fill_bytes(&mut client_nonce);\n\n    let platform = match crate::config::OS {\n        \"freebsd\" | \"netbsd\" | \"openbsd\" => match ARCH {\n            \"x86_64\" => Platform::PLATFORM_FREEBSD_X86_64,\n            _ => Platform::PLATFORM_FREEBSD_X86,\n        },\n        \"ios\" => match ARCH {\n            \"aarch64\" => Platform::PLATFORM_IPHONE_ARM64,\n            _ => Platform::PLATFORM_IPHONE_ARM,\n        },\n        // Rather than sending `Platform::PLATFORM_ANDROID_ARM` for \"android\",\n        // we are spoofing \"android\" as \"linux\", as otherwise during Session::connect\n        // all APs will reject the client with TryAnotherAP, no matter the credentials\n        // used was obtained via OAuth using KEYMASTER or ANDROID's client ID or\n        // Login5Manager::login\n        \"linux\" | \"android\" => match ARCH {\n            \"arm\" | \"aarch64\" => Platform::PLATFORM_LINUX_ARM,\n            \"blackfin\" => Platform::PLATFORM_LINUX_BLACKFIN,\n            \"mips\" => Platform::PLATFORM_LINUX_MIPS,\n            \"sh\" => Platform::PLATFORM_LINUX_SH,\n            \"x86_64\" => Platform::PLATFORM_LINUX_X86_64,\n            _ => Platform::PLATFORM_LINUX_X86,\n        },\n        \"macos\" => match ARCH {\n            \"ppc\" | \"ppc64\" => Platform::PLATFORM_OSX_PPC,\n            \"x86_64\" => Platform::PLATFORM_OSX_X86_64,\n            _ => Platform::PLATFORM_OSX_X86,\n        },\n        \"windows\" => match ARCH {\n            \"arm\" | \"aarch64\" => Platform::PLATFORM_WINDOWS_CE_ARM,\n            \"x86_64\" => Platform::PLATFORM_WIN32_X86_64,\n            _ => Platform::PLATFORM_WIN32_X86,\n        },\n        _ => Platform::PLATFORM_LINUX_X86,\n    };\n\n    #[cfg(debug_assertions)]\n    const PRODUCT_FLAGS: ProductFlags = ProductFlags::PRODUCT_FLAG_DEV_BUILD;\n    #[cfg(not(debug_assertions))]\n    const PRODUCT_FLAGS: ProductFlags = ProductFlags::PRODUCT_FLAG_NONE;\n\n    let mut packet = ClientHello::new();\n    packet\n        .build_info\n        .mut_or_insert_default()\n        // ProductInfo won't push autoplay and perhaps other settings\n        // when set to anything else than PRODUCT_CLIENT\n        .set_product(protocol::keyexchange::Product::PRODUCT_CLIENT);\n    packet\n        .build_info\n        .mut_or_insert_default()\n        .product_flags\n        .push(PRODUCT_FLAGS.into());\n    packet\n        .build_info\n        .mut_or_insert_default()\n        .set_platform(platform);\n    packet\n        .build_info\n        .mut_or_insert_default()\n        .set_version(version::SPOTIFY_VERSION);\n    packet\n        .cryptosuites_supported\n        .push(protocol::keyexchange::Cryptosuite::CRYPTO_SUITE_SHANNON.into());\n    packet\n        .login_crypto_hello\n        .mut_or_insert_default()\n        .diffie_hellman\n        .mut_or_insert_default()\n        .set_gc(gc);\n    packet\n        .login_crypto_hello\n        .mut_or_insert_default()\n        .diffie_hellman\n        .mut_or_insert_default()\n        .set_server_keys_known(1);\n    packet.set_client_nonce(client_nonce);\n    packet.set_padding(vec![0x1e]);\n\n    let mut buffer = vec![0, 4];\n    let size = 2 + 4 + packet.compute_size();\n    <Vec<u8> as WriteBytesExt>::write_u32::<BigEndian>(&mut buffer, size.try_into().unwrap())?;\n    packet.write_to_vec(&mut buffer)?;\n\n    connection.write_all(&buffer[..]).await?;\n    Ok(buffer)\n}\n\nasync fn client_response<T>(connection: &mut T, challenge: Vec<u8>) -> io::Result<()>\nwhere\n    T: AsyncWrite + Unpin,\n{\n    let mut packet = ClientResponsePlaintext::new();\n    packet\n        .login_crypto_response\n        .mut_or_insert_default()\n        .diffie_hellman\n        .mut_or_insert_default()\n        .set_hmac(challenge);\n\n    packet.pow_response.mut_or_insert_default();\n    packet.crypto_response.mut_or_insert_default();\n\n    let mut buffer = vec![];\n    let size = 4 + packet.compute_size();\n    <Vec<u8> as WriteBytesExt>::write_u32::<BigEndian>(&mut buffer, size.try_into().unwrap())?;\n    packet.write_to_vec(&mut buffer)?;\n\n    connection.write_all(&buffer[..]).await?;\n    Ok(())\n}\n\nasync fn recv_packet<T, M>(connection: &mut T, acc: &mut Vec<u8>) -> io::Result<M>\nwhere\n    T: AsyncRead + Unpin,\n    M: Message,\n{\n    let header = read_into_accumulator(connection, 4, acc).await?;\n    let size = BigEndian::read_u32(header) as usize;\n    let data = read_into_accumulator(connection, size - 4, acc).await?;\n    let message = M::parse_from_bytes(data)?;\n    Ok(message)\n}\n\nasync fn read_into_accumulator<'b, T: AsyncRead + Unpin>(\n    connection: &mut T,\n    size: usize,\n    acc: &'b mut Vec<u8>,\n) -> io::Result<&'b mut [u8]> {\n    let offset = acc.len();\n    acc.resize(offset + size, 0);\n\n    connection.read_exact(&mut acc[offset..]).await?;\n    Ok(&mut acc[offset..])\n}\n\nfn compute_keys(shared_secret: &[u8], packets: &[u8]) -> io::Result<(Vec<u8>, Vec<u8>, Vec<u8>)> {\n    type HmacSha1 = Hmac<Sha1>;\n\n    let mut data = Vec::with_capacity(0x64);\n    for i in 1..6 {\n        let mut mac = HmacSha1::new_from_slice(shared_secret).map_err(|_| {\n            io::Error::new(io::ErrorKind::InvalidData, HandshakeError::InvalidLength)\n        })?;\n        mac.update(packets);\n        mac.update(&[i]);\n        data.extend_from_slice(&mac.finalize().into_bytes());\n    }\n\n    let mut mac = HmacSha1::new_from_slice(&data[..0x14])\n        .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, HandshakeError::InvalidLength))?;\n    mac.update(packets);\n\n    Ok((\n        mac.finalize().into_bytes().to_vec(),\n        data[0x14..0x34].to_vec(),\n        data[0x34..0x54].to_vec(),\n    ))\n}\n"
  },
  {
    "path": "core/src/connection/mod.rs",
    "content": "mod codec;\nmod handshake;\n\npub use self::{codec::ApCodec, handshake::handshake};\n\nuse std::{io, time::Duration};\n\nuse futures_util::{SinkExt, StreamExt};\nuse num_traits::FromPrimitive;\nuse protobuf::Message;\nuse thiserror::Error;\nuse tokio::net::TcpStream;\nuse tokio_util::codec::Framed;\nuse url::Url;\n\nuse crate::{Error, authentication::Credentials, packet::PacketType, version};\n\nuse crate::protocol::keyexchange::{APLoginFailed, ErrorCode};\n\npub type Transport = Framed<TcpStream, ApCodec>;\n\nfn login_error_message(code: &ErrorCode) -> &'static str {\n    pub use ErrorCode::*;\n    match code {\n        ProtocolError => \"Protocol error\",\n        TryAnotherAP => \"Try another access point\",\n        BadConnectionId => \"Bad connection ID\",\n        TravelRestriction => \"Travel restriction\",\n        PremiumAccountRequired => \"Premium account required\",\n        BadCredentials => \"Bad credentials\",\n        CouldNotValidateCredentials => \"Could not validate credentials\",\n        AccountExists => \"Account exists\",\n        ExtraVerificationRequired => \"Extra verification required\",\n        InvalidAppKey => \"Invalid app key\",\n        ApplicationBanned => \"Application banned\",\n    }\n}\n\n#[derive(Debug, Error)]\npub enum AuthenticationError {\n    #[error(\"Login failed with reason: {}\", login_error_message(.0))]\n    LoginFailed(ErrorCode),\n    #[error(\"invalid packet {0}\")]\n    Packet(u8),\n    #[error(\"transport returned no data\")]\n    Transport,\n}\n\nimpl From<AuthenticationError> for Error {\n    fn from(err: AuthenticationError) -> Self {\n        match err {\n            AuthenticationError::LoginFailed(_) => Error::permission_denied(err),\n            AuthenticationError::Packet(_) => Error::unimplemented(err),\n            AuthenticationError::Transport => Error::unavailable(err),\n        }\n    }\n}\n\nimpl From<APLoginFailed> for AuthenticationError {\n    fn from(login_failure: APLoginFailed) -> Self {\n        Self::LoginFailed(login_failure.error_code())\n    }\n}\n\npub async fn connect(host: &str, port: u16, proxy: Option<&Url>) -> io::Result<Transport> {\n    const TIMEOUT: Duration = Duration::from_secs(5);\n    tokio::time::timeout(TIMEOUT, {\n        let socket = crate::socket::connect(host, port, proxy).await?;\n        debug!(\"Connection to AP established.\");\n        handshake(socket)\n    })\n    .await?\n}\n\npub async fn connect_with_retry(\n    host: &str,\n    port: u16,\n    proxy: Option<&Url>,\n    max_retries: u8,\n) -> io::Result<Transport> {\n    let mut num_retries = 0;\n    loop {\n        match connect(host, port, proxy).await {\n            Ok(f) => return Ok(f),\n            Err(e) => {\n                debug!(\"Connection to \\\"{host}:{port}\\\" failed: {e}\");\n                if num_retries < max_retries {\n                    num_retries += 1;\n                    debug!(\"Retry access point...\");\n                    continue;\n                }\n                return Err(e);\n            }\n        }\n    }\n}\n\npub async fn authenticate(\n    transport: &mut Transport,\n    credentials: Credentials,\n    device_id: &str,\n) -> Result<Credentials, Error> {\n    use crate::protocol::authentication::{APWelcome, ClientResponseEncrypted, CpuFamily, Os};\n\n    let cpu_family = match std::env::consts::ARCH {\n        \"blackfin\" => CpuFamily::CPU_BLACKFIN,\n        \"arm\" | \"aarch64\" => CpuFamily::CPU_ARM,\n        \"ia64\" => CpuFamily::CPU_IA64,\n        \"mips\" => CpuFamily::CPU_MIPS,\n        \"ppc\" => CpuFamily::CPU_PPC,\n        \"ppc64\" => CpuFamily::CPU_PPC_64,\n        \"sh\" => CpuFamily::CPU_SH,\n        \"x86\" => CpuFamily::CPU_X86,\n        \"x86_64\" => CpuFamily::CPU_X86_64,\n        _ => CpuFamily::CPU_UNKNOWN,\n    };\n\n    let os = match crate::config::OS {\n        \"android\" => Os::OS_ANDROID,\n        \"freebsd\" | \"netbsd\" | \"openbsd\" => Os::OS_FREEBSD,\n        \"ios\" => Os::OS_IPHONE,\n        \"linux\" => Os::OS_LINUX,\n        \"macos\" => Os::OS_OSX,\n        \"windows\" => Os::OS_WINDOWS,\n        _ => Os::OS_UNKNOWN,\n    };\n\n    let mut packet = ClientResponseEncrypted::new();\n    if let Some(username) = credentials.username {\n        packet\n            .login_credentials\n            .mut_or_insert_default()\n            .set_username(username);\n    }\n    packet\n        .login_credentials\n        .mut_or_insert_default()\n        .set_typ(credentials.auth_type);\n    packet\n        .login_credentials\n        .mut_or_insert_default()\n        .set_auth_data(credentials.auth_data);\n    packet\n        .system_info\n        .mut_or_insert_default()\n        .set_cpu_family(cpu_family);\n    packet.system_info.mut_or_insert_default().set_os(os);\n    packet\n        .system_info\n        .mut_or_insert_default()\n        .set_system_information_string(format!(\n            \"librespot-{}-{}\",\n            version::SHA_SHORT,\n            version::BUILD_ID\n        ));\n    packet\n        .system_info\n        .mut_or_insert_default()\n        .set_device_id(device_id.to_string());\n    packet.set_version_string(format!(\"librespot {}\", version::SEMVER));\n\n    let cmd = PacketType::Login;\n    let data = packet.write_to_bytes()?;\n\n    debug!(\"Authenticating with AP using {:?}\", credentials.auth_type);\n    transport.send((cmd as u8, data)).await?;\n    let (cmd, data) = transport\n        .next()\n        .await\n        .ok_or(AuthenticationError::Transport)??;\n    let packet_type = FromPrimitive::from_u8(cmd);\n    let result = match packet_type {\n        Some(PacketType::APWelcome) => {\n            let welcome_data = APWelcome::parse_from_bytes(data.as_ref())?;\n\n            let reusable_credentials = Credentials {\n                username: Some(welcome_data.canonical_username().to_owned()),\n                auth_type: welcome_data.reusable_auth_credentials_type(),\n                auth_data: welcome_data.reusable_auth_credentials().to_owned(),\n            };\n\n            Ok(reusable_credentials)\n        }\n        Some(PacketType::AuthFailure) => {\n            let error_data = APLoginFailed::parse_from_bytes(data.as_ref())?;\n            Err(error_data.into())\n        }\n        _ => {\n            trace!(\"Did not expect {cmd:?} AES key packet with data {data:#?}\");\n            Err(AuthenticationError::Packet(cmd))\n        }\n    };\n    Ok(result?)\n}\n"
  },
  {
    "path": "core/src/date.rs",
    "content": "use std::{fmt::Debug, ops::Deref};\n\nuse time::{\n    Date as _Date, OffsetDateTime, PrimitiveDateTime, Time, error::ComponentRange,\n    format_description::well_known::Iso8601,\n};\n\nuse crate::Error;\n\nuse librespot_protocol as protocol;\nuse protocol::metadata::Date as DateMessage;\n\nimpl From<ComponentRange> for Error {\n    fn from(err: ComponentRange) -> Self {\n        Error::out_of_range(err)\n    }\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]\npub struct Date(pub OffsetDateTime);\n\nimpl Deref for Date {\n    type Target = OffsetDateTime;\n    fn deref(&self) -> &Self::Target {\n        &self.0\n    }\n}\n\nimpl Date {\n    pub fn as_timestamp_ms(&self) -> i64 {\n        (self.0.unix_timestamp_nanos() / 1_000_000) as i64\n    }\n\n    pub fn from_timestamp_ms(timestamp: i64) -> Result<Self, Error> {\n        let date_time = OffsetDateTime::from_unix_timestamp_nanos(timestamp as i128 * 1_000_000)?;\n        Ok(Self(date_time))\n    }\n\n    pub fn as_utc(&self) -> OffsetDateTime {\n        self.0\n    }\n\n    pub fn from_utc(date_time: PrimitiveDateTime) -> Self {\n        Self(date_time.assume_utc())\n    }\n\n    pub fn now_utc() -> Self {\n        Self(OffsetDateTime::now_utc())\n    }\n\n    pub fn from_iso8601(input: &str) -> Result<Self, Error> {\n        let date_time = OffsetDateTime::parse(input, &Iso8601::DEFAULT)?;\n        Ok(Self(date_time))\n    }\n}\n\nimpl TryFrom<&DateMessage> for Date {\n    type Error = crate::Error;\n    fn try_from(msg: &DateMessage) -> Result<Self, Self::Error> {\n        // Some metadata contains a year, but no month. In that case just set January.\n        let month = if msg.has_month() {\n            msg.month() as u8\n        } else {\n            1\n        };\n\n        // Having no day will work, but may be unexpected: it will imply the last day\n        // of the month before. So prevent that, and just set day 1.\n        let day = if msg.has_day() { msg.day() as u8 } else { 1 };\n\n        let date = _Date::from_calendar_date(msg.year(), month.try_into()?, day)?;\n        let time = Time::from_hms(msg.hour() as u8, msg.minute() as u8, 0)?;\n        Ok(Self::from_utc(PrimitiveDateTime::new(date, time)))\n    }\n}\n\nimpl From<OffsetDateTime> for Date {\n    fn from(datetime: OffsetDateTime) -> Self {\n        Self(datetime)\n    }\n}\n"
  },
  {
    "path": "core/src/dealer/manager.rs",
    "content": "use futures_core::Stream;\nuse futures_util::StreamExt;\nuse std::{pin::Pin, str::FromStr, sync::OnceLock};\nuse thiserror::Error;\nuse tokio::sync::mpsc;\nuse tokio_stream::wrappers::UnboundedReceiverStream;\nuse url::Url;\n\nuse super::{\n    Builder, Dealer, GetUrlResult, Request, RequestHandler, Responder, Response, Subscription,\n    protocol::Message,\n};\nuse crate::{Error, Session};\n\ncomponent! {\n    DealerManager: DealerManagerInner {\n        builder: OnceLock<Builder> = OnceLock::from(Builder::new()),\n        dealer: OnceLock<Dealer> = OnceLock::new(),\n    }\n}\n\npub type BoxedStream<T> = Pin<Box<dyn Stream<Item = T> + Send>>;\npub type BoxedStreamResult<T> = BoxedStream<Result<T, Error>>;\n\n#[derive(Error, Debug)]\nenum DealerError {\n    #[error(\"Builder wasn't available\")]\n    BuilderNotAvailable,\n    #[error(\"Websocket couldn't be started because: {0}\")]\n    LaunchFailure(Error),\n    #[error(\"Failed to set dealer\")]\n    CouldNotSetDealer,\n}\n\nimpl From<DealerError> for Error {\n    fn from(err: DealerError) -> Self {\n        Error::failed_precondition(err)\n    }\n}\n\n#[derive(Debug)]\npub enum Reply {\n    Success,\n    Failure,\n    Unanswered,\n}\n\npub type RequestReply = (Request, mpsc::UnboundedSender<Reply>);\ntype RequestReceiver = mpsc::UnboundedReceiver<RequestReply>;\ntype RequestSender = mpsc::UnboundedSender<RequestReply>;\n\nstruct DealerRequestHandler(RequestSender);\n\nimpl DealerRequestHandler {\n    pub fn new() -> (Self, RequestReceiver) {\n        let (tx, rx) = mpsc::unbounded_channel();\n        (DealerRequestHandler(tx), rx)\n    }\n}\n\nimpl RequestHandler for DealerRequestHandler {\n    fn handle_request(&self, request: Request, responder: Responder) {\n        let (tx, mut rx) = mpsc::unbounded_channel();\n\n        if let Err(why) = self.0.send((request, tx)) {\n            error!(\"failed sending dealer request {why}\");\n            responder.send(Response { success: false });\n            return;\n        }\n\n        tokio::spawn(async move {\n            let reply = rx.recv().await.unwrap_or(Reply::Failure);\n            debug!(\"replying to ws request: {reply:?}\");\n            match reply {\n                Reply::Unanswered => responder.force_unanswered(),\n                Reply::Success | Reply::Failure => responder.send(Response {\n                    success: matches!(reply, Reply::Success),\n                }),\n            }\n        });\n    }\n}\n\nimpl DealerManager {\n    async fn get_url(session: Session) -> GetUrlResult {\n        let (host, port) = session.apresolver().resolve(\"dealer\").await?;\n        let token = session.login5().auth_token().await?.access_token;\n        let url = format!(\"wss://{host}:{port}/?access_token={token}\");\n        let url = Url::from_str(&url)?;\n        Ok(url)\n    }\n\n    pub fn add_listen_for(&self, url: impl Into<String>) -> Result<Subscription, Error> {\n        let url = url.into();\n        self.lock(|inner| {\n            if let Some(dealer) = inner.dealer.get() {\n                dealer.subscribe(&[&url])\n            } else if let Some(builder) = inner.builder.get_mut() {\n                builder.subscribe(&[&url])\n            } else {\n                Err(DealerError::BuilderNotAvailable.into())\n            }\n        })\n    }\n\n    pub fn listen_for<T>(\n        &self,\n        uri: impl Into<String>,\n        t: impl Fn(Message) -> Result<T, Error> + Send + 'static,\n    ) -> Result<BoxedStreamResult<T>, Error> {\n        Ok(Box::pin(self.add_listen_for(uri)?.map(t)))\n    }\n\n    pub fn add_handle_for(&self, url: impl Into<String>) -> Result<RequestReceiver, Error> {\n        let url = url.into();\n\n        let (handler, receiver) = DealerRequestHandler::new();\n        self.lock(|inner| {\n            if let Some(dealer) = inner.dealer.get() {\n                dealer.add_handler(&url, handler).map(|_| receiver)\n            } else if let Some(builder) = inner.builder.get_mut() {\n                builder.add_handler(&url, handler).map(|_| receiver)\n            } else {\n                Err(DealerError::BuilderNotAvailable.into())\n            }\n        })\n    }\n\n    pub fn handle_for(&self, uri: impl Into<String>) -> Result<BoxedStream<RequestReply>, Error> {\n        Ok(Box::pin(\n            self.add_handle_for(uri).map(UnboundedReceiverStream::new)?,\n        ))\n    }\n\n    pub fn handles(&self, uri: &str) -> bool {\n        self.lock(|inner| {\n            if let Some(dealer) = inner.dealer.get() {\n                dealer.handles(uri)\n            } else if let Some(builder) = inner.builder.get() {\n                builder.handles(uri)\n            } else {\n                false\n            }\n        })\n    }\n\n    pub async fn start(&self) -> Result<(), Error> {\n        debug!(\"Launching dealer\");\n\n        let session = self.session();\n        // the url has to be a function that can retrieve a new url,\n        // otherwise when we later try to reconnect with the initial url/token\n        // and the token is expired we will just get 401 error\n        let get_url = move || Self::get_url(session.clone());\n\n        let dealer = self\n            .lock(move |inner| inner.builder.take())\n            .ok_or(DealerError::BuilderNotAvailable)?\n            .launch(get_url, None)\n            .await\n            .map_err(DealerError::LaunchFailure)?;\n\n        self.lock(|inner| inner.dealer.set(dealer))\n            .map_err(|_| DealerError::CouldNotSetDealer)?;\n\n        Ok(())\n    }\n\n    pub async fn close(&self) {\n        if let Some(dealer) = self.lock(|inner| inner.dealer.take()) {\n            dealer.close().await\n        }\n    }\n}\n"
  },
  {
    "path": "core/src/dealer/maps.rs",
    "content": "use std::collections::HashMap;\n\nuse crate::Error;\nuse thiserror::Error;\n\n#[derive(Debug, Error)]\npub enum HandlerMapError {\n    #[error(\"request was already handled\")]\n    AlreadyHandled,\n}\n\nimpl From<HandlerMapError> for Error {\n    fn from(err: HandlerMapError) -> Self {\n        Error::aborted(err)\n    }\n}\n\npub enum HandlerMap<T> {\n    Leaf(T),\n    Branch(HashMap<String, HandlerMap<T>>),\n}\n\nimpl<T> Default for HandlerMap<T> {\n    fn default() -> Self {\n        Self::Branch(HashMap::new())\n    }\n}\n\nimpl<T> HandlerMap<T> {\n    pub fn contains(&self, path: &str) -> bool {\n        matches!(self, HandlerMap::Branch(map) if map.contains_key(path))\n    }\n\n    pub fn insert<'a>(\n        &mut self,\n        mut path: impl Iterator<Item = &'a str>,\n        handler: T,\n    ) -> Result<(), Error> {\n        match self {\n            Self::Leaf(_) => Err(HandlerMapError::AlreadyHandled.into()),\n            Self::Branch(children) => {\n                if let Some(component) = path.next() {\n                    let node = children.entry(component.to_owned()).or_default();\n                    node.insert(path, handler)\n                } else if children.is_empty() {\n                    *self = Self::Leaf(handler);\n                    Ok(())\n                } else {\n                    Err(HandlerMapError::AlreadyHandled.into())\n                }\n            }\n        }\n    }\n\n    pub fn get<'a>(&self, mut path: impl Iterator<Item = &'a str>) -> Option<&T> {\n        match self {\n            Self::Leaf(t) => Some(t),\n            Self::Branch(m) => {\n                let component = path.next()?;\n                m.get(component)?.get(path)\n            }\n        }\n    }\n\n    pub fn remove<'a>(&mut self, mut path: impl Iterator<Item = &'a str>) -> Option<T> {\n        match self {\n            Self::Leaf(_) => match std::mem::take(self) {\n                Self::Leaf(t) => Some(t),\n                _ => unreachable!(),\n            },\n            Self::Branch(map) => {\n                let component = path.next()?;\n                let next = map.get_mut(component)?;\n                let result = next.remove(path);\n                match &*next {\n                    Self::Branch(b) if b.is_empty() => {\n                        map.remove(component);\n                    }\n                    _ => (),\n                }\n                result\n            }\n        }\n    }\n}\n\npub struct SubscriberMap<T> {\n    subscribed: Vec<T>,\n    children: HashMap<String, SubscriberMap<T>>,\n}\n\nimpl<T> Default for SubscriberMap<T> {\n    fn default() -> Self {\n        Self {\n            subscribed: Vec::new(),\n            children: HashMap::new(),\n        }\n    }\n}\n\nimpl<T> SubscriberMap<T> {\n    pub fn insert<'a>(&mut self, mut path: impl Iterator<Item = &'a str>, handler: T) {\n        if let Some(component) = path.next() {\n            self.children\n                .entry(component.to_owned())\n                .or_default()\n                .insert(path, handler);\n        } else {\n            self.subscribed.push(handler);\n        }\n    }\n\n    pub fn contains<'a>(&self, mut path: impl Iterator<Item = &'a str>) -> bool {\n        if !self.subscribed.is_empty() {\n            return true;\n        }\n\n        if let Some(next) = path.next() {\n            if let Some(next_map) = self.children.get(next) {\n                return next_map.contains(path);\n            }\n        } else {\n            return !self.is_empty();\n        }\n\n        false\n    }\n\n    pub fn is_empty(&self) -> bool {\n        self.children.is_empty() && self.subscribed.is_empty()\n    }\n\n    pub fn retain<'a>(\n        &mut self,\n        mut path: impl Iterator<Item = &'a str>,\n        fun: &mut impl FnMut(&T) -> bool,\n    ) -> bool {\n        let mut handled_by_any = false;\n        self.subscribed.retain(|x| {\n            handled_by_any = true;\n            fun(x)\n        });\n\n        if let Some(next) = path.next() {\n            if let Some(y) = self.children.get_mut(next) {\n                handled_by_any = handled_by_any || y.retain(path, fun);\n                if y.is_empty() {\n                    self.children.remove(next);\n                }\n            }\n        }\n\n        handled_by_any\n    }\n}\n"
  },
  {
    "path": "core/src/dealer/mod.rs",
    "content": "pub mod manager;\nmod maps;\npub mod protocol;\n\nuse std::{\n    iter,\n    pin::Pin,\n    sync::{\n        Arc, Mutex,\n        atomic::{self, AtomicBool},\n    },\n    task::Poll,\n    time::Duration,\n};\n\nuse futures_core::{Future, Stream};\nuse futures_util::{SinkExt, StreamExt, future::join_all};\nuse thiserror::Error;\nuse tokio::{\n    select,\n    sync::{\n        Semaphore,\n        mpsc::{self, UnboundedReceiver},\n    },\n    task::JoinHandle,\n};\nuse tokio_tungstenite::tungstenite;\nuse tungstenite::error::UrlError;\nuse url::Url;\n\nuse self::{\n    maps::*,\n    protocol::{Message, MessageOrRequest, Request, WebsocketMessage, WebsocketRequest},\n};\n\nuse crate::{\n    Error, socket,\n    util::{CancelOnDrop, TimeoutOnDrop, keep_flushing},\n};\n\ntype WsMessage = tungstenite::Message;\ntype WsError = tungstenite::Error;\ntype WsResult<T> = Result<T, Error>;\ntype GetUrlResult = Result<Url, Error>;\n\nimpl From<WsError> for Error {\n    fn from(err: WsError) -> Self {\n        Error::failed_precondition(err)\n    }\n}\n\nconst WEBSOCKET_CLOSE_TIMEOUT: Duration = Duration::from_secs(3);\n\nconst PING_INTERVAL: Duration = Duration::from_secs(30);\nconst PING_TIMEOUT: Duration = Duration::from_secs(3);\n\nconst RECONNECT_INTERVAL: Duration = Duration::from_secs(10);\n\nconst DEALER_REQUEST_HANDLERS_POISON_MSG: &str =\n    \"dealer request handlers mutex should not be poisoned\";\nconst DEALER_MESSAGE_HANDLERS_POISON_MSG: &str =\n    \"dealer message handlers mutex should not be poisoned\";\n\nstruct Response {\n    pub success: bool,\n}\n\nstruct Responder {\n    key: String,\n    tx: mpsc::UnboundedSender<WsMessage>,\n    sent: bool,\n}\n\nimpl Responder {\n    fn new(key: String, tx: mpsc::UnboundedSender<WsMessage>) -> Self {\n        Self {\n            key,\n            tx,\n            sent: false,\n        }\n    }\n\n    // Should only be called once\n    fn send_internal(&mut self, response: Response) {\n        let response = serde_json::json!({\n            \"type\": \"reply\",\n            \"key\": &self.key,\n            \"payload\": {\n                \"success\": response.success,\n            }\n        })\n        .to_string();\n\n        if let Err(e) = self.tx.send(WsMessage::Text(response.into())) {\n            warn!(\"Wasn't able to reply to dealer request: {e}\");\n        }\n    }\n\n    pub fn send(mut self, response: Response) {\n        self.send_internal(response);\n        self.sent = true;\n    }\n\n    pub fn force_unanswered(mut self) {\n        self.sent = true;\n    }\n}\n\nimpl Drop for Responder {\n    fn drop(&mut self) {\n        if !self.sent {\n            self.send_internal(Response { success: false });\n        }\n    }\n}\n\ntrait IntoResponse {\n    fn respond(self, responder: Responder);\n}\n\nimpl IntoResponse for Response {\n    fn respond(self, responder: Responder) {\n        responder.send(self)\n    }\n}\n\nimpl<F> IntoResponse for F\nwhere\n    F: Future<Output = Response> + Send + 'static,\n{\n    fn respond(self, responder: Responder) {\n        tokio::spawn(async move {\n            responder.send(self.await);\n        });\n    }\n}\n\nimpl<F, R> RequestHandler for F\nwhere\n    F: (Fn(Request) -> R) + Send + 'static,\n    R: IntoResponse,\n{\n    fn handle_request(&self, request: Request, responder: Responder) {\n        self(request).respond(responder);\n    }\n}\n\ntrait RequestHandler: Send + 'static {\n    fn handle_request(&self, request: Request, responder: Responder);\n}\n\ntype MessageHandler = mpsc::UnboundedSender<Message>;\n\n// TODO: Maybe it's possible to unregister subscription directly when they\n//       are dropped instead of on next failed attempt.\npub struct Subscription(UnboundedReceiver<Message>);\n\nimpl Stream for Subscription {\n    type Item = Message;\n\n    fn poll_next(\n        mut self: Pin<&mut Self>,\n        cx: &mut std::task::Context<'_>,\n    ) -> Poll<Option<Self::Item>> {\n        self.0.poll_recv(cx)\n    }\n}\n\nfn split_uri(s: &str) -> Option<impl Iterator<Item = &'_ str>> {\n    let (scheme, sep, rest) = if let Some(rest) = s.strip_prefix(\"hm://\") {\n        (\"hm\", '/', rest)\n    } else if let Some(rest) = s.strip_prefix(\"spotify:\") {\n        (\"spotify\", ':', rest)\n    } else if s.contains('/') {\n        (\"\", '/', s)\n    } else {\n        return None;\n    };\n\n    let rest = rest.trim_end_matches(sep);\n    let split = rest.split(sep);\n\n    Some(iter::once(scheme).chain(split))\n}\n\n#[derive(Debug, Clone, Error)]\nenum AddHandlerError {\n    #[error(\"There is already a handler for the given uri\")]\n    AlreadyHandled,\n    #[error(\"The specified uri {0} is invalid\")]\n    InvalidUri(String),\n}\n\nimpl From<AddHandlerError> for Error {\n    fn from(err: AddHandlerError) -> Self {\n        match err {\n            AddHandlerError::AlreadyHandled => Error::aborted(err),\n            AddHandlerError::InvalidUri(_) => Error::invalid_argument(err),\n        }\n    }\n}\n\n#[derive(Debug, Clone, Error)]\nenum SubscriptionError {\n    #[error(\"The specified uri is invalid\")]\n    InvalidUri(String),\n}\n\nimpl From<SubscriptionError> for Error {\n    fn from(err: SubscriptionError) -> Self {\n        Error::invalid_argument(err)\n    }\n}\n\nfn add_handler(\n    map: &mut HandlerMap<Box<dyn RequestHandler>>,\n    uri: &str,\n    handler: impl RequestHandler,\n) -> Result<(), Error> {\n    let split = split_uri(uri).ok_or_else(|| AddHandlerError::InvalidUri(uri.to_string()))?;\n    map.insert(split, Box::new(handler))\n}\n\nfn remove_handler<T>(map: &mut HandlerMap<T>, uri: &str) -> Option<T> {\n    map.remove(split_uri(uri)?)\n}\n\nfn subscribe(\n    map: &mut SubscriberMap<MessageHandler>,\n    uris: &[&str],\n) -> Result<Subscription, Error> {\n    let (tx, rx) = mpsc::unbounded_channel();\n\n    for &uri in uris {\n        let split = split_uri(uri).ok_or_else(|| SubscriptionError::InvalidUri(uri.to_string()))?;\n        map.insert(split, tx.clone());\n    }\n\n    Ok(Subscription(rx))\n}\n\nfn handles(\n    req_map: &HandlerMap<Box<dyn RequestHandler>>,\n    msg_map: &SubscriberMap<MessageHandler>,\n    uri: &str,\n) -> bool {\n    if req_map.contains(uri) {\n        return true;\n    }\n\n    match split_uri(uri) {\n        None => false,\n        Some(mut split) => msg_map.contains(&mut split),\n    }\n}\n\n#[derive(Default)]\nstruct Builder {\n    message_handlers: SubscriberMap<MessageHandler>,\n    request_handlers: HandlerMap<Box<dyn RequestHandler>>,\n}\n\nmacro_rules! create_dealer {\n    ($builder:expr, $shared:ident -> $body:expr) => {\n        match $builder {\n            builder => {\n                let shared = Arc::new(DealerShared {\n                    message_handlers: Mutex::new(builder.message_handlers),\n                    request_handlers: Mutex::new(builder.request_handlers),\n                    notify_drop: Semaphore::new(0),\n                });\n\n                let handle = {\n                    let $shared = Arc::clone(&shared);\n                    tokio::spawn($body)\n                };\n\n                Dealer {\n                    shared,\n                    handle: TimeoutOnDrop::new(handle, WEBSOCKET_CLOSE_TIMEOUT),\n                }\n            }\n        }\n    };\n}\n\nimpl Builder {\n    pub fn new() -> Self {\n        Self::default()\n    }\n\n    pub fn add_handler(&mut self, uri: &str, handler: impl RequestHandler) -> Result<(), Error> {\n        add_handler(&mut self.request_handlers, uri, handler)\n    }\n\n    pub fn subscribe(&mut self, uris: &[&str]) -> Result<Subscription, Error> {\n        subscribe(&mut self.message_handlers, uris)\n    }\n\n    pub fn handles(&self, uri: &str) -> bool {\n        handles(&self.request_handlers, &self.message_handlers, uri)\n    }\n\n    pub fn launch_in_background<Fut, F>(self, get_url: F, proxy: Option<Url>) -> Dealer\n    where\n        Fut: Future<Output = GetUrlResult> + Send + 'static,\n        F: (Fn() -> Fut) + Send + 'static,\n    {\n        create_dealer!(self, shared -> run(shared, None, get_url, proxy))\n    }\n\n    pub async fn launch<Fut, F>(self, get_url: F, proxy: Option<Url>) -> WsResult<Dealer>\n    where\n        Fut: Future<Output = GetUrlResult> + Send + 'static,\n        F: (Fn() -> Fut) + Send + 'static,\n    {\n        let dealer = create_dealer!(self, shared -> {\n            // Try to connect.\n            let url = get_url().await?;\n            let tasks = connect(&url, proxy.as_ref(), &shared).await?;\n\n            // If a connection is established, continue in a background task.\n            run(shared, Some(tasks), get_url, proxy)\n        });\n\n        Ok(dealer)\n    }\n}\n\nstruct DealerShared {\n    message_handlers: Mutex<SubscriberMap<MessageHandler>>,\n    request_handlers: Mutex<HandlerMap<Box<dyn RequestHandler>>>,\n\n    // Semaphore with 0 permits. By closing this semaphore, we indicate\n    // that the actual Dealer struct has been dropped.\n    notify_drop: Semaphore,\n}\n\nimpl DealerShared {\n    fn dispatch_message(&self, mut msg: WebsocketMessage) {\n        let msg = match msg.handle_payload() {\n            Ok(value) => Message {\n                headers: msg.headers,\n                payload: value,\n                uri: msg.uri,\n            },\n            Err(why) => {\n                warn!(\"failure during data parsing for {}: {why}\", msg.uri);\n                return;\n            }\n        };\n\n        if let Some(split) = split_uri(&msg.uri) {\n            if self\n                .message_handlers\n                .lock()\n                .expect(DEALER_MESSAGE_HANDLERS_POISON_MSG)\n                .retain(split, &mut |tx| tx.send(msg.clone()).is_ok())\n            {\n                return;\n            }\n        }\n\n        debug!(\"No subscriber for msg.uri: {}\", msg.uri);\n    }\n\n    fn dispatch_request(\n        &self,\n        request: WebsocketRequest,\n        send_tx: &mpsc::UnboundedSender<WsMessage>,\n    ) {\n        trace!(\"dealer request {}\", &request.message_ident);\n\n        let payload_request = match request.handle_payload() {\n            Ok(payload) => payload,\n            Err(why) => {\n                warn!(\"request payload handling failed because of {why}\");\n                return;\n            }\n        };\n\n        // ResponseSender will automatically send \"success: false\" if it is dropped without an answer.\n        let responder = Responder::new(request.key.clone(), send_tx.clone());\n\n        let split = if let Some(split) = split_uri(&request.message_ident) {\n            split\n        } else {\n            warn!(\n                \"Dealer request with invalid message_ident: {}\",\n                &request.message_ident\n            );\n            return;\n        };\n\n        let handler_map = self\n            .request_handlers\n            .lock()\n            .expect(DEALER_REQUEST_HANDLERS_POISON_MSG);\n\n        if let Some(handler) = handler_map.get(split) {\n            handler.handle_request(payload_request, responder);\n            return;\n        }\n\n        warn!(\"No handler for message_ident: {}\", &request.message_ident);\n    }\n\n    fn dispatch(&self, m: MessageOrRequest, send_tx: &mpsc::UnboundedSender<WsMessage>) {\n        match m {\n            MessageOrRequest::Message(m) => self.dispatch_message(m),\n            MessageOrRequest::Request(r) => self.dispatch_request(r, send_tx),\n        }\n    }\n\n    async fn closed(&self) {\n        if self.notify_drop.acquire().await.is_ok() {\n            error!(\"should never have gotten a permit\");\n        }\n    }\n\n    fn is_closed(&self) -> bool {\n        self.notify_drop.is_closed()\n    }\n}\n\nstruct Dealer {\n    shared: Arc<DealerShared>,\n    handle: TimeoutOnDrop<Result<(), Error>>,\n}\n\nimpl Dealer {\n    pub fn add_handler<H>(&self, uri: &str, handler: H) -> Result<(), Error>\n    where\n        H: RequestHandler,\n    {\n        add_handler(\n            &mut self\n                .shared\n                .request_handlers\n                .lock()\n                .expect(DEALER_REQUEST_HANDLERS_POISON_MSG),\n            uri,\n            handler,\n        )\n    }\n\n    pub fn remove_handler(&self, uri: &str) -> Option<Box<dyn RequestHandler>> {\n        remove_handler(\n            &mut self\n                .shared\n                .request_handlers\n                .lock()\n                .expect(DEALER_REQUEST_HANDLERS_POISON_MSG),\n            uri,\n        )\n    }\n\n    pub fn subscribe(&self, uris: &[&str]) -> Result<Subscription, Error> {\n        subscribe(\n            &mut self\n                .shared\n                .message_handlers\n                .lock()\n                .expect(DEALER_MESSAGE_HANDLERS_POISON_MSG),\n            uris,\n        )\n    }\n\n    pub fn handles(&self, uri: &str) -> bool {\n        handles(\n            &self\n                .shared\n                .request_handlers\n                .lock()\n                .expect(DEALER_REQUEST_HANDLERS_POISON_MSG),\n            &self\n                .shared\n                .message_handlers\n                .lock()\n                .expect(DEALER_MESSAGE_HANDLERS_POISON_MSG),\n            uri,\n        )\n    }\n\n    pub async fn close(mut self) {\n        debug!(\"closing dealer\");\n\n        self.shared.notify_drop.close();\n\n        if let Some(handle) = self.handle.take() {\n            if let Err(e) = CancelOnDrop(handle).await {\n                error!(\"error aborting dealer operations: {e}\");\n            }\n        }\n    }\n}\n\n/// Initializes a connection and returns futures that will finish when the connection is closed/lost.\nasync fn connect(\n    address: &Url,\n    proxy: Option<&Url>,\n    shared: &Arc<DealerShared>,\n) -> WsResult<(JoinHandle<()>, JoinHandle<()>)> {\n    let host = address\n        .host_str()\n        .ok_or(WsError::Url(UrlError::NoHostName))?;\n\n    let default_port = match address.scheme() {\n        \"ws\" => 80,\n        \"wss\" => 443,\n        _ => return Err(WsError::Url(UrlError::UnsupportedUrlScheme).into()),\n    };\n\n    let port = address.port().unwrap_or(default_port);\n\n    let stream = socket::connect(host, port, proxy).await?;\n\n    let (mut ws_tx, ws_rx) = tokio_tungstenite::client_async_tls(address.as_str(), stream)\n        .await?\n        .0\n        .split();\n\n    let (send_tx, mut send_rx) = mpsc::unbounded_channel::<WsMessage>();\n\n    // Spawn a task that will forward messages from the channel to the websocket.\n    let send_task = {\n        let shared = Arc::clone(shared);\n\n        tokio::spawn(async move {\n            let result = loop {\n                select! {\n                    biased;\n                    () = shared.closed() => {\n                        break Ok(None);\n                    }\n                    msg = send_rx.recv() => {\n                        if let Some(msg) = msg {\n                            // New message arrived through channel\n                            if let WsMessage::Close(close_frame) = msg {\n                                break Ok(close_frame);\n                            }\n\n                            if let Err(e) = ws_tx.feed(msg).await  {\n                                break Err(e);\n                            }\n                        } else {\n                            break Ok(None);\n                        }\n                    },\n                    e = keep_flushing(&mut ws_tx) => {\n                        break Err(e)\n                    }\n                    else => (),\n                }\n            };\n\n            send_rx.close();\n\n            // I don't trust in tokio_tungstenite's implementation of Sink::close.\n            let result = match result {\n                Ok(close_frame) => ws_tx.send(WsMessage::Close(close_frame)).await,\n                Err(WsError::AlreadyClosed) | Err(WsError::ConnectionClosed) => ws_tx.flush().await,\n                Err(e) => {\n                    warn!(\"Dealer finished with an error: {e}\");\n                    ws_tx.send(WsMessage::Close(None)).await\n                }\n            };\n\n            if let Err(e) = result {\n                warn!(\"Error while closing websocket: {e}\");\n            }\n\n            debug!(\"Dropping send task\");\n        })\n    };\n\n    let shared = Arc::clone(shared);\n\n    // A task that receives messages from the web socket.\n    let receive_task = tokio::spawn(async {\n        let pong_received = AtomicBool::new(true);\n        let send_tx = send_tx;\n        let shared = shared;\n\n        let receive_task = async {\n            let mut ws_rx = ws_rx;\n\n            loop {\n                match ws_rx.next().await {\n                    Some(Ok(msg)) => match msg {\n                        WsMessage::Text(t) => match serde_json::from_str(&t) {\n                            Ok(m) => shared.dispatch(m, &send_tx),\n                            Err(e) => warn!(\"Message couldn't be parsed: {e}. Message was {t}\"),\n                        },\n                        WsMessage::Binary(_) => {\n                            info!(\"Received invalid binary message\");\n                        }\n                        WsMessage::Pong(_) => {\n                            trace!(\"Received pong\");\n                            pong_received.store(true, atomic::Ordering::Relaxed);\n                        }\n                        _ => (), // tungstenite handles Close and Ping automatically\n                    },\n                    Some(Err(e)) => {\n                        warn!(\"Websocket connection failed: {e}\");\n                        break;\n                    }\n                    None => {\n                        debug!(\"Websocket connection closed.\");\n                        break;\n                    }\n                }\n            }\n        };\n\n        // Sends pings and checks whether a pong comes back.\n        let ping_task = async {\n            use tokio::time::{interval, sleep};\n\n            let mut timer = interval(PING_INTERVAL);\n\n            loop {\n                timer.tick().await;\n\n                pong_received.store(false, atomic::Ordering::Relaxed);\n                if send_tx\n                    .send(WsMessage::Ping(bytes::Bytes::default()))\n                    .is_err()\n                {\n                    // The sender is closed.\n                    break;\n                }\n\n                trace!(\"Sent ping\");\n\n                sleep(PING_TIMEOUT).await;\n\n                if !pong_received.load(atomic::Ordering::SeqCst) {\n                    // No response\n                    warn!(\"Websocket peer does not respond.\");\n                    break;\n                }\n            }\n        };\n\n        // Exit this task as soon as one our subtasks fails.\n        // In both cases the connection is probably lost.\n        select! {\n            () = ping_task => (),\n            () = receive_task => ()\n        }\n\n        // Try to take send_task down with us, in case it's still alive.\n        let _ = send_tx.send(WsMessage::Close(None));\n\n        debug!(\"Dropping receive task\");\n    });\n\n    Ok((send_task, receive_task))\n}\n\n/// The main background task for `Dealer`, which coordinates reconnecting.\nasync fn run<F, Fut>(\n    shared: Arc<DealerShared>,\n    initial_tasks: Option<(JoinHandle<()>, JoinHandle<()>)>,\n    mut get_url: F,\n    proxy: Option<Url>,\n) -> Result<(), Error>\nwhere\n    Fut: Future<Output = GetUrlResult> + Send + 'static,\n    F: (FnMut() -> Fut) + Send + 'static,\n{\n    let init_task = |t| Some(TimeoutOnDrop::new(t, WEBSOCKET_CLOSE_TIMEOUT));\n\n    let mut tasks = if let Some((s, r)) = initial_tasks {\n        (init_task(s), init_task(r))\n    } else {\n        (None, None)\n    };\n\n    while !shared.is_closed() {\n        match &mut tasks {\n            (Some(t0), Some(t1)) => {\n                select! {\n                    () = shared.closed() => break,\n                    r = t0 => {\n                        if let Err(e) = r {\n                            error!(\"timeout on task 0: {e}\");\n                        }\n                        tasks.0.take();\n                    },\n                    r = t1 => {\n                        if let Err(e) = r {\n                            error!(\"timeout on task 1: {e}\");\n                        }\n                        tasks.1.take();\n                    }\n                }\n            }\n            _ => {\n                let url = select! {\n                    () = shared.closed() => {\n                        break\n                    },\n                    e = get_url() => e\n                }?;\n\n                match connect(&url, proxy.as_ref(), &shared).await {\n                    Ok((s, r)) => tasks = (init_task(s), init_task(r)),\n                    Err(e) => {\n                        error!(\"Error while connecting: {e}\");\n                        tokio::time::sleep(RECONNECT_INTERVAL).await;\n                    }\n                }\n            }\n        }\n    }\n\n    let tasks = tasks.0.into_iter().chain(tasks.1);\n\n    let _ = join_all(tasks).await;\n\n    Ok(())\n}\n"
  },
  {
    "path": "core/src/dealer/protocol/request.rs",
    "content": "use crate::{\n    deserialize_with::*,\n    protocol::{\n        context::Context,\n        context_player_options::ContextPlayerOptionOverrides,\n        player::{PlayOrigin, ProvidedTrack},\n        transfer_state::TransferState,\n    },\n};\nuse serde::{Deserialize, Serialize};\nuse serde_json::Value;\nuse std::fmt::{Display, Formatter};\n\n#[derive(Clone, Debug, Deserialize)]\npub struct Request {\n    pub message_id: u32,\n    // todo: did only send target_alias_id: null so far, maybe we just ignore it, will see\n    // pub target_alias_id: Option<()>,\n    pub sent_by_device_id: String,\n    pub command: Command,\n}\n\n#[derive(Clone, Debug, Deserialize)]\n#[serde(tag = \"endpoint\", rename_all = \"snake_case\")]\npub enum Command {\n    Transfer(TransferCommand),\n    #[serde(deserialize_with = \"boxed\")]\n    Play(Box<PlayCommand>),\n    Pause(PauseCommand),\n    SeekTo(SeekToCommand),\n    SetShufflingContext(SetValueCommand),\n    SetRepeatingTrack(SetValueCommand),\n    SetRepeatingContext(SetValueCommand),\n    AddToQueue(AddToQueueCommand),\n    SetQueue(SetQueueCommand),\n    SetOptions(SetOptionsCommand),\n    UpdateContext(UpdateContextCommand),\n    SkipNext(SkipNextCommand),\n    // commands that don't send any context (at least not usually...)\n    SkipPrev(GenericCommand),\n    Resume(GenericCommand),\n    // catch unknown commands, so that we can implement them later\n    #[serde(untagged)]\n    Unknown(Value),\n}\n\nimpl Display for Command {\n    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {\n        use Command::*;\n\n        write!(\n            f,\n            \"endpoint: {}{}\",\n            matches!(self, Unknown(_))\n                .then_some(\"unknown \")\n                .unwrap_or_default(),\n            match self {\n                Transfer(_) => \"transfer\",\n                Play(_) => \"play\",\n                Pause(_) => \"pause\",\n                SeekTo(_) => \"seek_to\",\n                SetShufflingContext(_) => \"set_shuffling_context\",\n                SetRepeatingContext(_) => \"set_repeating_context\",\n                SetRepeatingTrack(_) => \"set_repeating_track\",\n                AddToQueue(_) => \"add_to_queue\",\n                SetQueue(_) => \"set_queue\",\n                SetOptions(_) => \"set_options\",\n                UpdateContext(_) => \"update_context\",\n                SkipNext(_) => \"skip_next\",\n                SkipPrev(_) => \"skip_prev\",\n                Resume(_) => \"resume\",\n                Unknown(json) => {\n                    json.as_object()\n                        .and_then(|obj| obj.get(\"endpoint\").map(|v| v.as_str()))\n                        .flatten()\n                        .unwrap_or(\"???\")\n                }\n            }\n        )\n    }\n}\n\n#[derive(Clone, Debug, Deserialize)]\npub struct TransferCommand {\n    #[serde(default, deserialize_with = \"base64_proto\")]\n    pub data: Option<TransferState>,\n    pub options: TransferOptions,\n    pub from_device_identifier: String,\n    pub logging_params: LoggingParams,\n}\n\n#[derive(Clone, Debug, Deserialize)]\npub struct PlayCommand {\n    #[serde(deserialize_with = \"json_proto\")]\n    pub context: Context,\n    #[serde(deserialize_with = \"json_proto\")]\n    pub play_origin: PlayOrigin,\n    pub options: PlayOptions,\n    pub logging_params: LoggingParams,\n}\n\n#[derive(Clone, Debug, Deserialize)]\npub struct PauseCommand {\n    // does send options with it, but seems to be empty, investigate which options are send here\n    pub logging_params: LoggingParams,\n}\n\n#[derive(Clone, Debug, Deserialize)]\npub struct SeekToCommand {\n    pub value: u32,\n    pub position: u32,\n    pub logging_params: LoggingParams,\n}\n\n#[derive(Clone, Debug, Deserialize)]\npub struct SkipNextCommand {\n    #[serde(default, deserialize_with = \"option_json_proto\")]\n    pub track: Option<ProvidedTrack>,\n    pub logging_params: LoggingParams,\n}\n\n#[derive(Clone, Debug, Deserialize)]\npub struct SetValueCommand {\n    pub value: bool,\n    pub logging_params: LoggingParams,\n}\n\n#[derive(Clone, Debug, Deserialize)]\npub struct AddToQueueCommand {\n    #[serde(deserialize_with = \"json_proto\")]\n    pub track: ProvidedTrack,\n    pub logging_params: LoggingParams,\n}\n\n#[derive(Clone, Debug, Deserialize)]\npub struct SetQueueCommand {\n    #[serde(deserialize_with = \"vec_json_proto\")]\n    pub next_tracks: Vec<ProvidedTrack>,\n    #[serde(deserialize_with = \"vec_json_proto\")]\n    pub prev_tracks: Vec<ProvidedTrack>,\n    // this queue revision is actually the last revision, so using it will not update the web ui\n    // might be that internally they use the last revision to create the next revision\n    pub queue_revision: String,\n    pub logging_params: LoggingParams,\n}\n\n#[derive(Clone, Debug, Deserialize)]\npub struct SetOptionsCommand {\n    pub shuffling_context: Option<bool>,\n    pub repeating_context: Option<bool>,\n    pub repeating_track: Option<bool>,\n    pub options: Option<OptionsOptions>,\n    pub logging_params: LoggingParams,\n}\n\n#[derive(Clone, Debug, Deserialize)]\npub struct UpdateContextCommand {\n    #[serde(deserialize_with = \"json_proto\")]\n    pub context: Context,\n    pub session_id: Option<String>,\n}\n\n#[derive(Clone, Debug, Deserialize)]\npub struct GenericCommand {\n    pub logging_params: LoggingParams,\n}\n\n#[derive(Clone, Debug, Default, Deserialize, Serialize)]\npub struct TransferOptions {\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub restore_paused: Option<String>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub restore_position: Option<String>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub restore_track: Option<String>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub retain_session: Option<String>,\n}\n\n#[derive(Clone, Debug, Deserialize)]\npub struct PlayOptions {\n    pub skip_to: Option<SkipTo>,\n    #[serde(default, deserialize_with = \"option_json_proto\")]\n    pub player_options_override: Option<ContextPlayerOptionOverrides>,\n    pub license: Option<String>,\n    // possible to send wie web-api\n    pub seek_to: Option<u32>,\n    // mobile\n    pub always_play_something: Option<bool>,\n    pub audio_stream: Option<String>,\n    pub initially_paused: Option<bool>,\n    pub prefetch_level: Option<String>,\n    pub system_initiated: Option<bool>,\n}\n\n#[derive(Clone, Debug, Deserialize)]\npub struct OptionsOptions {\n    only_for_local_device: bool,\n    override_restrictions: bool,\n    system_initiated: bool,\n}\n\n#[derive(Clone, Debug, Deserialize, Default)]\npub struct SkipTo {\n    pub track_uid: Option<String>,\n    pub track_uri: Option<String>,\n    pub track_index: Option<u32>,\n}\n\n#[derive(Clone, Debug, Deserialize)]\npub struct LoggingParams {\n    pub interaction_ids: Option<Vec<String>>,\n    pub device_identifier: Option<String>,\n    pub command_initiated_time: Option<i64>,\n    pub page_instance_ids: Option<Vec<String>>,\n    pub command_id: Option<String>,\n}\n"
  },
  {
    "path": "core/src/dealer/protocol.rs",
    "content": "pub mod request;\n\npub use request::*;\n\nuse std::collections::HashMap;\nuse std::io::{Error as IoError, Read};\n\nuse crate::{Error, deserialize_with::json_proto};\nuse base64::{DecodeError, Engine, prelude::BASE64_STANDARD};\nuse flate2::read::GzDecoder;\nuse log::LevelFilter;\nuse serde::Deserialize;\nuse serde_json::Error as SerdeError;\nuse thiserror::Error;\n\nconst IGNORE_UNKNOWN: protobuf_json_mapping::ParseOptions = protobuf_json_mapping::ParseOptions {\n    ignore_unknown_fields: true,\n    _future_options: (),\n};\n\ntype JsonValue = serde_json::Value;\n\n#[derive(Debug, Error)]\nenum ProtocolError {\n    #[error(\"base64 decoding failed: {0}\")]\n    Base64(DecodeError),\n    #[error(\"gzip decoding failed: {0}\")]\n    GZip(IoError),\n    #[error(\"deserialization failed: {0}\")]\n    Deserialization(SerdeError),\n    #[error(\"payload had more then one value. had {0} values\")]\n    MoreThenOneValue(usize),\n    #[error(\"received unexpected data {0:#?}\")]\n    UnexpectedData(PayloadValue),\n    #[error(\"payload was empty\")]\n    Empty,\n}\n\nimpl From<ProtocolError> for Error {\n    fn from(err: ProtocolError) -> Self {\n        match err {\n            ProtocolError::UnexpectedData(_) => Error::unavailable(err),\n            _ => Error::failed_precondition(err),\n        }\n    }\n}\n\n#[derive(Clone, Debug, Deserialize)]\npub(super) struct Payload {\n    pub compressed: String,\n}\n\n#[derive(Clone, Debug, Deserialize)]\npub(super) struct WebsocketRequest {\n    #[serde(default)]\n    pub headers: HashMap<String, String>,\n    pub message_ident: String,\n    pub key: String,\n    pub payload: Payload,\n}\n\n#[derive(Clone, Debug, Deserialize)]\npub(super) struct WebsocketMessage {\n    #[serde(default)]\n    pub headers: HashMap<String, String>,\n    pub method: Option<String>,\n    #[serde(default)]\n    pub payloads: Vec<MessagePayloadValue>,\n    pub uri: String,\n}\n\n#[derive(Clone, Debug, Deserialize)]\n#[serde(untagged)]\npub enum MessagePayloadValue {\n    String(String),\n    Bytes(Vec<u8>),\n    Json(JsonValue),\n}\n\n#[derive(Clone, Debug, Deserialize)]\n#[serde(tag = \"type\", rename_all = \"snake_case\")]\npub(super) enum MessageOrRequest {\n    Message(WebsocketMessage),\n    Request(WebsocketRequest),\n}\n\n#[derive(Clone, Debug)]\npub enum PayloadValue {\n    Empty,\n    Raw(Vec<u8>),\n    Json(String),\n}\n\n#[derive(Clone, Debug)]\npub struct Message {\n    pub headers: HashMap<String, String>,\n    pub payload: PayloadValue,\n    pub uri: String,\n}\n\n#[derive(Deserialize)]\n#[serde(untagged)]\npub enum FallbackWrapper<T: protobuf::MessageFull> {\n    Inner(#[serde(deserialize_with = \"json_proto\")] T),\n    Fallback(JsonValue),\n}\n\nimpl Message {\n    pub fn try_from_json<M: protobuf::MessageFull>(\n        value: Self,\n    ) -> Result<FallbackWrapper<M>, Error> {\n        match value.payload {\n            PayloadValue::Json(json) => Ok(serde_json::from_str(&json)?),\n            other => Err(ProtocolError::UnexpectedData(other).into()),\n        }\n    }\n\n    pub fn from_raw<M: protobuf::Message>(value: Self) -> Result<M, Error> {\n        match value.payload {\n            PayloadValue::Raw(bytes) => {\n                M::parse_from_bytes(&bytes).map_err(Error::failed_precondition)\n            }\n            other => Err(ProtocolError::UnexpectedData(other).into()),\n        }\n    }\n}\n\nimpl WebsocketMessage {\n    pub fn handle_payload(&mut self) -> Result<PayloadValue, Error> {\n        if self.payloads.is_empty() {\n            return Ok(PayloadValue::Empty);\n        } else if self.payloads.len() > 1 {\n            return Err(ProtocolError::MoreThenOneValue(self.payloads.len()).into());\n        }\n\n        let payload = self.payloads.pop().ok_or(ProtocolError::Empty)?;\n        let bytes = match payload {\n            MessagePayloadValue::String(string) => BASE64_STANDARD\n                .decode(string)\n                .map_err(ProtocolError::Base64)?,\n            MessagePayloadValue::Bytes(bytes) => bytes,\n            MessagePayloadValue::Json(json) => return Ok(PayloadValue::Json(json.to_string())),\n        };\n\n        handle_transfer_encoding(&self.headers, bytes).map(PayloadValue::Raw)\n    }\n}\n\nimpl WebsocketRequest {\n    pub fn handle_payload(&self) -> Result<Request, Error> {\n        let payload_bytes = BASE64_STANDARD\n            .decode(&self.payload.compressed)\n            .map_err(ProtocolError::Base64)?;\n\n        let payload = handle_transfer_encoding(&self.headers, payload_bytes)?;\n        let payload = String::from_utf8(payload)?;\n\n        if log::max_level() >= LevelFilter::Trace {\n            if let Ok(json) = serde_json::from_str::<serde_json::Value>(&payload) {\n                trace!(\"websocket request: {json:#?}\");\n            } else {\n                trace!(\"websocket request: {payload}\");\n            }\n        }\n\n        serde_json::from_str(&payload)\n            .map_err(ProtocolError::Deserialization)\n            .map_err(Into::into)\n    }\n}\n\nfn handle_transfer_encoding(\n    headers: &HashMap<String, String>,\n    data: Vec<u8>,\n) -> Result<Vec<u8>, Error> {\n    let encoding = headers.get(\"Transfer-Encoding\").map(String::as_str);\n    if let Some(encoding) = encoding {\n        trace!(\"message was sent with {encoding} encoding \");\n    } else {\n        trace!(\"message was sent with no encoding \");\n    }\n\n    if !matches!(encoding, Some(\"gzip\")) {\n        return Ok(data);\n    }\n\n    let mut gz = GzDecoder::new(&data[..]);\n    let mut bytes = vec![];\n    match gz.read_to_end(&mut bytes) {\n        Ok(i) if i == bytes.len() => Ok(bytes),\n        Ok(_) => Err(Error::failed_precondition(\n            \"read bytes mismatched with expected bytes\",\n        )),\n        Err(why) => Err(ProtocolError::GZip(why).into()),\n    }\n}\n"
  },
  {
    "path": "core/src/deserialize_with.rs",
    "content": "use base64::Engine;\nuse base64::prelude::BASE64_STANDARD;\nuse protobuf::MessageFull;\nuse serde::de::{Error, Unexpected};\nuse serde::{Deserialize, Deserializer};\nuse serde_json::Value;\n\nconst IGNORE_UNKNOWN: protobuf_json_mapping::ParseOptions = protobuf_json_mapping::ParseOptions {\n    ignore_unknown_fields: true,\n    _future_options: (),\n};\n\nfn parse_value_to_msg<T: MessageFull>(\n    value: &Value,\n) -> Result<T, protobuf_json_mapping::ParseError> {\n    protobuf_json_mapping::parse_from_str_with_options::<T>(&value.to_string(), &IGNORE_UNKNOWN)\n}\n\npub fn base64_proto<'de, T, D>(de: D) -> Result<Option<T>, D::Error>\nwhere\n    T: MessageFull,\n    D: Deserializer<'de>,\n{\n    let v: String = Deserialize::deserialize(de)?;\n    let bytes = BASE64_STANDARD\n        .decode(v)\n        .map_err(|e| Error::custom(e.to_string()))?;\n\n    T::parse_from_bytes(&bytes).map(Some).map_err(Error::custom)\n}\n\npub fn json_proto<'de, T, D>(de: D) -> Result<T, D::Error>\nwhere\n    T: MessageFull,\n    D: Deserializer<'de>,\n{\n    let v: Value = Deserialize::deserialize(de)?;\n    parse_value_to_msg(&v).map_err(Error::custom)\n}\n\npub fn option_json_proto<'de, T, D>(de: D) -> Result<Option<T>, D::Error>\nwhere\n    T: MessageFull,\n    D: Deserializer<'de>,\n{\n    let v: Value = Deserialize::deserialize(de)?;\n    parse_value_to_msg(&v).map(Some).map_err(Error::custom)\n}\n\npub fn vec_json_proto<'de, T, D>(de: D) -> Result<Vec<T>, D::Error>\nwhere\n    T: MessageFull,\n    D: Deserializer<'de>,\n{\n    let v: Value = Deserialize::deserialize(de)?;\n    let array = match v {\n        Value::Array(array) => array,\n        _ => return Err(Error::custom(\"the value wasn't an array\")),\n    };\n\n    let res = array\n        .iter()\n        .flat_map(parse_value_to_msg)\n        .collect::<Vec<T>>();\n\n    Ok(res)\n}\n\npub fn boxed<'de, T, D>(de: D) -> Result<Box<T>, D::Error>\nwhere\n    T: Deserialize<'de>,\n    D: Deserializer<'de>,\n{\n    let v: T = Deserialize::deserialize(de)?;\n    Ok(Box::new(v))\n}\n\npub fn bool_from_string<'de, D>(de: D) -> Result<bool, D::Error>\nwhere\n    D: Deserializer<'de>,\n{\n    match String::deserialize(de)?.as_ref() {\n        \"true\" => Ok(true),\n        \"false\" => Ok(false),\n        other => Err(Error::invalid_value(\n            Unexpected::Str(other),\n            &\"true or false\",\n        )),\n    }\n}\n"
  },
  {
    "path": "core/src/diffie_hellman.rs",
    "content": "use std::sync::LazyLock;\n\nuse num_bigint::BigUint;\nuse num_integer::Integer;\nuse num_traits::{One, Zero};\nuse rand::{CryptoRng, Rng};\n\nstatic DH_GENERATOR: LazyLock<BigUint> = LazyLock::new(|| BigUint::from_bytes_be(&[0x02]));\nstatic DH_PRIME: LazyLock<BigUint> = LazyLock::new(|| {\n    BigUint::from_bytes_be(&[\n        0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xc9, 0x0f, 0xda, 0xa2, 0x21, 0x68, 0xc2,\n        0x34, 0xc4, 0xc6, 0x62, 0x8b, 0x80, 0xdc, 0x1c, 0xd1, 0x29, 0x02, 0x4e, 0x08, 0x8a, 0x67,\n        0xcc, 0x74, 0x02, 0x0b, 0xbe, 0xa6, 0x3b, 0x13, 0x9b, 0x22, 0x51, 0x4a, 0x08, 0x79, 0x8e,\n        0x34, 0x04, 0xdd, 0xef, 0x95, 0x19, 0xb3, 0xcd, 0x3a, 0x43, 0x1b, 0x30, 0x2b, 0x0a, 0x6d,\n        0xf2, 0x5f, 0x14, 0x37, 0x4f, 0xe1, 0x35, 0x6d, 0x6d, 0x51, 0xc2, 0x45, 0xe4, 0x85, 0xb5,\n        0x76, 0x62, 0x5e, 0x7e, 0xc6, 0xf4, 0x4c, 0x42, 0xe9, 0xa6, 0x3a, 0x36, 0x20, 0xff, 0xff,\n        0xff, 0xff, 0xff, 0xff, 0xff, 0xff,\n    ])\n});\n\nfn powm(base: &BigUint, exp: &BigUint, modulus: &BigUint) -> BigUint {\n    let mut base = base.clone();\n    let mut exp = exp.clone();\n    let mut result: BigUint = One::one();\n\n    while !exp.is_zero() {\n        if exp.is_odd() {\n            result = (result * &base) % modulus;\n        }\n        exp >>= 1;\n        base = (&base * &base) % modulus;\n    }\n\n    result\n}\n\npub struct DhLocalKeys {\n    private_key: BigUint,\n    public_key: BigUint,\n}\n\nimpl DhLocalKeys {\n    pub fn random<R: Rng + CryptoRng>(rng: &mut R) -> DhLocalKeys {\n        let mut bytes = [0u8; 95];\n        rng.fill_bytes(&mut bytes);\n        let private_key = BigUint::from_bytes_le(&bytes);\n        let public_key = powm(&DH_GENERATOR, &private_key, &DH_PRIME);\n\n        DhLocalKeys {\n            private_key,\n            public_key,\n        }\n    }\n\n    pub fn public_key(&self) -> Vec<u8> {\n        self.public_key.to_bytes_be()\n    }\n\n    pub fn shared_secret(&self, remote_key: &[u8]) -> Vec<u8> {\n        let shared_key = powm(\n            &BigUint::from_bytes_be(remote_key),\n            &self.private_key,\n            &DH_PRIME,\n        );\n        shared_key.to_bytes_be()\n    }\n}\n"
  },
  {
    "path": "core/src/error.rs",
    "content": "use std::{\n    error, fmt,\n    num::{ParseIntError, TryFromIntError},\n    str::Utf8Error,\n    string::FromUtf8Error,\n};\n\nuse base64::DecodeError;\nuse http::{\n    header::{InvalidHeaderName, InvalidHeaderValue, ToStrError},\n    method::InvalidMethod,\n    status::InvalidStatusCode,\n    uri::{InvalidUri, InvalidUriParts},\n};\nuse protobuf::Error as ProtobufError;\nuse thiserror::Error;\nuse tokio::sync::{\n    AcquireError, TryAcquireError, mpsc::error::SendError, oneshot::error::RecvError,\n};\nuse url::ParseError;\n\nuse librespot_oauth::OAuthError;\n\n#[derive(Debug)]\npub struct Error {\n    pub kind: ErrorKind,\n    pub error: Box<dyn error::Error + Send + Sync>,\n}\n\n#[derive(Clone, Copy, Debug, Eq, Error, Hash, Ord, PartialEq, PartialOrd)]\npub enum ErrorKind {\n    #[error(\"The operation was cancelled by the caller\")]\n    Cancelled = 1,\n\n    #[error(\"Unknown error\")]\n    Unknown = 2,\n\n    #[error(\"Client specified an invalid argument\")]\n    InvalidArgument = 3,\n\n    #[error(\"Deadline expired before operation could complete\")]\n    DeadlineExceeded = 4,\n\n    #[error(\"Requested entity was not found\")]\n    NotFound = 5,\n\n    #[error(\"Attempt to create entity that already exists\")]\n    AlreadyExists = 6,\n\n    #[error(\"Permission denied\")]\n    PermissionDenied = 7,\n\n    #[error(\"No valid authentication credentials\")]\n    Unauthenticated = 16,\n\n    #[error(\"Resource has been exhausted\")]\n    ResourceExhausted = 8,\n\n    #[error(\"Invalid state\")]\n    FailedPrecondition = 9,\n\n    #[error(\"Operation aborted\")]\n    Aborted = 10,\n\n    #[error(\"Operation attempted past the valid range\")]\n    OutOfRange = 11,\n\n    #[error(\"Not implemented\")]\n    Unimplemented = 12,\n\n    #[error(\"Internal error\")]\n    Internal = 13,\n\n    #[error(\"Service unavailable\")]\n    Unavailable = 14,\n\n    #[error(\"Unrecoverable data loss or corruption\")]\n    DataLoss = 15,\n\n    #[error(\"Operation must not be used\")]\n    DoNotUse = -1,\n}\n\n#[derive(Debug, Error)]\nstruct ErrorMessage(String);\n\nimpl fmt::Display for ErrorMessage {\n    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n        write!(f, \"{}\", self.0)\n    }\n}\n\nimpl Error {\n    pub fn new<E>(kind: ErrorKind, error: E) -> Error\n    where\n        E: Into<Box<dyn error::Error + Send + Sync>>,\n    {\n        Self {\n            kind,\n            error: error.into(),\n        }\n    }\n\n    pub fn aborted<E>(error: E) -> Error\n    where\n        E: Into<Box<dyn error::Error + Send + Sync>>,\n    {\n        Self {\n            kind: ErrorKind::Aborted,\n            error: error.into(),\n        }\n    }\n\n    pub fn already_exists<E>(error: E) -> Error\n    where\n        E: Into<Box<dyn error::Error + Send + Sync>>,\n    {\n        Self {\n            kind: ErrorKind::AlreadyExists,\n            error: error.into(),\n        }\n    }\n\n    pub fn cancelled<E>(error: E) -> Error\n    where\n        E: Into<Box<dyn error::Error + Send + Sync>>,\n    {\n        Self {\n            kind: ErrorKind::Cancelled,\n            error: error.into(),\n        }\n    }\n\n    pub fn data_loss<E>(error: E) -> Error\n    where\n        E: Into<Box<dyn error::Error + Send + Sync>>,\n    {\n        Self {\n            kind: ErrorKind::DataLoss,\n            error: error.into(),\n        }\n    }\n\n    pub fn deadline_exceeded<E>(error: E) -> Error\n    where\n        E: Into<Box<dyn error::Error + Send + Sync>>,\n    {\n        Self {\n            kind: ErrorKind::DeadlineExceeded,\n            error: error.into(),\n        }\n    }\n\n    pub fn do_not_use<E>(error: E) -> Error\n    where\n        E: Into<Box<dyn error::Error + Send + Sync>>,\n    {\n        Self {\n            kind: ErrorKind::DoNotUse,\n            error: error.into(),\n        }\n    }\n\n    pub fn failed_precondition<E>(error: E) -> Error\n    where\n        E: Into<Box<dyn error::Error + Send + Sync>>,\n    {\n        Self {\n            kind: ErrorKind::FailedPrecondition,\n            error: error.into(),\n        }\n    }\n\n    pub fn internal<E>(error: E) -> Error\n    where\n        E: Into<Box<dyn error::Error + Send + Sync>>,\n    {\n        Self {\n            kind: ErrorKind::Internal,\n            error: error.into(),\n        }\n    }\n\n    pub fn invalid_argument<E>(error: E) -> Error\n    where\n        E: Into<Box<dyn error::Error + Send + Sync>>,\n    {\n        Self {\n            kind: ErrorKind::InvalidArgument,\n            error: error.into(),\n        }\n    }\n\n    pub fn not_found<E>(error: E) -> Error\n    where\n        E: Into<Box<dyn error::Error + Send + Sync>>,\n    {\n        Self {\n            kind: ErrorKind::NotFound,\n            error: error.into(),\n        }\n    }\n\n    pub fn out_of_range<E>(error: E) -> Error\n    where\n        E: Into<Box<dyn error::Error + Send + Sync>>,\n    {\n        Self {\n            kind: ErrorKind::OutOfRange,\n            error: error.into(),\n        }\n    }\n\n    pub fn permission_denied<E>(error: E) -> Error\n    where\n        E: Into<Box<dyn error::Error + Send + Sync>>,\n    {\n        Self {\n            kind: ErrorKind::PermissionDenied,\n            error: error.into(),\n        }\n    }\n\n    pub fn resource_exhausted<E>(error: E) -> Error\n    where\n        E: Into<Box<dyn error::Error + Send + Sync>>,\n    {\n        Self {\n            kind: ErrorKind::ResourceExhausted,\n            error: error.into(),\n        }\n    }\n\n    pub fn unauthenticated<E>(error: E) -> Error\n    where\n        E: Into<Box<dyn error::Error + Send + Sync>>,\n    {\n        Self {\n            kind: ErrorKind::Unauthenticated,\n            error: error.into(),\n        }\n    }\n\n    pub fn unavailable<E>(error: E) -> Error\n    where\n        E: Into<Box<dyn error::Error + Send + Sync>>,\n    {\n        Self {\n            kind: ErrorKind::Unavailable,\n            error: error.into(),\n        }\n    }\n\n    pub fn unimplemented<E>(error: E) -> Error\n    where\n        E: Into<Box<dyn error::Error + Send + Sync>>,\n    {\n        Self {\n            kind: ErrorKind::Unimplemented,\n            error: error.into(),\n        }\n    }\n\n    pub fn unknown<E>(error: E) -> Error\n    where\n        E: Into<Box<dyn error::Error + Send + Sync>>,\n    {\n        Self {\n            kind: ErrorKind::Unknown,\n            error: error.into(),\n        }\n    }\n}\n\nimpl std::error::Error for Error {\n    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {\n        self.error.source()\n    }\n}\n\nimpl fmt::Display for Error {\n    fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result {\n        write!(fmt, \"{} {{ \", self.kind)?;\n        self.error.fmt(fmt)?;\n        write!(fmt, \" }}\")\n    }\n}\n\nimpl From<OAuthError> for Error {\n    fn from(err: OAuthError) -> Self {\n        use OAuthError::*;\n        match err {\n            AuthCodeBadUri { .. }\n            | AuthCodeNotFound { .. }\n            | AuthCodeListenerRead\n            | AuthCodeListenerParse => Error::unavailable(err),\n            AuthCodeStdinRead\n            | AuthCodeListenerBind { .. }\n            | AuthCodeListenerTerminated\n            | AuthCodeListenerWrite\n            | Recv\n            | ExchangeCode { .. } => Error::internal(err),\n            _ => Error::failed_precondition(err),\n        }\n    }\n}\n\nimpl From<DecodeError> for Error {\n    fn from(err: DecodeError) -> Self {\n        Self::new(ErrorKind::FailedPrecondition, err)\n    }\n}\n\nimpl From<http::Error> for Error {\n    fn from(err: http::Error) -> Self {\n        if err.is::<InvalidHeaderName>()\n            || err.is::<InvalidHeaderValue>()\n            || err.is::<InvalidMethod>()\n            || err.is::<InvalidUri>()\n            || err.is::<InvalidUriParts>()\n        {\n            return Self::new(ErrorKind::InvalidArgument, err);\n        }\n\n        if err.is::<InvalidStatusCode>() {\n            return Self::new(ErrorKind::FailedPrecondition, err);\n        }\n\n        Self::new(ErrorKind::Unknown, err)\n    }\n}\n\nimpl From<hyper::Error> for Error {\n    fn from(err: hyper::Error) -> Self {\n        if err.is_parse() || err.is_parse_status() || err.is_user() {\n            return Self::new(ErrorKind::Internal, err);\n        }\n\n        if err.is_canceled() {\n            return Self::new(ErrorKind::Cancelled, err);\n        }\n\n        if err.is_incomplete_message() {\n            return Self::new(ErrorKind::DataLoss, err);\n        }\n\n        if err.is_body_write_aborted() || err.is_closed() {\n            return Self::new(ErrorKind::Aborted, err);\n        }\n\n        if err.is_timeout() {\n            return Self::new(ErrorKind::DeadlineExceeded, err);\n        }\n\n        Self::new(ErrorKind::Unknown, err)\n    }\n}\n\nimpl From<hyper_util::client::legacy::Error> for Error {\n    fn from(err: hyper_util::client::legacy::Error) -> Self {\n        if err.is_connect() {\n            return Self::new(ErrorKind::Unavailable, err);\n        }\n\n        Self::new(ErrorKind::Unknown, err)\n    }\n}\n\nimpl From<time::error::Parse> for Error {\n    fn from(err: time::error::Parse) -> Self {\n        Self::new(ErrorKind::FailedPrecondition, err)\n    }\n}\n\nimpl From<quick_xml::Error> for Error {\n    fn from(err: quick_xml::Error) -> Self {\n        Self::new(ErrorKind::FailedPrecondition, err)\n    }\n}\n\nimpl From<serde_json::Error> for Error {\n    fn from(err: serde_json::Error) -> Self {\n        Self::new(ErrorKind::FailedPrecondition, err)\n    }\n}\n\nimpl From<std::io::Error> for Error {\n    fn from(err: std::io::Error) -> Self {\n        use std::io::ErrorKind as IoErrorKind;\n        match err.kind() {\n            IoErrorKind::NotFound => Self::new(ErrorKind::NotFound, err),\n            IoErrorKind::PermissionDenied => Self::new(ErrorKind::PermissionDenied, err),\n            IoErrorKind::AddrInUse | IoErrorKind::AlreadyExists => {\n                Self::new(ErrorKind::AlreadyExists, err)\n            }\n            IoErrorKind::AddrNotAvailable\n            | IoErrorKind::ConnectionRefused\n            | IoErrorKind::NotConnected => Self::new(ErrorKind::Unavailable, err),\n            IoErrorKind::BrokenPipe\n            | IoErrorKind::ConnectionReset\n            | IoErrorKind::ConnectionAborted => Self::new(ErrorKind::Aborted, err),\n            IoErrorKind::Interrupted | IoErrorKind::WouldBlock => {\n                Self::new(ErrorKind::Cancelled, err)\n            }\n            IoErrorKind::InvalidData | IoErrorKind::UnexpectedEof => {\n                Self::new(ErrorKind::FailedPrecondition, err)\n            }\n            IoErrorKind::TimedOut => Self::new(ErrorKind::DeadlineExceeded, err),\n            IoErrorKind::InvalidInput => Self::new(ErrorKind::InvalidArgument, err),\n            IoErrorKind::WriteZero => Self::new(ErrorKind::ResourceExhausted, err),\n            _ => Self::new(ErrorKind::Unknown, err),\n        }\n    }\n}\n\nimpl From<FromUtf8Error> for Error {\n    fn from(err: FromUtf8Error) -> Self {\n        Self::new(ErrorKind::FailedPrecondition, err)\n    }\n}\n\nimpl From<InvalidHeaderValue> for Error {\n    fn from(err: InvalidHeaderValue) -> Self {\n        Self::new(ErrorKind::InvalidArgument, err)\n    }\n}\n\nimpl From<InvalidUri> for Error {\n    fn from(err: InvalidUri) -> Self {\n        Self::new(ErrorKind::InvalidArgument, err)\n    }\n}\n\nimpl From<ParseError> for Error {\n    fn from(err: ParseError) -> Self {\n        Self::new(ErrorKind::FailedPrecondition, err)\n    }\n}\n\nimpl From<ParseIntError> for Error {\n    fn from(err: ParseIntError) -> Self {\n        Self::new(ErrorKind::FailedPrecondition, err)\n    }\n}\n\nimpl From<TryFromIntError> for Error {\n    fn from(err: TryFromIntError) -> Self {\n        Self::new(ErrorKind::FailedPrecondition, err)\n    }\n}\n\nimpl From<ProtobufError> for Error {\n    fn from(err: ProtobufError) -> Self {\n        Self::new(ErrorKind::FailedPrecondition, err)\n    }\n}\n\nimpl From<RecvError> for Error {\n    fn from(err: RecvError) -> Self {\n        Self::new(ErrorKind::Internal, err)\n    }\n}\n\nimpl<T> From<SendError<T>> for Error {\n    fn from(err: SendError<T>) -> Self {\n        Self {\n            kind: ErrorKind::Internal,\n            error: ErrorMessage(err.to_string()).into(),\n        }\n    }\n}\n\nimpl From<AcquireError> for Error {\n    fn from(err: AcquireError) -> Self {\n        Self {\n            kind: ErrorKind::ResourceExhausted,\n            error: ErrorMessage(err.to_string()).into(),\n        }\n    }\n}\n\nimpl From<TryAcquireError> for Error {\n    fn from(err: TryAcquireError) -> Self {\n        Self {\n            kind: ErrorKind::ResourceExhausted,\n            error: ErrorMessage(err.to_string()).into(),\n        }\n    }\n}\n\nimpl From<ToStrError> for Error {\n    fn from(err: ToStrError) -> Self {\n        Self::new(ErrorKind::FailedPrecondition, err)\n    }\n}\n\nimpl From<Utf8Error> for Error {\n    fn from(err: Utf8Error) -> Self {\n        Self::new(ErrorKind::FailedPrecondition, err)\n    }\n}\n\nimpl From<protobuf_json_mapping::ParseError> for Error {\n    fn from(err: protobuf_json_mapping::ParseError) -> Self {\n        Self::failed_precondition(err)\n    }\n}\n"
  },
  {
    "path": "core/src/file_id.rs",
    "content": "use std::fmt::{self, Write};\n\nuse librespot_protocol as protocol;\n\nconst RAW_LEN: usize = 20;\n\n#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]\npub struct FileId(pub [u8; RAW_LEN]);\n\nimpl FileId {\n    pub fn from_raw(src: &[u8]) -> FileId {\n        let mut dst = [0u8; RAW_LEN];\n        let len = src.len();\n        // some tracks return 16 instead of 20 bytes: #1188\n        if len <= RAW_LEN {\n            dst[..len].clone_from_slice(src);\n        }\n        FileId(dst)\n    }\n\n    #[allow(clippy::wrong_self_convention)]\n    pub fn to_base16(&self) -> String {\n        let mut s = String::new();\n        for b in &self.0 {\n            write!(&mut s, \"{b:02x}\").unwrap();\n        }\n        s\n    }\n}\n\nimpl fmt::Debug for FileId {\n    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n        f.debug_tuple(\"FileId\").field(&self.to_base16()).finish()\n    }\n}\n\nimpl fmt::Display for FileId {\n    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n        f.write_str(&self.to_base16())\n    }\n}\n\nimpl From<&[u8]> for FileId {\n    fn from(src: &[u8]) -> Self {\n        Self::from_raw(src)\n    }\n}\nimpl From<&protocol::metadata::Image> for FileId {\n    fn from(image: &protocol::metadata::Image) -> Self {\n        Self::from(image.file_id())\n    }\n}\n\nimpl From<&protocol::metadata::AudioFile> for FileId {\n    fn from(file: &protocol::metadata::AudioFile) -> Self {\n        Self::from(file.file_id())\n    }\n}\n\nimpl From<&protocol::metadata::VideoFile> for FileId {\n    fn from(video: &protocol::metadata::VideoFile) -> Self {\n        Self::from(video.file_id())\n    }\n}\n"
  },
  {
    "path": "core/src/http_client.rs",
    "content": "use std::{\n    sync::OnceLock,\n    time::{Duration, Instant},\n};\n\nuse bytes::Bytes;\nuse futures_util::{FutureExt, future::IntoStream};\nuse governor::{\n    Quota, RateLimiter, clock::MonotonicClock, middleware::NoOpMiddleware,\n    state::keyed::DefaultKeyedStateStore,\n};\nuse http::{Uri, header::HeaderValue};\nuse http_body_util::{BodyExt, Full};\nuse hyper::{HeaderMap, Request, Response, StatusCode, body::Incoming, header::USER_AGENT};\nuse hyper_proxy2::{Intercept, Proxy, ProxyConnector};\nuse hyper_util::{\n    client::legacy::{Client, ResponseFuture, connect::HttpConnector},\n    rt::TokioExecutor,\n};\nuse nonzero_ext::nonzero;\nuse thiserror::Error;\nuse url::Url;\n\n#[cfg(all(feature = \"__rustls\", not(feature = \"native-tls\")))]\nuse hyper_rustls::{HttpsConnector, HttpsConnectorBuilder};\n#[cfg(all(feature = \"native-tls\", not(feature = \"__rustls\")))]\nuse hyper_tls::HttpsConnector;\n\nuse crate::{\n    Error,\n    config::{OS, os_version},\n    date::Date,\n    version::{FALLBACK_USER_AGENT, VERSION_STRING, spotify_version},\n};\n\n// The 30 seconds interval is documented by Spotify, but the calls per interval\n// is a guesstimate and probably subject to licensing (purchasing extra calls)\n// and may change at any time.\npub const RATE_LIMIT_INTERVAL: Duration = Duration::from_secs(30);\npub const RATE_LIMIT_MAX_WAIT: Duration = Duration::from_secs(10);\npub const RATE_LIMIT_CALLS_PER_INTERVAL: u32 = 300;\n\n#[derive(Debug, Error)]\npub enum HttpClientError {\n    #[error(\"Response status code: {0}\")]\n    StatusCode(hyper::StatusCode),\n}\n\nimpl From<HttpClientError> for Error {\n    fn from(err: HttpClientError) -> Self {\n        match err {\n            HttpClientError::StatusCode(code) => {\n                // not exhaustive, but what reasonably could be expected\n                match code {\n                    StatusCode::GATEWAY_TIMEOUT | StatusCode::REQUEST_TIMEOUT => {\n                        Error::deadline_exceeded(err)\n                    }\n                    StatusCode::GONE\n                    | StatusCode::NOT_FOUND\n                    | StatusCode::MOVED_PERMANENTLY\n                    | StatusCode::PERMANENT_REDIRECT\n                    | StatusCode::TEMPORARY_REDIRECT => Error::not_found(err),\n                    StatusCode::FORBIDDEN | StatusCode::PAYMENT_REQUIRED => {\n                        Error::permission_denied(err)\n                    }\n                    StatusCode::NETWORK_AUTHENTICATION_REQUIRED\n                    | StatusCode::PROXY_AUTHENTICATION_REQUIRED\n                    | StatusCode::UNAUTHORIZED => Error::unauthenticated(err),\n                    StatusCode::EXPECTATION_FAILED\n                    | StatusCode::PRECONDITION_FAILED\n                    | StatusCode::PRECONDITION_REQUIRED => Error::failed_precondition(err),\n                    StatusCode::RANGE_NOT_SATISFIABLE => Error::out_of_range(err),\n                    StatusCode::INTERNAL_SERVER_ERROR\n                    | StatusCode::MISDIRECTED_REQUEST\n                    | StatusCode::SERVICE_UNAVAILABLE\n                    | StatusCode::UNAVAILABLE_FOR_LEGAL_REASONS => Error::unavailable(err),\n                    StatusCode::BAD_REQUEST\n                    | StatusCode::HTTP_VERSION_NOT_SUPPORTED\n                    | StatusCode::LENGTH_REQUIRED\n                    | StatusCode::METHOD_NOT_ALLOWED\n                    | StatusCode::NOT_ACCEPTABLE\n                    | StatusCode::PAYLOAD_TOO_LARGE\n                    | StatusCode::REQUEST_HEADER_FIELDS_TOO_LARGE\n                    | StatusCode::UNSUPPORTED_MEDIA_TYPE\n                    | StatusCode::URI_TOO_LONG => Error::invalid_argument(err),\n                    StatusCode::TOO_MANY_REQUESTS => Error::resource_exhausted(err),\n                    StatusCode::NOT_IMPLEMENTED => Error::unimplemented(err),\n                    _ => Error::unknown(err),\n                }\n            }\n        }\n    }\n}\n\ntype HyperClient = Client<ProxyConnector<HttpsConnector<HttpConnector>>, Full<bytes::Bytes>>;\n\npub struct HttpClient {\n    user_agent: HeaderValue,\n    proxy_url: Option<Url>,\n    hyper_client: OnceLock<HyperClient>,\n\n    rate_limiter:\n        RateLimiter<String, DefaultKeyedStateStore<String>, MonotonicClock, NoOpMiddleware>,\n}\n\nimpl HttpClient {\n    pub fn new(proxy_url: Option<&Url>) -> Self {\n        let zero_str = String::from(\"0\");\n        let os_version = os_version();\n\n        let (spotify_platform, os_version) = match OS {\n            \"android\" => (\"Android\", os_version),\n            \"ios\" => (\"iOS\", os_version),\n            \"macos\" => (\"OSX\", zero_str),\n            \"windows\" => (\"Win32\", zero_str),\n            _ => (\"Linux\", zero_str),\n        };\n\n        let user_agent_str = &format!(\n            \"Spotify/{} {}/{} ({})\",\n            spotify_version(),\n            spotify_platform,\n            os_version,\n            VERSION_STRING\n        );\n\n        let user_agent = HeaderValue::from_str(user_agent_str).unwrap_or_else(|err| {\n            error!(\"Invalid user agent <{user_agent_str}>: {err}\");\n            HeaderValue::from_static(FALLBACK_USER_AGENT)\n        });\n\n        let replenish_interval_ns =\n            RATE_LIMIT_INTERVAL.as_nanos() / RATE_LIMIT_CALLS_PER_INTERVAL as u128;\n        let quota = Quota::with_period(Duration::from_nanos(replenish_interval_ns as u64))\n            .expect(\"replenish interval should be valid\")\n            .allow_burst(nonzero![RATE_LIMIT_CALLS_PER_INTERVAL]);\n        let rate_limiter = RateLimiter::keyed(quota);\n\n        Self {\n            user_agent,\n            proxy_url: proxy_url.cloned(),\n            hyper_client: OnceLock::new(),\n            rate_limiter,\n        }\n    }\n\n    fn try_create_hyper_client(proxy_url: Option<&Url>) -> Result<HyperClient, Error> {\n        // configuring TLS is expensive and should be done once per process\n\n        #[cfg(all(feature = \"__rustls\", not(feature = \"native-tls\")))]\n        let https_connector = {\n            #[cfg(feature = \"rustls-tls-native-roots\")]\n            let tls = HttpsConnectorBuilder::new().with_native_roots()?;\n            #[cfg(feature = \"rustls-tls-webpki-roots\")]\n            let tls = HttpsConnectorBuilder::new().with_webpki_roots();\n            tls.https_or_http().enable_http1().enable_http2().build()\n        };\n\n        #[cfg(all(feature = \"native-tls\", not(feature = \"__rustls\")))]\n        let https_connector = HttpsConnector::new();\n\n        // When not using a proxy a dummy proxy is configured that will not intercept any traffic.\n        // This prevents needing to carry the Client Connector generics through the whole project\n        let proxy = match &proxy_url {\n            Some(proxy_url) => Proxy::new(Intercept::All, proxy_url.to_string().parse()?),\n            None => Proxy::new(Intercept::None, Uri::from_static(\"0.0.0.0\")),\n        };\n        let proxy_connector = ProxyConnector::from_proxy(https_connector, proxy)?;\n\n        let client = Client::builder(TokioExecutor::new())\n            .http2_adaptive_window(true)\n            .build(proxy_connector);\n        Ok(client)\n    }\n\n    fn hyper_client(&self) -> &HyperClient {\n        self.hyper_client\n            .get_or_init(|| Self::try_create_hyper_client(self.proxy_url.as_ref()).unwrap())\n    }\n\n    pub async fn request(&self, req: Request<Bytes>) -> Result<Response<Incoming>, Error> {\n        debug!(\"Requesting {}\", req.uri());\n\n        // `Request` does not implement `Clone` because its `Body` may be a single-shot stream.\n        // As correct as that may be technically, we now need all this boilerplate to clone it\n        // ourselves, as any `Request` is moved in the loop.\n        let (parts, body_as_bytes) = req.into_parts();\n\n        loop {\n            let mut req = Request::builder()\n                .method(parts.method.clone())\n                .uri(parts.uri.clone())\n                .version(parts.version)\n                .body(body_as_bytes.clone())?;\n            *req.headers_mut() = parts.headers.clone();\n\n            let request = self.request_fut(req)?;\n            let response = request.await;\n\n            if let Ok(response) = &response {\n                let code = response.status();\n\n                if code == StatusCode::TOO_MANY_REQUESTS {\n                    if let Some(duration) = Self::get_retry_after(response.headers()) {\n                        warn!(\n                            \"Rate limited by service, retrying in {} seconds...\",\n                            duration.as_secs()\n                        );\n                        tokio::time::sleep(duration).await;\n                        continue;\n                    }\n                }\n\n                if !code.is_success() {\n                    return Err(HttpClientError::StatusCode(code).into());\n                }\n            }\n\n            let response = response?;\n            return Ok(response);\n        }\n    }\n\n    pub async fn request_body(&self, req: Request<Bytes>) -> Result<Bytes, Error> {\n        let response = self.request(req).await?;\n        Ok(response.into_body().collect().await?.to_bytes())\n    }\n\n    pub fn request_stream(&self, req: Request<Bytes>) -> Result<IntoStream<ResponseFuture>, Error> {\n        Ok(self.request_fut(req)?.into_stream())\n    }\n\n    pub fn request_fut(&self, mut req: Request<Bytes>) -> Result<ResponseFuture, Error> {\n        let headers_mut = req.headers_mut();\n        headers_mut.insert(USER_AGENT, self.user_agent.clone());\n\n        // For rate limiting we cannot *just* depend on Spotify sending us HTTP/429\n        // Retry-After headers. For example, when there is a service interruption\n        // and HTTP/500 is returned, we don't want to DoS the Spotify infrastructure.\n        let domain = match req.uri().host() {\n            Some(host) => {\n                // strip the prefix from *.domain.tld (assume rate limit is per domain, not subdomain)\n                let mut parts = host.split('.').map(Into::into).collect::<Vec<String>>();\n                let n = parts.len().saturating_sub(2);\n                parts.drain(n..).collect()\n            }\n            None => String::from(\"\"),\n        };\n        self.rate_limiter.check_key(&domain).map_err(|e| {\n            Error::resource_exhausted(format!(\n                \"rate limited for at least another {} seconds\",\n                e.wait_time_from(Instant::now()).as_secs()\n            ))\n        })?;\n\n        Ok(self.hyper_client().request(req.map(Full::new)))\n    }\n\n    pub fn get_retry_after(headers: &HeaderMap<HeaderValue>) -> Option<Duration> {\n        let now = Date::now_utc().as_timestamp_ms();\n\n        let mut retry_after_ms = None;\n        if let Some(header_val) = headers.get(\"X-RateLimit-Next\") {\n            // *.akamaized.net (Akamai)\n            if let Ok(date_str) = header_val.to_str() {\n                if let Ok(target) = Date::from_iso8601(date_str) {\n                    retry_after_ms = Some(target.as_timestamp_ms().saturating_sub(now))\n                }\n            }\n        } else if let Some(header_val) = headers.get(\"Fastly-RateLimit-Reset\") {\n            // *.scdn.co (Fastly)\n            if let Ok(timestamp) = header_val.to_str() {\n                if let Ok(target) = timestamp.parse::<i64>() {\n                    retry_after_ms = Some(target.saturating_sub(now))\n                }\n            }\n        } else if let Some(header_val) = headers.get(\"Retry-After\") {\n            // Generic RFC compliant (including *.spotify.com)\n            if let Ok(retry_after) = header_val.to_str() {\n                if let Ok(duration) = retry_after.parse::<i64>() {\n                    retry_after_ms = Some(duration * 1000)\n                }\n            }\n        }\n\n        if let Some(retry_after) = retry_after_ms {\n            let duration = Duration::from_millis(retry_after as u64);\n            if duration <= RATE_LIMIT_MAX_WAIT {\n                return Some(duration);\n            } else {\n                debug!(\n                    \"Waiting {} seconds would exceed {} second limit\",\n                    duration.as_secs(),\n                    RATE_LIMIT_MAX_WAIT.as_secs()\n                );\n            }\n        }\n\n        None\n    }\n}\n"
  },
  {
    "path": "core/src/lib.rs",
    "content": "#[macro_use]\nextern crate log;\n\nuse librespot_protocol as protocol;\n\n#[macro_use]\nmod component;\n\npub mod apresolve;\npub mod audio_key;\npub mod authentication;\npub mod cache;\npub mod cdn_url;\npub mod channel;\npub mod config;\nmod connection;\npub mod date;\n#[allow(dead_code)]\npub mod dealer;\npub mod deserialize_with;\n#[doc(hidden)]\npub mod diffie_hellman;\npub mod error;\npub mod file_id;\npub mod http_client;\npub mod login5;\npub mod mercury;\npub mod packet;\nmod proxytunnel;\npub mod session;\nmod socket;\n#[allow(dead_code)]\npub mod spclient;\npub mod spotify_id;\npub mod spotify_uri;\npub mod token;\n#[doc(hidden)]\npub mod util;\npub mod version;\n\npub use config::SessionConfig;\npub use error::Error;\npub use file_id::FileId;\npub use session::Session;\npub use spotify_id::SpotifyId;\npub use spotify_uri::SpotifyUri;\n"
  },
  {
    "path": "core/src/login5.rs",
    "content": "use crate::config::OS;\nuse crate::spclient::CLIENT_TOKEN;\nuse crate::token::Token;\nuse crate::{Error, SessionConfig, util};\nuse bytes::Bytes;\nuse http::{HeaderValue, Method, Request, header::ACCEPT};\nuse librespot_protocol::login5::login_response::Response;\nuse librespot_protocol::{\n    client_info::ClientInfo,\n    credentials::{Password, StoredCredential},\n    hashcash::HashcashSolution,\n    login5::{\n        ChallengeSolution, LoginError, LoginOk, LoginRequest, LoginResponse,\n        login_request::Login_method,\n    },\n};\nuse protobuf::well_known_types::duration::Duration as ProtoDuration;\nuse protobuf::{Message, MessageField};\nuse std::time::{Duration, SystemTime};\nuse thiserror::Error;\nuse tokio::time::sleep;\n\nconst MAX_LOGIN_TRIES: u8 = 3;\nconst LOGIN_TIMEOUT: Duration = Duration::from_secs(3);\n\ncomponent! {\n    Login5Manager : Login5ManagerInner {\n        auth_token: Option<Token> = None,\n    }\n}\n\n#[derive(Debug, Error)]\nenum Login5Error {\n    #[error(\"Login request was denied: {0:?}\")]\n    FaultyRequest(LoginError),\n    #[error(\"Code challenge is not supported\")]\n    CodeChallenge,\n    #[error(\"Tried to acquire token without stored credentials\")]\n    NoStoredCredentials,\n    #[error(\"Couldn't successfully authenticate after {0} times\")]\n    RetriesFailed(u8),\n    #[error(\"Login via login5 is only allowed for android or ios\")]\n    OnlyForMobile,\n}\n\nimpl From<Login5Error> for Error {\n    fn from(err: Login5Error) -> Self {\n        match err {\n            Login5Error::NoStoredCredentials | Login5Error::OnlyForMobile => {\n                Error::unavailable(err)\n            }\n            Login5Error::RetriesFailed(_) | Login5Error::FaultyRequest(_) => {\n                Error::failed_precondition(err)\n            }\n            Login5Error::CodeChallenge => Error::unimplemented(err),\n        }\n    }\n}\n\nimpl Login5Manager {\n    async fn request(&self, message: &LoginRequest) -> Result<Bytes, Error> {\n        let client_token = self.session().spclient().client_token().await?;\n        let body = message.write_to_bytes()?;\n\n        let request = Request::builder()\n            .method(&Method::POST)\n            .uri(\"https://login5.spotify.com/v3/login\")\n            .header(ACCEPT, HeaderValue::from_static(\"application/x-protobuf\"))\n            .header(CLIENT_TOKEN, HeaderValue::from_str(&client_token)?)\n            .body(body.into())?;\n\n        self.session().http_client().request_body(request).await\n    }\n\n    async fn login5_request(&self, login: Login_method) -> Result<LoginOk, Error> {\n        let client_id = match OS {\n            \"macos\" | \"windows\" => self.session().client_id(),\n            // StoredCredential is used to get an access_token from Session credentials.\n            // Using the session client_id allows user to use Keymaster on Android/IOS\n            // if their Credentials::with_access_token was obtained there, assuming\n            // they have overriden the SessionConfig::client_id with the Keymaster's.\n            _ if matches!(login, Login_method::StoredCredential(_)) => self.session().client_id(),\n            _ => SessionConfig::default().client_id,\n        };\n\n        let mut login_request = LoginRequest {\n            client_info: MessageField::some(ClientInfo {\n                client_id,\n                device_id: self.session().device_id().to_string(),\n                special_fields: Default::default(),\n            }),\n            login_method: Some(login),\n            ..Default::default()\n        };\n\n        let mut response = self.request(&login_request).await?;\n        let mut count = 0;\n\n        loop {\n            count += 1;\n\n            let message = LoginResponse::parse_from_bytes(&response)?;\n            if let Some(Response::Ok(ok)) = message.response {\n                break Ok(ok);\n            }\n\n            if message.has_error() {\n                match message.error() {\n                    LoginError::TIMEOUT | LoginError::TOO_MANY_ATTEMPTS => {\n                        sleep(LOGIN_TIMEOUT).await\n                    }\n                    others => return Err(Login5Error::FaultyRequest(others).into()),\n                }\n            }\n\n            if message.has_challenges() {\n                // handles the challenges, and updates the login context with the response\n                Self::handle_challenges(&mut login_request, message)?;\n            }\n\n            if count < MAX_LOGIN_TRIES {\n                response = self.request(&login_request).await?;\n            } else {\n                return Err(Login5Error::RetriesFailed(MAX_LOGIN_TRIES).into());\n            }\n        }\n    }\n\n    /// Login for android and ios\n    ///\n    /// This request doesn't require a connected session as it is the entrypoint for android or ios\n    ///\n    /// This request will only work when:\n    /// - client_id => android or ios | can be easily adjusted in [SessionConfig::default_for_os]\n    /// - user-agent => android or ios | has to be adjusted in [HttpClient::new](crate::http_client::HttpClient::new)\n    pub async fn login(\n        &self,\n        id: impl Into<String>,\n        password: impl Into<String>,\n    ) -> Result<(Token, Vec<u8>), Error> {\n        if !matches!(OS, \"android\" | \"ios\") {\n            // by manipulating the user-agent and client-id it can be also used/tested on desktop\n            return Err(Login5Error::OnlyForMobile.into());\n        }\n\n        let method = Login_method::Password(Password {\n            id: id.into(),\n            password: password.into(),\n            ..Default::default()\n        });\n\n        let token_response = self.login5_request(method).await?;\n        let auth_token = Self::token_from_login(\n            token_response.access_token,\n            token_response.access_token_expires_in,\n        );\n\n        Ok((auth_token, token_response.stored_credential))\n    }\n\n    /// Retrieve the access_token via login5\n    ///\n    /// This request will only work when the store credentials match the client-id. Meaning that\n    /// stored credentials generated with the keymaster client-id will not work, for example, with\n    /// the android client-id.\n    pub async fn auth_token(&self) -> Result<Token, Error> {\n        let auth_data = self.session().auth_data();\n        if auth_data.is_empty() {\n            return Err(Login5Error::NoStoredCredentials.into());\n        }\n\n        let auth_token = self.lock(|inner| {\n            if let Some(token) = &inner.auth_token {\n                if token.is_expired() {\n                    inner.auth_token = None;\n                }\n            }\n            inner.auth_token.clone()\n        });\n\n        if let Some(auth_token) = auth_token {\n            return Ok(auth_token);\n        }\n\n        let method = Login_method::StoredCredential(StoredCredential {\n            username: self.session().username().to_string(),\n            data: auth_data,\n            ..Default::default()\n        });\n\n        let token_response = self.login5_request(method).await?;\n        let auth_token = Self::token_from_login(\n            token_response.access_token,\n            token_response.access_token_expires_in,\n        );\n\n        let token = self.lock(|inner| {\n            inner.auth_token = Some(auth_token.clone());\n            inner.auth_token.clone()\n        });\n\n        trace!(\"Got auth token: {auth_token:?}\");\n\n        token.ok_or(Login5Error::NoStoredCredentials.into())\n    }\n\n    fn handle_challenges(\n        login_request: &mut LoginRequest,\n        message: LoginResponse,\n    ) -> Result<(), Error> {\n        let challenges = message.challenges();\n        debug!(\n            \"Received {} challenges, solving...\",\n            challenges.challenges.len()\n        );\n\n        for challenge in &challenges.challenges {\n            if challenge.has_code() {\n                return Err(Login5Error::CodeChallenge.into());\n            } else if !challenge.has_hashcash() {\n                debug!(\"Challenge was empty, skipping...\");\n                continue;\n            }\n\n            let hash_cash_challenge = challenge.hashcash();\n\n            let mut suffix = [0u8; 0x10];\n            let duration = util::solve_hash_cash(\n                &message.login_context,\n                &hash_cash_challenge.prefix,\n                hash_cash_challenge.length,\n                &mut suffix,\n            )?;\n\n            let (seconds, nanos) = (duration.as_secs() as i64, duration.subsec_nanos() as i32);\n            debug!(\"Solving hashcash took {seconds}s {nanos}ns\");\n\n            let mut solution = ChallengeSolution::new();\n            solution.set_hashcash(HashcashSolution {\n                suffix: Vec::from(suffix),\n                duration: MessageField::some(ProtoDuration {\n                    seconds,\n                    nanos,\n                    ..Default::default()\n                }),\n                ..Default::default()\n            });\n\n            login_request\n                .challenge_solutions\n                .mut_or_insert_default()\n                .solutions\n                .push(solution);\n        }\n\n        login_request.login_context = message.login_context;\n\n        Ok(())\n    }\n\n    fn token_from_login(token: String, expires_in: i32) -> Token {\n        Token {\n            access_token: token,\n            expires_in: Duration::from_secs(expires_in.try_into().unwrap_or(3600)),\n            token_type: \"Bearer\".to_string(),\n            scopes: vec![],\n            timestamp: SystemTime::now(),\n        }\n    }\n}\n"
  },
  {
    "path": "core/src/mercury/mod.rs",
    "content": "use std::{\n    collections::HashMap,\n    future::Future,\n    pin::Pin,\n    task::{Context, Poll},\n};\n\nuse byteorder::{BigEndian, ByteOrder};\nuse bytes::Bytes;\nuse futures_util::FutureExt;\nuse protobuf::Message;\nuse tokio::sync::{mpsc, oneshot};\n\nuse crate::{Error, packet::PacketType, protocol, util::SeqGenerator};\n\nmod types;\npub use self::types::*;\n\nmod sender;\npub use self::sender::MercurySender;\n\ncomponent! {\n    MercuryManager : MercuryManagerInner {\n        sequence: SeqGenerator<u64> = SeqGenerator::new(0),\n        pending: HashMap<Vec<u8>, MercuryPending> = HashMap::new(),\n        subscriptions: Vec<(String, mpsc::UnboundedSender<MercuryResponse>)> = Vec::new(),\n        invalid: bool = false,\n    }\n}\n\npub struct MercuryPending {\n    parts: Vec<Vec<u8>>,\n    partial: Option<Vec<u8>>,\n    callback: Option<oneshot::Sender<Result<MercuryResponse, Error>>>,\n}\n\npub struct MercuryFuture<T> {\n    receiver: oneshot::Receiver<Result<T, Error>>,\n}\n\nimpl<T> Future for MercuryFuture<T> {\n    type Output = Result<T, Error>;\n\n    fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {\n        self.receiver.poll_unpin(cx)?\n    }\n}\n\nimpl MercuryManager {\n    fn next_seq(&self) -> Vec<u8> {\n        let mut seq = vec![0u8; 8];\n        BigEndian::write_u64(&mut seq, self.lock(|inner| inner.sequence.get()));\n        seq\n    }\n\n    fn request(&self, req: MercuryRequest) -> Result<MercuryFuture<MercuryResponse>, Error> {\n        let (tx, rx) = oneshot::channel();\n\n        let pending = MercuryPending {\n            parts: Vec::new(),\n            partial: None,\n            callback: Some(tx),\n        };\n\n        let seq = self.next_seq();\n        self.lock(|inner| {\n            if !inner.invalid {\n                inner.pending.insert(seq.clone(), pending);\n            }\n        });\n\n        let cmd = req.method.command();\n        let data = req.encode(&seq)?;\n\n        self.session().send_packet(cmd, data)?;\n        Ok(MercuryFuture { receiver: rx })\n    }\n\n    pub fn get<T: Into<String>>(&self, uri: T) -> Result<MercuryFuture<MercuryResponse>, Error> {\n        self.request(MercuryRequest {\n            method: MercuryMethod::Get,\n            uri: uri.into(),\n            content_type: None,\n            payload: Vec::new(),\n        })\n    }\n\n    pub fn send<T: Into<String>>(\n        &self,\n        uri: T,\n        data: Vec<u8>,\n    ) -> Result<MercuryFuture<MercuryResponse>, Error> {\n        self.request(MercuryRequest {\n            method: MercuryMethod::Send,\n            uri: uri.into(),\n            content_type: None,\n            payload: vec![data],\n        })\n    }\n\n    pub fn sender<T: Into<String>>(&self, uri: T) -> MercurySender {\n        MercurySender::new(self.clone(), uri.into())\n    }\n\n    pub fn subscribe<T: Into<String>>(\n        &self,\n        uri: T,\n    ) -> impl Future<Output = Result<mpsc::UnboundedReceiver<MercuryResponse>, Error>> + 'static\n    {\n        let uri = uri.into();\n        let request = self.request(MercuryRequest {\n            method: MercuryMethod::Sub,\n            uri: uri.clone(),\n            content_type: None,\n            payload: Vec::new(),\n        });\n\n        let manager = self.clone();\n        async move {\n            let response = request?.await?;\n\n            let (tx, rx) = mpsc::unbounded_channel();\n\n            manager.lock(move |inner| {\n                if !inner.invalid {\n                    debug!(\"subscribed uri={} count={}\", uri, response.payload.len());\n                    if !response.payload.is_empty() {\n                        // Old subscription protocol, watch the provided list of URIs\n                        for sub in response.payload {\n                            match protocol::pubsub::Subscription::parse_from_bytes(&sub) {\n                                Ok(mut sub) => {\n                                    let sub_uri = sub.take_uri();\n\n                                    debug!(\"subscribed sub_uri={sub_uri}\");\n\n                                    inner.subscriptions.push((sub_uri, tx.clone()));\n                                }\n                                Err(e) => {\n                                    error!(\"could not subscribe to {uri}: {e}\");\n                                }\n                            }\n                        }\n                    } else {\n                        // New subscription protocol, watch the requested URI\n                        inner.subscriptions.push((uri, tx));\n                    }\n                }\n            });\n\n            Ok(rx)\n        }\n    }\n\n    pub fn listen_for<T: Into<String>>(\n        &self,\n        uri: T,\n    ) -> impl Future<Output = mpsc::UnboundedReceiver<MercuryResponse>> + 'static {\n        let uri = uri.into();\n\n        let manager = self.clone();\n        async move {\n            let (tx, rx) = mpsc::unbounded_channel();\n\n            manager.lock(move |inner| {\n                if !inner.invalid {\n                    debug!(\"listening to uri={uri}\");\n                    inner.subscriptions.push((uri, tx));\n                }\n            });\n\n            rx\n        }\n    }\n\n    pub(crate) fn dispatch(&self, cmd: PacketType, mut data: Bytes) -> Result<(), Error> {\n        let seq_len = BigEndian::read_u16(data.split_to(2).as_ref()) as usize;\n        let seq = data.split_to(seq_len).as_ref().to_owned();\n\n        let flags = data.split_to(1).as_ref()[0];\n        let count = BigEndian::read_u16(data.split_to(2).as_ref()) as usize;\n\n        let pending = self.lock(|inner| inner.pending.remove(&seq));\n\n        let mut pending = match pending {\n            Some(pending) => pending,\n            None => {\n                if let PacketType::MercuryEvent = cmd {\n                    MercuryPending {\n                        parts: Vec::new(),\n                        partial: None,\n                        callback: None,\n                    }\n                } else {\n                    warn!(\"Ignore seq {:?} cmd {:x}\", seq, cmd as u8);\n                    return Err(MercuryError::Command(cmd).into());\n                }\n            }\n        };\n\n        for i in 0..count {\n            let mut part = Self::parse_part(&mut data);\n            if let Some(mut partial) = pending.partial.take() {\n                partial.extend_from_slice(&part);\n                part = partial;\n            }\n\n            if i == count - 1 && (flags == 2) {\n                pending.partial = Some(part)\n            } else {\n                pending.parts.push(part);\n            }\n        }\n\n        if flags == 0x1 {\n            self.complete_request(cmd, pending)?;\n        } else {\n            self.lock(move |inner| inner.pending.insert(seq, pending));\n        }\n\n        Ok(())\n    }\n\n    fn parse_part(data: &mut Bytes) -> Vec<u8> {\n        let size = BigEndian::read_u16(data.split_to(2).as_ref()) as usize;\n        data.split_to(size).as_ref().to_owned()\n    }\n\n    fn complete_request(&self, cmd: PacketType, mut pending: MercuryPending) -> Result<(), Error> {\n        let header_data = pending.parts.remove(0);\n        let header = protocol::mercury::Header::parse_from_bytes(&header_data)?;\n\n        let response = MercuryResponse {\n            uri: header.uri().to_string(),\n            status_code: header.status_code(),\n            payload: pending.parts,\n        };\n\n        let status_code = response.status_code;\n        if status_code >= 500 {\n            error!(\"error {} for uri {}\", status_code, &response.uri);\n            Err(MercuryError::Response(response).into())\n        } else if status_code >= 400 {\n            error!(\"error {} for uri {}\", status_code, &response.uri);\n            if let Some(cb) = pending.callback {\n                cb.send(Err(MercuryError::Response(response.clone()).into()))\n                    .map_err(|_| MercuryError::Channel)?;\n            }\n            Err(MercuryError::Response(response).into())\n        } else if let PacketType::MercuryEvent = cmd {\n            // TODO: This is just a workaround to make utf-8 encoded usernames work.\n            // A better solution would be to use an uri struct and urlencode it directly\n            // before sending while saving the subscription under its unencoded form.\n            let mut uri_split = response.uri.split('/');\n\n            let encoded_uri = std::iter::once(uri_split.next().unwrap_or_default().to_string())\n                .chain(uri_split.map(|component| {\n                    form_urlencoded::byte_serialize(component.as_bytes()).collect::<String>()\n                }))\n                .collect::<Vec<String>>()\n                .join(\"/\");\n\n            let mut found = false;\n\n            self.lock(|inner| {\n                inner.subscriptions.retain(|(prefix, sub)| {\n                    if encoded_uri.starts_with(prefix) {\n                        found = true;\n\n                        // if send fails, remove from list of subs\n                        // TODO: send unsub message\n                        sub.send(response.clone()).is_ok()\n                    } else {\n                        // URI doesn't match\n                        true\n                    }\n                });\n            });\n\n            if found {\n                Ok(())\n            } else if self.session().dealer().handles(&response.uri) {\n                trace!(\"mercury response <{}> is handled by dealer\", response.uri);\n                Ok(())\n            } else {\n                debug!(\"unknown subscription uri={}\", &response.uri);\n                trace!(\"response pushed over Mercury: {response:?}\");\n                Err(MercuryError::Response(response).into())\n            }\n        } else if let Some(cb) = pending.callback {\n            cb.send(Ok(response)).map_err(|_| MercuryError::Channel)?;\n            Ok(())\n        } else {\n            error!(\"can't handle Mercury response: {response:?}\");\n            Err(MercuryError::Response(response).into())\n        }\n    }\n\n    pub(crate) fn shutdown(&self) {\n        self.lock(|inner| {\n            inner.invalid = true;\n            // destroy the sending halves of the channels to signal everyone who is waiting for something.\n            inner.pending.clear();\n            inner.subscriptions.clear();\n        });\n    }\n}\n"
  },
  {
    "path": "core/src/mercury/sender.rs",
    "content": "use std::collections::VecDeque;\n\nuse super::{MercuryFuture, MercuryManager, MercuryResponse};\n\nuse crate::Error;\n\npub struct MercurySender {\n    mercury: MercuryManager,\n    uri: String,\n    pending: VecDeque<MercuryFuture<MercuryResponse>>,\n    buffered_future: Option<MercuryFuture<MercuryResponse>>,\n}\n\nimpl MercurySender {\n    pub(crate) fn new(mercury: MercuryManager, uri: String) -> MercurySender {\n        MercurySender {\n            mercury,\n            uri,\n            pending: VecDeque::new(),\n            buffered_future: None,\n        }\n    }\n\n    pub fn is_flushed(&self) -> bool {\n        self.buffered_future.is_none() && self.pending.is_empty()\n    }\n\n    pub fn send(&mut self, item: Vec<u8>) -> Result<(), Error> {\n        let task = self.mercury.send(self.uri.clone(), item)?;\n        self.pending.push_back(task);\n        Ok(())\n    }\n\n    pub async fn flush(&mut self) -> Result<(), Error> {\n        if self.buffered_future.is_none() {\n            self.buffered_future = self.pending.pop_front();\n        }\n\n        while let Some(fut) = self.buffered_future.as_mut() {\n            fut.await?;\n            self.buffered_future = self.pending.pop_front();\n        }\n        Ok(())\n    }\n}\n\nimpl Clone for MercurySender {\n    fn clone(&self) -> MercurySender {\n        MercurySender {\n            mercury: self.mercury.clone(),\n            uri: self.uri.clone(),\n            pending: VecDeque::new(),\n            buffered_future: None,\n        }\n    }\n}\n"
  },
  {
    "path": "core/src/mercury/types.rs",
    "content": "use std::io::Write;\n\nuse byteorder::{BigEndian, WriteBytesExt};\nuse protobuf::Message;\nuse thiserror::Error;\n\nuse crate::{Error, packet::PacketType, protocol};\n\n#[derive(Debug, PartialEq, Eq)]\npub enum MercuryMethod {\n    Get,\n    Sub,\n    Unsub,\n    Send,\n}\n\n#[derive(Debug)]\npub struct MercuryRequest {\n    pub method: MercuryMethod,\n    pub uri: String,\n    pub content_type: Option<String>,\n    pub payload: Vec<Vec<u8>>,\n}\n\n#[derive(Debug, Clone)]\npub struct MercuryResponse {\n    pub uri: String,\n    pub status_code: i32,\n    pub payload: Vec<Vec<u8>>,\n}\n\n#[derive(Debug, Error)]\npub enum MercuryError {\n    #[error(\"callback receiver was disconnected\")]\n    Channel,\n    #[error(\"error handling packet type: {0:?}\")]\n    Command(PacketType),\n    #[error(\"error handling Mercury response: {0:?}\")]\n    Response(MercuryResponse),\n}\n\nimpl From<MercuryError> for Error {\n    fn from(err: MercuryError) -> Self {\n        match err {\n            MercuryError::Channel => Error::aborted(err),\n            MercuryError::Command(_) => Error::unimplemented(err),\n            MercuryError::Response(_) => Error::unavailable(err),\n        }\n    }\n}\n\nimpl std::fmt::Display for MercuryMethod {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        let s = match *self {\n            MercuryMethod::Get => \"GET\",\n            MercuryMethod::Sub => \"SUB\",\n            MercuryMethod::Unsub => \"UNSUB\",\n            MercuryMethod::Send => \"SEND\",\n        };\n        write!(f, \"{s}\")\n    }\n}\n\nimpl MercuryMethod {\n    pub fn command(&self) -> PacketType {\n        use PacketType::*;\n        match *self {\n            MercuryMethod::Get | MercuryMethod::Send => MercuryReq,\n            MercuryMethod::Sub => MercurySub,\n            MercuryMethod::Unsub => MercuryUnsub,\n        }\n    }\n}\n\nimpl MercuryRequest {\n    pub fn encode(&self, seq: &[u8]) -> Result<Vec<u8>, Error> {\n        let mut packet = Vec::new();\n        packet.write_u16::<BigEndian>(seq.len() as u16)?;\n        packet.write_all(seq)?;\n        packet.write_u8(1)?; // Flags: FINAL\n        packet.write_u16::<BigEndian>(1 + self.payload.len() as u16)?; // Part count\n\n        let mut header = protocol::mercury::Header::new();\n        header.set_uri(self.uri.clone());\n        header.set_method(self.method.to_string());\n\n        if let Some(ref content_type) = self.content_type {\n            header.set_content_type(content_type.clone());\n        }\n\n        packet.write_u16::<BigEndian>(header.compute_size() as u16)?;\n        header.write_to_writer(&mut packet)?;\n\n        for p in &self.payload {\n            packet.write_u16::<BigEndian>(p.len() as u16)?;\n            packet.write_all(p)?;\n        }\n\n        Ok(packet)\n    }\n}\n"
  },
  {
    "path": "core/src/packet.rs",
    "content": "// Ported from librespot-java. Relicensed under MIT with permission.\n\nuse num_derive::{FromPrimitive, ToPrimitive};\n\n#[derive(Debug, Copy, Clone, FromPrimitive, ToPrimitive)]\npub enum PacketType {\n    SecretBlock = 0x02,\n    Ping = 0x04,\n    StreamChunk = 0x08,\n    StreamChunkRes = 0x09,\n    ChannelError = 0x0a,\n    ChannelAbort = 0x0b,\n    RequestKey = 0x0c,\n    AesKey = 0x0d,\n    AesKeyError = 0x0e,\n    Image = 0x19,\n    CountryCode = 0x1b,\n    Pong = 0x49,\n    PongAck = 0x4a,\n    Pause = 0x4b,\n    ProductInfo = 0x50,\n    LegacyWelcome = 0x69,\n    LicenseVersion = 0x76,\n    Login = 0xab,\n    APWelcome = 0xac,\n    AuthFailure = 0xad,\n    MercuryReq = 0xb2,\n    MercurySub = 0xb3,\n    MercuryUnsub = 0xb4,\n    MercuryEvent = 0xb5,\n    TrackEndedTime = 0x82,\n    UnknownDataAllZeros = 0x1f,\n    PreferredLocale = 0x74,\n    Unknown0x0f = 0x0f,\n    Unknown0x10 = 0x10,\n    Unknown0x4f = 0x4f,\n\n    // TODO - occurs when subscribing with an empty URI. Maybe a MercuryError?\n    // Payload: b\"\\0\\x08\\0\\0\\0\\0\\0\\0\\0\\0\\x01\\0\\x01\\0\\x03 \\xb0\\x06\"\n    Unknown0xb6 = 0xb6,\n}\n"
  },
  {
    "path": "core/src/proxytunnel.rs",
    "content": "use std::io;\n\nuse tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};\n\npub async fn proxy_connect<T: AsyncRead + AsyncWrite + Unpin>(\n    mut proxy_connection: T,\n    connect_host: &str,\n    connect_port: &str,\n) -> io::Result<T> {\n    let mut buffer = Vec::new();\n    buffer.extend_from_slice(b\"CONNECT \");\n    buffer.extend_from_slice(connect_host.as_bytes());\n    buffer.push(b':');\n    buffer.extend_from_slice(connect_port.as_bytes());\n    buffer.extend_from_slice(b\" HTTP/1.1\\r\\n\\r\\n\");\n\n    proxy_connection.write_all(buffer.as_ref()).await?;\n\n    buffer.resize(buffer.capacity(), 0);\n\n    let mut offset = 0;\n    loop {\n        let bytes_read = proxy_connection.read(&mut buffer[offset..]).await?;\n        if bytes_read == 0 {\n            return Err(io::Error::other(\"Early EOF from proxy\"));\n        }\n        offset += bytes_read;\n\n        let mut headers = [httparse::EMPTY_HEADER; 16];\n        let mut response = httparse::Response::new(&mut headers);\n\n        let status = response\n            .parse(&buffer[..offset])\n            .map_err(io::Error::other)?;\n\n        if status.is_complete() {\n            return match response.code {\n                Some(200) => Ok(proxy_connection), // Proxy says all is well\n                Some(code) => {\n                    let reason = response.reason.unwrap_or(\"no reason\");\n                    let msg = format!(\"Proxy responded with {code}: {reason}\");\n                    Err(io::Error::other(msg))\n                }\n                None => Err(io::Error::other(\"Malformed response from proxy\")),\n            };\n        }\n\n        if offset >= buffer.len() {\n            buffer.resize(buffer.len() + 100, 0);\n        }\n    }\n}\n"
  },
  {
    "path": "core/src/session.rs",
    "content": "use std::{\n    collections::HashMap,\n    future::Future,\n    io,\n    pin::Pin,\n    process::exit,\n    sync::{Arc, OnceLock, RwLock, Weak},\n    task::{Context, Poll},\n    time::{Duration, SystemTime, UNIX_EPOCH},\n};\n\nuse crate::dealer::manager::DealerManager;\nuse crate::{\n    Error,\n    apresolve::{ApResolver, SocketAddress},\n    audio_key::AudioKeyManager,\n    authentication::Credentials,\n    cache::Cache,\n    channel::ChannelManager,\n    config::SessionConfig,\n    connection::{self, AuthenticationError, Transport},\n    http_client::HttpClient,\n    login5::Login5Manager,\n    mercury::MercuryManager,\n    packet::PacketType,\n    protocol::keyexchange::ErrorCode,\n    spclient::SpClient,\n    token::TokenProvider,\n};\nuse byteorder::{BigEndian, ByteOrder};\nuse bytes::Bytes;\nuse futures_core::TryStream;\nuse futures_util::StreamExt;\nuse librespot_protocol::authentication::AuthenticationType;\nuse num_traits::FromPrimitive;\nuse pin_project_lite::pin_project;\nuse quick_xml::events::Event;\nuse thiserror::Error;\nuse tokio::{\n    sync::mpsc,\n    time::{Duration as TokioDuration, Instant as TokioInstant, Sleep, sleep},\n};\nuse tokio_stream::wrappers::UnboundedReceiverStream;\nuse uuid::Uuid;\n\nconst SESSION_DATA_POISON_MSG: &str = \"session data rwlock should not be poisoned\";\n\n#[derive(Debug, Error)]\npub enum SessionError {\n    #[error(transparent)]\n    AuthenticationError(#[from] AuthenticationError),\n    #[error(\"Cannot create session: {0}\")]\n    IoError(#[from] io::Error),\n    #[error(\"Session is not connected\")]\n    NotConnected,\n    #[error(\"packet {0} unknown\")]\n    Packet(u8),\n}\n\nimpl From<SessionError> for Error {\n    fn from(err: SessionError) -> Self {\n        match err {\n            SessionError::AuthenticationError(_) => Error::unauthenticated(err),\n            SessionError::IoError(_) => Error::unavailable(err),\n            SessionError::NotConnected => Error::unavailable(err),\n            SessionError::Packet(_) => Error::unimplemented(err),\n        }\n    }\n}\n\nimpl From<quick_xml::encoding::EncodingError> for Error {\n    fn from(err: quick_xml::encoding::EncodingError) -> Self {\n        Error::invalid_argument(err)\n    }\n}\n\npub type UserAttributes = HashMap<String, String>;\n\n#[derive(Debug, Clone, Default)]\npub struct UserData {\n    pub country: String,\n    pub canonical_username: String,\n    pub attributes: UserAttributes,\n}\n\n#[derive(Debug, Clone, Default)]\nstruct SessionData {\n    session_id: String,\n    client_id: String,\n    client_name: String,\n    client_brand_name: String,\n    client_model_name: String,\n    connection_id: String,\n    auth_data: Vec<u8>,\n    time_delta: i64,\n    invalid: bool,\n    user_data: UserData,\n}\n\nstruct SessionInternal {\n    config: SessionConfig,\n    data: RwLock<SessionData>,\n\n    http_client: HttpClient,\n    tx_connection: OnceLock<mpsc::UnboundedSender<(u8, Vec<u8>)>>,\n\n    apresolver: OnceLock<ApResolver>,\n    audio_key: OnceLock<AudioKeyManager>,\n    channel: OnceLock<ChannelManager>,\n    mercury: OnceLock<MercuryManager>,\n    dealer: OnceLock<DealerManager>,\n    spclient: OnceLock<SpClient>,\n    token_provider: OnceLock<TokenProvider>,\n    login5: OnceLock<Login5Manager>,\n    cache: Option<Arc<Cache>>,\n\n    handle: tokio::runtime::Handle,\n}\n\n/// A shared reference to a Spotify session.\n///\n/// After instantiating, you need to login via [Session::connect].\n/// You can either implement the whole playback logic yourself by using\n/// this structs interface directly or hand it to a\n/// `Player`.\n///\n/// *Note*: [Session] instances cannot yet be reused once invalidated. After\n/// an unexpectedly closed connection, you'll need to create a new [Session].\n#[derive(Clone)]\npub struct Session(Arc<SessionInternal>);\n\nimpl Session {\n    pub fn new(config: SessionConfig, cache: Option<Cache>) -> Self {\n        let http_client = HttpClient::new(config.proxy.as_ref());\n\n        debug!(\"new Session\");\n\n        let session_data = SessionData {\n            client_id: config.client_id.clone(),\n            // can be any guid, doesn't need to be simple\n            session_id: Uuid::new_v4().as_simple().to_string(),\n            ..SessionData::default()\n        };\n\n        Self(Arc::new(SessionInternal {\n            config,\n            data: RwLock::new(session_data),\n            http_client,\n            tx_connection: OnceLock::new(),\n            cache: cache.map(Arc::new),\n            apresolver: OnceLock::new(),\n            audio_key: OnceLock::new(),\n            channel: OnceLock::new(),\n            mercury: OnceLock::new(),\n            dealer: OnceLock::new(),\n            spclient: OnceLock::new(),\n            token_provider: OnceLock::new(),\n            login5: OnceLock::new(),\n            handle: tokio::runtime::Handle::current(),\n        }))\n    }\n\n    async fn connect_inner(\n        &self,\n        access_point: &SocketAddress,\n        credentials: Credentials,\n    ) -> Result<(Credentials, Transport), Error> {\n        const MAX_RETRIES: u8 = 1;\n        let mut transport = connection::connect_with_retry(\n            &access_point.0,\n            access_point.1,\n            self.config().proxy.as_ref(),\n            MAX_RETRIES,\n        )\n        .await?;\n        let mut reusable_credentials = connection::authenticate(\n            &mut transport,\n            credentials.clone(),\n            &self.config().device_id,\n        )\n        .await?;\n\n        // Might be able to remove this once keymaster is replaced with login5.\n        if credentials.auth_type == AuthenticationType::AUTHENTICATION_SPOTIFY_TOKEN {\n            trace!(\n                \"Reconnect using stored credentials as token authed sessions cannot use keymaster.\"\n            );\n            transport = connection::connect_with_retry(\n                &access_point.0,\n                access_point.1,\n                self.config().proxy.as_ref(),\n                MAX_RETRIES,\n            )\n            .await?;\n            reusable_credentials = connection::authenticate(\n                &mut transport,\n                reusable_credentials.clone(),\n                &self.config().device_id,\n            )\n            .await?;\n        }\n\n        Ok((reusable_credentials, transport))\n    }\n\n    pub async fn connect(\n        &self,\n        credentials: Credentials,\n        store_credentials: bool,\n    ) -> Result<(), Error> {\n        // There currently happen to be 6 APs but anything will do to avoid an infinite loop.\n        const MAX_AP_TRIES: u8 = 6;\n        let mut num_ap_tries = 0;\n        let (reusable_credentials, transport) = loop {\n            let ap = self.apresolver().resolve(\"accesspoint\").await?;\n            info!(\"Connecting to AP \\\"{}:{}\\\"\", ap.0, ap.1);\n            match self.connect_inner(&ap, credentials.clone()).await {\n                Ok(ct) => break ct,\n                Err(e) => {\n                    num_ap_tries += 1;\n                    if MAX_AP_TRIES == num_ap_tries {\n                        error!(\"Tried too many access points\");\n                        return Err(e);\n                    }\n                    if let Some(AuthenticationError::LoginFailed(ErrorCode::TryAnotherAP)) =\n                        e.error.downcast_ref::<AuthenticationError>()\n                    {\n                        warn!(\"Instructed to try another access point...\");\n                        continue;\n                    } else if let Some(AuthenticationError::LoginFailed(..)) =\n                        e.error.downcast_ref::<AuthenticationError>()\n                    {\n                        return Err(e);\n                    } else {\n                        warn!(\"Try another access point...\");\n                        continue;\n                    }\n                }\n            }\n        };\n\n        let username = reusable_credentials\n            .username\n            .as_ref()\n            .map_or(\"UNKNOWN\", |s| s.as_str());\n        info!(\"Authenticated as '{username}' !\");\n        self.set_username(username);\n        self.set_auth_data(&reusable_credentials.auth_data);\n        if let Some(cache) = self.cache() {\n            if store_credentials {\n                let cred_changed = cache\n                    .credentials()\n                    .map(|c| c != reusable_credentials)\n                    .unwrap_or(true);\n                if cred_changed {\n                    cache.save_credentials(&reusable_credentials);\n                }\n            }\n        }\n\n        // This channel serves as a buffer for packets and serializes access to the TcpStream, such\n        // that `self.send_packet` can return immediately and needs no additional synchronization.\n        let (tx_connection, rx_connection) = mpsc::unbounded_channel();\n        self.0\n            .tx_connection\n            .set(tx_connection)\n            .map_err(|_| SessionError::NotConnected)?;\n\n        let (sink, stream) = transport.split();\n        let sender_task = UnboundedReceiverStream::new(rx_connection)\n            .map(Ok)\n            .forward(sink);\n        let session_weak = self.weak();\n        tokio::spawn(async move {\n            if let Err(e) = sender_task.await {\n                error!(\"{e}\");\n                if let Some(session) = session_weak.try_upgrade() {\n                    if !session.is_invalid() {\n                        session.shutdown();\n                    }\n                }\n            }\n        });\n\n        tokio::spawn(DispatchTask::new(self.weak(), stream));\n\n        Ok(())\n    }\n\n    pub fn apresolver(&self) -> &ApResolver {\n        self.0\n            .apresolver\n            .get_or_init(|| ApResolver::new(self.weak()))\n    }\n\n    pub fn audio_key(&self) -> &AudioKeyManager {\n        self.0\n            .audio_key\n            .get_or_init(|| AudioKeyManager::new(self.weak()))\n    }\n\n    pub fn channel(&self) -> &ChannelManager {\n        self.0\n            .channel\n            .get_or_init(|| ChannelManager::new(self.weak()))\n    }\n\n    pub fn http_client(&self) -> &HttpClient {\n        &self.0.http_client\n    }\n\n    pub fn mercury(&self) -> &MercuryManager {\n        self.0\n            .mercury\n            .get_or_init(|| MercuryManager::new(self.weak()))\n    }\n\n    pub fn dealer(&self) -> &DealerManager {\n        self.0\n            .dealer\n            .get_or_init(|| DealerManager::new(self.weak()))\n    }\n\n    pub fn spclient(&self) -> &SpClient {\n        self.0.spclient.get_or_init(|| SpClient::new(self.weak()))\n    }\n\n    pub fn token_provider(&self) -> &TokenProvider {\n        self.0\n            .token_provider\n            .get_or_init(|| TokenProvider::new(self.weak()))\n    }\n\n    pub fn login5(&self) -> &Login5Manager {\n        self.0\n            .login5\n            .get_or_init(|| Login5Manager::new(self.weak()))\n    }\n\n    pub fn time_delta(&self) -> i64 {\n        self.0\n            .data\n            .read()\n            .expect(SESSION_DATA_POISON_MSG)\n            .time_delta\n    }\n\n    pub fn spawn<T>(&self, task: T)\n    where\n        T: Future + Send + 'static,\n        T::Output: Send + 'static,\n    {\n        self.0.handle.spawn(task);\n    }\n\n    fn debug_info(&self) {\n        debug!(\n            \"Session strong={} weak={}\",\n            Arc::strong_count(&self.0),\n            Arc::weak_count(&self.0)\n        );\n    }\n\n    fn check_catalogue(attributes: &UserAttributes) {\n        if let Some(account_type) = attributes.get(\"type\") {\n            if account_type != \"premium\" {\n                error!(\"librespot does not support {account_type:?} accounts.\");\n                info!(\"Please support Spotify and your artists and sign up for a premium account.\");\n\n                // TODO: logout instead of exiting\n                exit(1);\n            }\n        }\n    }\n\n    pub fn send_packet(&self, cmd: PacketType, data: Vec<u8>) -> Result<(), Error> {\n        match self.0.tx_connection.get() {\n            Some(tx) => Ok(tx.send((cmd as u8, data))?),\n            None => Err(SessionError::NotConnected.into()),\n        }\n    }\n\n    pub fn cache(&self) -> Option<&Arc<Cache>> {\n        self.0.cache.as_ref()\n    }\n\n    pub fn config(&self) -> &SessionConfig {\n        &self.0.config\n    }\n\n    // This clones a fairly large struct, so use a specific getter or setter unless\n    // you need more fields at once, in which case this can spare multiple `read`\n    // locks.\n    pub fn user_data(&self) -> UserData {\n        self.0\n            .data\n            .read()\n            .expect(SESSION_DATA_POISON_MSG)\n            .user_data\n            .clone()\n    }\n\n    pub fn session_id(&self) -> String {\n        self.0\n            .data\n            .read()\n            .expect(SESSION_DATA_POISON_MSG)\n            .session_id\n            .clone()\n    }\n\n    pub fn set_session_id(&self, session_id: &str) {\n        session_id.clone_into(\n            &mut self\n                .0\n                .data\n                .write()\n                .expect(SESSION_DATA_POISON_MSG)\n                .session_id,\n        );\n    }\n\n    pub fn device_id(&self) -> &str {\n        &self.config().device_id\n    }\n\n    pub fn client_id(&self) -> String {\n        self.0\n            .data\n            .read()\n            .expect(SESSION_DATA_POISON_MSG)\n            .client_id\n            .clone()\n    }\n\n    pub fn set_client_id(&self, client_id: &str) {\n        client_id.clone_into(\n            &mut self\n                .0\n                .data\n                .write()\n                .expect(SESSION_DATA_POISON_MSG)\n                .client_id,\n        );\n    }\n\n    pub fn client_name(&self) -> String {\n        self.0\n            .data\n            .read()\n            .expect(SESSION_DATA_POISON_MSG)\n            .client_name\n            .clone()\n    }\n\n    pub fn set_client_name(&self, client_name: &str) {\n        client_name.clone_into(\n            &mut self\n                .0\n                .data\n                .write()\n                .expect(SESSION_DATA_POISON_MSG)\n                .client_name,\n        );\n    }\n\n    pub fn client_brand_name(&self) -> String {\n        self.0\n            .data\n            .read()\n            .expect(SESSION_DATA_POISON_MSG)\n            .client_brand_name\n            .clone()\n    }\n\n    pub fn set_client_brand_name(&self, client_brand_name: &str) {\n        client_brand_name.clone_into(\n            &mut self\n                .0\n                .data\n                .write()\n                .expect(SESSION_DATA_POISON_MSG)\n                .client_brand_name,\n        );\n    }\n\n    pub fn client_model_name(&self) -> String {\n        self.0\n            .data\n            .read()\n            .expect(SESSION_DATA_POISON_MSG)\n            .client_model_name\n            .clone()\n    }\n\n    pub fn set_client_model_name(&self, client_model_name: &str) {\n        client_model_name.clone_into(\n            &mut self\n                .0\n                .data\n                .write()\n                .expect(SESSION_DATA_POISON_MSG)\n                .client_model_name,\n        );\n    }\n\n    pub fn connection_id(&self) -> String {\n        self.0\n            .data\n            .read()\n            .expect(SESSION_DATA_POISON_MSG)\n            .connection_id\n            .clone()\n    }\n\n    pub fn set_connection_id(&self, connection_id: &str) {\n        connection_id.clone_into(\n            &mut self\n                .0\n                .data\n                .write()\n                .expect(SESSION_DATA_POISON_MSG)\n                .connection_id,\n        );\n    }\n\n    pub fn username(&self) -> String {\n        self.0\n            .data\n            .read()\n            .expect(SESSION_DATA_POISON_MSG)\n            .user_data\n            .canonical_username\n            .clone()\n    }\n\n    pub fn set_username(&self, username: &str) {\n        username.clone_into(\n            &mut self\n                .0\n                .data\n                .write()\n                .expect(SESSION_DATA_POISON_MSG)\n                .user_data\n                .canonical_username,\n        );\n    }\n\n    pub fn auth_data(&self) -> Vec<u8> {\n        self.0\n            .data\n            .read()\n            .expect(SESSION_DATA_POISON_MSG)\n            .auth_data\n            .clone()\n    }\n\n    pub fn set_auth_data(&self, auth_data: &[u8]) {\n        auth_data.clone_into(\n            &mut self\n                .0\n                .data\n                .write()\n                .expect(SESSION_DATA_POISON_MSG)\n                .auth_data,\n        );\n    }\n\n    pub fn country(&self) -> String {\n        self.0\n            .data\n            .read()\n            .expect(SESSION_DATA_POISON_MSG)\n            .user_data\n            .country\n            .clone()\n    }\n\n    pub fn filter_explicit_content(&self) -> bool {\n        match self.get_user_attribute(\"filter-explicit-content\") {\n            Some(value) => matches!(&*value, \"1\"),\n            None => false,\n        }\n    }\n\n    pub fn autoplay(&self) -> bool {\n        if let Some(overide) = self.config().autoplay {\n            return overide;\n        }\n\n        match self.get_user_attribute(\"autoplay\") {\n            Some(value) => matches!(&*value, \"1\"),\n            None => false,\n        }\n    }\n\n    pub fn set_user_attribute(&self, key: &str, value: &str) -> Option<String> {\n        let mut dummy_attributes = UserAttributes::new();\n        dummy_attributes.insert(key.to_owned(), value.to_owned());\n        Self::check_catalogue(&dummy_attributes);\n\n        self.0\n            .data\n            .write()\n            .expect(SESSION_DATA_POISON_MSG)\n            .user_data\n            .attributes\n            .insert(key.to_owned(), value.to_owned())\n    }\n\n    pub fn set_user_attributes(&self, attributes: UserAttributes) {\n        Self::check_catalogue(&attributes);\n\n        self.0\n            .data\n            .write()\n            .expect(SESSION_DATA_POISON_MSG)\n            .user_data\n            .attributes\n            .extend(attributes)\n    }\n\n    pub fn get_user_attribute(&self, key: &str) -> Option<String> {\n        self.0\n            .data\n            .read()\n            .expect(SESSION_DATA_POISON_MSG)\n            .user_data\n            .attributes\n            .get(key)\n            .cloned()\n    }\n\n    fn weak(&self) -> SessionWeak {\n        SessionWeak(Arc::downgrade(&self.0))\n    }\n\n    pub fn shutdown(&self) {\n        debug!(\"Shutdown: Invalidating session\");\n        self.0.data.write().expect(SESSION_DATA_POISON_MSG).invalid = true;\n        self.mercury().shutdown();\n        self.channel().shutdown();\n    }\n\n    pub fn is_invalid(&self) -> bool {\n        self.0.data.read().expect(SESSION_DATA_POISON_MSG).invalid\n    }\n}\n\n#[derive(Clone)]\npub struct SessionWeak(Weak<SessionInternal>);\n\nimpl SessionWeak {\n    fn try_upgrade(&self) -> Option<Session> {\n        self.0.upgrade().map(Session)\n    }\n\n    pub(crate) fn upgrade(&self) -> Session {\n        self.try_upgrade()\n            .expect(\"session was dropped and so should have this component\")\n    }\n}\n\nimpl Drop for SessionInternal {\n    fn drop(&mut self) {\n        debug!(\"drop Session\");\n    }\n}\n\n#[derive(Clone, Copy, Default, Debug, PartialEq)]\nenum KeepAliveState {\n    #[default]\n    // Expecting a Ping from the server, either after startup or after a PongAck.\n    ExpectingPing,\n\n    // We need to send a Pong at the given time.\n    PendingPong,\n\n    // We just sent a Pong and wait for it be ACK'd.\n    ExpectingPongAck,\n}\n\nconst INITIAL_PING_TIMEOUT: TokioDuration = TokioDuration::from_secs(20);\nconst PING_TIMEOUT: TokioDuration = TokioDuration::from_secs(80); // 60s expected + 20s buffer\nconst PONG_DELAY: TokioDuration = TokioDuration::from_secs(60);\nconst PONG_ACK_TIMEOUT: TokioDuration = TokioDuration::from_secs(20);\n\nimpl KeepAliveState {\n    fn debug(&self, sleep: &Sleep) {\n        let delay = sleep\n            .deadline()\n            .checked_duration_since(TokioInstant::now())\n            .map(|t| t.as_secs_f64())\n            .unwrap_or(f64::INFINITY);\n\n        trace!(\"keep-alive state: {self:?}, timeout in {delay:.1}\");\n    }\n}\n\npin_project! {\n    struct DispatchTask<S>\n    where\n        S: TryStream<Ok = (u8, Bytes)>\n    {\n        session: SessionWeak,\n        keep_alive_state: KeepAliveState,\n        #[pin]\n        stream: S,\n        #[pin]\n        timeout: Sleep,\n    }\n\n    impl<S> PinnedDrop for DispatchTask<S>\n    where\n        S: TryStream<Ok = (u8, Bytes)>\n    {\n        fn drop(_this: Pin<&mut Self>) {\n            debug!(\"drop Dispatch\");\n        }\n    }\n}\n\nimpl<S> DispatchTask<S>\nwhere\n    S: TryStream<Ok = (u8, Bytes)>,\n{\n    fn new(session: SessionWeak, stream: S) -> Self {\n        Self {\n            session,\n            keep_alive_state: KeepAliveState::ExpectingPing,\n            stream,\n            timeout: sleep(INITIAL_PING_TIMEOUT),\n        }\n    }\n\n    fn dispatch(\n        mut self: Pin<&mut Self>,\n        session: &Session,\n        cmd: u8,\n        data: Bytes,\n    ) -> Result<(), Error> {\n        use KeepAliveState::*;\n        use PacketType::*;\n\n        let packet_type = FromPrimitive::from_u8(cmd);\n        let cmd = match packet_type {\n            Some(cmd) => cmd,\n            None => {\n                trace!(\"Ignoring unknown packet {cmd:x}\");\n                return Err(SessionError::Packet(cmd).into());\n            }\n        };\n\n        match packet_type {\n            Some(Ping) => {\n                trace!(\"Received Ping\");\n                if self.keep_alive_state != ExpectingPing {\n                    warn!(\"Received unexpected Ping from server\")\n                }\n                let mut this = self.as_mut().project();\n                *this.keep_alive_state = PendingPong;\n                this.timeout\n                    .as_mut()\n                    .reset(TokioInstant::now() + PONG_DELAY);\n                this.keep_alive_state.debug(&this.timeout);\n\n                let server_timestamp = BigEndian::read_u32(data.as_ref()) as i64;\n                let timestamp = SystemTime::now()\n                    .duration_since(UNIX_EPOCH)\n                    .unwrap_or(Duration::ZERO)\n                    .as_secs() as i64;\n                {\n                    let mut data = session.0.data.write().expect(SESSION_DATA_POISON_MSG);\n                    data.time_delta = server_timestamp.saturating_sub(timestamp);\n                }\n\n                session.debug_info();\n\n                Ok(())\n            }\n            Some(PongAck) => {\n                trace!(\"Received PongAck\");\n                if self.keep_alive_state != ExpectingPongAck {\n                    warn!(\"Received unexpected PongAck from server\")\n                }\n                let mut this = self.as_mut().project();\n                *this.keep_alive_state = ExpectingPing;\n                this.timeout\n                    .as_mut()\n                    .reset(TokioInstant::now() + PING_TIMEOUT);\n                this.keep_alive_state.debug(&this.timeout);\n\n                Ok(())\n            }\n            Some(CountryCode) => {\n                let country = String::from_utf8(data.as_ref().to_owned())?;\n                info!(\"Country: {country:?}\");\n                session\n                    .0\n                    .data\n                    .write()\n                    .expect(SESSION_DATA_POISON_MSG)\n                    .user_data\n                    .country = country;\n                Ok(())\n            }\n            Some(StreamChunkRes) | Some(ChannelError) => session.channel().dispatch(cmd, data),\n            Some(AesKey) | Some(AesKeyError) => session.audio_key().dispatch(cmd, data),\n            Some(MercuryReq) | Some(MercurySub) | Some(MercuryUnsub) | Some(MercuryEvent) => {\n                session.mercury().dispatch(cmd, data)\n            }\n            Some(ProductInfo) => {\n                let data = std::str::from_utf8(&data)?;\n                let mut reader = quick_xml::Reader::from_str(data);\n\n                let mut buf = Vec::new();\n                let mut current_element = String::new();\n                let mut user_attributes: UserAttributes = HashMap::new();\n\n                loop {\n                    match reader.read_event_into(&mut buf) {\n                        Ok(Event::Start(ref element)) => {\n                            std::str::from_utf8(element)?.clone_into(&mut current_element)\n                        }\n                        Ok(Event::End(_)) => {\n                            current_element = String::new();\n                        }\n                        Ok(Event::Text(ref value)) => {\n                            if !current_element.is_empty() {\n                                let _ = user_attributes.insert(\n                                    current_element.clone(),\n                                    value.xml_content()?.to_string(),\n                                );\n                            }\n                        }\n                        Ok(Event::Eof) => break,\n                        Ok(_) => (),\n                        Err(e) => warn!(\n                            \"Error parsing XML at position {}: {:?}\",\n                            reader.buffer_position(),\n                            e\n                        ),\n                    }\n                }\n\n                trace!(\"Received product info: {user_attributes:#?}\");\n                Session::check_catalogue(&user_attributes);\n\n                session\n                    .0\n                    .data\n                    .write()\n                    .expect(SESSION_DATA_POISON_MSG)\n                    .user_data\n                    .attributes = user_attributes;\n                Ok(())\n            }\n            Some(SecretBlock)\n            | Some(LegacyWelcome)\n            | Some(UnknownDataAllZeros)\n            | Some(LicenseVersion) => Ok(()),\n            _ => {\n                trace!(\"Ignoring {cmd:?} packet with data {data:#?}\");\n                Err(SessionError::Packet(cmd as u8).into())\n            }\n        }\n    }\n}\n\nimpl<S> Future for DispatchTask<S>\nwhere\n    S: TryStream<Ok = (u8, Bytes), Error = std::io::Error>,\n    <S as TryStream>::Ok: std::fmt::Debug,\n{\n    type Output = Result<(), S::Error>;\n\n    fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {\n        use KeepAliveState::*;\n\n        let session = match self.session.try_upgrade() {\n            Some(session) => session,\n            None => return Poll::Ready(Ok(())),\n        };\n\n        // Process all messages that are immediately ready\n        loop {\n            match self.as_mut().project().stream.try_poll_next(cx) {\n                Poll::Ready(Some(Ok((cmd, data)))) => {\n                    let result = self.as_mut().dispatch(&session, cmd, data);\n                    if let Err(e) = result {\n                        debug!(\"could not dispatch command: {e}\");\n                    }\n                }\n                Poll::Ready(None) => {\n                    warn!(\"Connection to server closed.\");\n                    session.shutdown();\n                    return Poll::Ready(Ok(()));\n                }\n                Poll::Ready(Some(Err(e))) => {\n                    error!(\"Connection to server closed.\");\n                    session.shutdown();\n                    return Poll::Ready(Err(e));\n                }\n                Poll::Pending => break,\n            }\n        }\n\n        // Handle the keep-alive sequence, returning an error when we haven't received a\n        // Ping/PongAck for too long.\n        //\n        // The expected keepalive sequence is\n        // - Server: Ping\n        // - wait 60s\n        // - Client: Pong\n        // - Server: PongAck\n        // - wait 60s\n        // - repeat\n        //\n        // This means that we silently lost connection to Spotify servers if\n        // - we don't receive Ping immediately after connecting,\n        // - we don't receive a Ping 60s after the last PongAck or\n        // - we don't receive a PongAck immediately after our Pong.\n        //\n        // Currently, we add a safety margin of 20s to these expected deadlines.\n        let mut this = self.as_mut().project();\n        if let Poll::Ready(()) = this.timeout.as_mut().poll(cx) {\n            match this.keep_alive_state {\n                ExpectingPing | ExpectingPongAck => {\n                    if !session.is_invalid() {\n                        session.shutdown();\n                    }\n                    // TODO: Optionally reconnect (with cached/last credentials?)\n                    return Poll::Ready(Err(io::Error::new(\n                        io::ErrorKind::TimedOut,\n                        format!(\n                            \"session lost connection to server ({:?})\",\n                            this.keep_alive_state\n                        ),\n                    )));\n                }\n                PendingPong => {\n                    trace!(\"Sending Pong\");\n                    // TODO: Ideally, this should flush the `Framed<TcpStream> as Sink`\n                    // before starting the timeout.\n                    let _ = session.send_packet(PacketType::Pong, vec![0, 0, 0, 0]);\n                    *this.keep_alive_state = ExpectingPongAck;\n                    this.timeout\n                        .as_mut()\n                        .reset(TokioInstant::now() + PONG_ACK_TIMEOUT);\n                    this.keep_alive_state.debug(&this.timeout);\n                }\n            }\n        }\n\n        Poll::Pending\n    }\n}\n"
  },
  {
    "path": "core/src/socket.rs",
    "content": "use std::io;\n\nuse tokio::net::TcpStream;\nuse url::Url;\n\nuse crate::proxytunnel;\n\npub async fn connect(host: &str, port: u16, proxy: Option<&Url>) -> io::Result<TcpStream> {\n    if let Some(proxy_url) = proxy {\n        info!(\"Using proxy \\\"{proxy_url}\\\"\");\n\n        let socket_addrs = proxy_url.socket_addrs(|| None)?;\n        let socket = TcpStream::connect(&*socket_addrs).await?;\n\n        proxytunnel::proxy_connect(socket, host, &port.to_string()).await\n    } else {\n        TcpStream::connect((host, port)).await\n    }\n}\n"
  },
  {
    "path": "core/src/spclient.rs",
    "content": "use std::{\n    fmt::Write,\n    time::{Duration, SystemTime},\n};\n\nuse crate::config::{OS, os_version};\nuse crate::{\n    Error, FileId, SpotifyId, SpotifyUri,\n    apresolve::SocketAddress,\n    config::SessionConfig,\n    dealer::protocol::TransferOptions,\n    error::ErrorKind,\n    protocol::{\n        autoplay_context_request::AutoplayContextRequest,\n        clienttoken_http::{\n            ChallengeAnswer, ChallengeType, ClientTokenRequest, ClientTokenRequestType,\n            ClientTokenResponse, ClientTokenResponseType,\n        },\n        connect::PutStateRequest,\n        context::Context,\n        extended_metadata::BatchedEntityRequest,\n        extended_metadata::{BatchedExtensionResponse, EntityRequest, ExtensionQuery},\n        extension_kind::ExtensionKind,\n    },\n    token::Token,\n    util,\n    version::spotify_semantic_version,\n};\nuse bytes::Bytes;\nuse data_encoding::HEXUPPER_PERMISSIVE;\nuse futures_util::future::IntoStream;\nuse http::{Uri, header::HeaderValue};\nuse hyper::{\n    HeaderMap, Method, Request,\n    header::{ACCEPT, AUTHORIZATION, CONTENT_LENGTH, CONTENT_TYPE, HeaderName, RANGE},\n};\nuse hyper_util::client::legacy::ResponseFuture;\nuse protobuf::{Enum, EnumOrUnknown, Message, MessageFull};\nuse rand::RngCore;\nuse serde::Serialize;\nuse sysinfo::System;\nuse thiserror::Error;\n\ncomponent! {\n    SpClient : SpClientInner {\n        accesspoint: Option<SocketAddress> = None,\n        strategy: RequestStrategy = RequestStrategy::default(),\n        client_token: Option<Token> = None,\n    }\n}\n\npub type SpClientResult = Result<Bytes, Error>;\n\n#[allow(clippy::declare_interior_mutable_const)]\npub const CLIENT_TOKEN: HeaderName = HeaderName::from_static(\"client-token\");\n#[allow(clippy::declare_interior_mutable_const)]\nconst CONNECTION_ID: HeaderName = HeaderName::from_static(\"x-spotify-connection-id\");\n\nconst NO_METRICS_AND_SALT: RequestOptions = RequestOptions {\n    metrics: false,\n    salt: false,\n    base_url: None,\n};\n\n#[derive(Debug, Error)]\npub enum SpClientError {\n    #[error(\"missing attribute {0}\")]\n    Attribute(String),\n    #[error(\"expected data but received none\")]\n    NoData,\n    #[error(\"expected an entry to exist in {0}\")]\n    ExpectedEntry(&'static str),\n}\n\nimpl From<SpClientError> for Error {\n    fn from(err: SpClientError) -> Self {\n        Self::failed_precondition(err)\n    }\n}\n\n#[derive(Copy, Clone, Debug)]\npub enum RequestStrategy {\n    TryTimes(usize),\n    Infinitely,\n}\n\nimpl Default for RequestStrategy {\n    fn default() -> Self {\n        RequestStrategy::TryTimes(10)\n    }\n}\n\npub struct RequestOptions {\n    metrics: bool,\n    salt: bool,\n    base_url: Option<&'static str>,\n}\n\nimpl Default for RequestOptions {\n    fn default() -> Self {\n        Self {\n            metrics: true,\n            salt: true,\n            base_url: None,\n        }\n    }\n}\n\n#[derive(Debug, Serialize)]\npub struct TransferRequest {\n    pub transfer_options: TransferOptions,\n}\n\nimpl SpClient {\n    pub fn set_strategy(&self, strategy: RequestStrategy) {\n        self.lock(|inner| inner.strategy = strategy)\n    }\n\n    pub async fn flush_accesspoint(&self) {\n        self.lock(|inner| inner.accesspoint = None)\n    }\n\n    pub async fn get_accesspoint(&self) -> Result<SocketAddress, Error> {\n        // Memoize the current access point.\n        let ap = self.lock(|inner| inner.accesspoint.clone());\n        let tuple = match ap {\n            Some(tuple) => tuple,\n            None => {\n                let tuple = self.session().apresolver().resolve(\"spclient\").await?;\n                self.lock(|inner| inner.accesspoint = Some(tuple.clone()));\n                info!(\n                    \"Resolved \\\"{}:{}\\\" as spclient access point\",\n                    tuple.0, tuple.1\n                );\n                tuple\n            }\n        };\n        Ok(tuple)\n    }\n\n    pub async fn base_url(&self) -> Result<String, Error> {\n        let ap = self.get_accesspoint().await?;\n        Ok(format!(\"https://{}:{}\", ap.0, ap.1))\n    }\n\n    async fn client_token_request<M: Message>(&self, message: &M) -> Result<Bytes, Error> {\n        let body = message.write_to_bytes()?;\n\n        let request = Request::builder()\n            .method(&Method::POST)\n            .uri(\"https://clienttoken.spotify.com/v1/clienttoken\")\n            .header(ACCEPT, HeaderValue::from_static(\"application/x-protobuf\"))\n            .body(body.into())?;\n\n        self.session().http_client().request_body(request).await\n    }\n\n    pub async fn client_token(&self) -> Result<String, Error> {\n        let client_token = self.lock(|inner| {\n            if let Some(token) = &inner.client_token {\n                if token.is_expired() {\n                    inner.client_token = None;\n                }\n            }\n            inner.client_token.clone()\n        });\n\n        if let Some(client_token) = client_token {\n            return Ok(client_token.access_token);\n        }\n\n        debug!(\"Client token unavailable or expired, requesting new token.\");\n\n        let mut request = ClientTokenRequest::new();\n        request.request_type = ClientTokenRequestType::REQUEST_CLIENT_DATA_REQUEST.into();\n\n        let client_data = request.mut_client_data();\n\n        client_data.client_version = spotify_semantic_version();\n\n        // Current state of affairs: keymaster ID works on all tested platforms, but may be phased out,\n        // so it seems a good idea to mimick the real clients. `self.session().client_id()` returns the\n        // ID of the client that last connected, but requesting a client token with this ID only works\n        // on macOS and Windows. On Android and iOS we can send a platform-specific client ID and are\n        // then presented with a hash cash challenge. On Linux, we have to pass the old keymaster ID.\n        // We delegate most of this logic to `SessionConfig`.\n        let os = OS;\n        let client_id = match os {\n            \"macos\" | \"windows\" => self.session().client_id(),\n            os => SessionConfig::default_for_os(os).client_id,\n        };\n        client_data.client_id = client_id;\n\n        let connectivity_data = client_data.mut_connectivity_sdk_data();\n        connectivity_data.device_id = self.session().device_id().to_string();\n\n        let platform_data = connectivity_data\n            .platform_specific_data\n            .mut_or_insert_default();\n\n        let os_version = os_version();\n        let kernel_version = System::kernel_version().unwrap_or_else(|| String::from(\"0\"));\n\n        match os {\n            \"windows\" => {\n                let os_version = os_version.parse::<f32>().unwrap_or(10.) as i32;\n                let kernel_version = kernel_version.parse::<i32>().unwrap_or(21370);\n\n                let (pe, image_file) = match std::env::consts::ARCH {\n                    \"arm\" => (448, 452),\n                    \"aarch64\" => (43620, 452),\n                    \"x86_64\" => (34404, 34404),\n                    _ => (332, 332), // x86\n                };\n\n                let windows_data = platform_data.mut_desktop_windows();\n                windows_data.os_version = os_version;\n                windows_data.os_build = kernel_version;\n                windows_data.platform_id = 2;\n                windows_data.unknown_value_6 = 9;\n                windows_data.image_file_machine = image_file;\n                windows_data.pe_machine = pe;\n                windows_data.unknown_value_10 = true;\n            }\n            \"ios\" => {\n                let ios_data = platform_data.mut_ios();\n                ios_data.user_interface_idiom = 0;\n                ios_data.target_iphone_simulator = false;\n                ios_data.hw_machine = \"iPhone14,5\".to_string();\n                ios_data.system_version = os_version;\n            }\n            \"android\" => {\n                let android_data = platform_data.mut_android();\n                android_data.android_version = os_version;\n                android_data.api_version = 31;\n                \"Pixel\".clone_into(&mut android_data.device_name);\n                \"GF5KQ\".clone_into(&mut android_data.model_str);\n                \"Google\".clone_into(&mut android_data.vendor);\n            }\n            \"macos\" => {\n                let macos_data = platform_data.mut_desktop_macos();\n                macos_data.system_version = os_version;\n                macos_data.hw_model = \"iMac21,1\".to_string();\n                macos_data.compiled_cpu_type = std::env::consts::ARCH.to_string();\n            }\n            _ => {\n                let linux_data = platform_data.mut_desktop_linux();\n                linux_data.system_name = \"Linux\".to_string();\n                linux_data.system_release = kernel_version;\n                linux_data.system_version = os_version;\n                linux_data.hardware = std::env::consts::ARCH.to_string();\n            }\n        }\n\n        let mut response = self.client_token_request(&request).await?;\n        let mut count = 0;\n        const MAX_TRIES: u8 = 3;\n\n        let token_response = loop {\n            count += 1;\n\n            let message = ClientTokenResponse::parse_from_bytes(&response)?;\n\n            match ClientTokenResponseType::from_i32(message.response_type.value()) {\n                // depending on the platform, you're either given a token immediately\n                // or are presented a hash cash challenge to solve first\n                Some(ClientTokenResponseType::RESPONSE_GRANTED_TOKEN_RESPONSE) => {\n                    debug!(\"Received a granted token\");\n                    break message;\n                }\n                Some(ClientTokenResponseType::RESPONSE_CHALLENGES_RESPONSE) => {\n                    debug!(\"Received a hash cash challenge, solving...\");\n\n                    let challenges = message.challenges().clone();\n                    let state = challenges.state;\n                    if let Some(challenge) = challenges.challenges.first() {\n                        let hash_cash_challenge = challenge.evaluate_hashcash_parameters();\n\n                        let ctx = vec![];\n                        let prefix = HEXUPPER_PERMISSIVE\n                            .decode(hash_cash_challenge.prefix.as_bytes())\n                            .map_err(|e| {\n                                Error::failed_precondition(format!(\n                                    \"Unable to decode hash cash challenge: {e}\"\n                                ))\n                            })?;\n                        let length = hash_cash_challenge.length;\n\n                        let mut suffix = [0u8; 0x10];\n                        let answer = util::solve_hash_cash(&ctx, &prefix, length, &mut suffix);\n\n                        match answer {\n                            Ok(_) => {\n                                // the suffix must be in uppercase\n                                let suffix = HEXUPPER_PERMISSIVE.encode(&suffix);\n\n                                let mut answer_message = ClientTokenRequest::new();\n                                answer_message.request_type =\n                                    ClientTokenRequestType::REQUEST_CHALLENGE_ANSWERS_REQUEST\n                                        .into();\n\n                                let challenge_answers = answer_message.mut_challenge_answers();\n\n                                let mut challenge_answer = ChallengeAnswer::new();\n                                challenge_answer.mut_hash_cash().suffix = suffix;\n                                challenge_answer.ChallengeType =\n                                    ChallengeType::CHALLENGE_HASH_CASH.into();\n\n                                challenge_answers.state = state.to_string();\n                                challenge_answers.answers.push(challenge_answer);\n\n                                trace!(\"Answering hash cash challenge\");\n                                match self.client_token_request(&answer_message).await {\n                                    Ok(token) => {\n                                        response = token;\n                                        continue;\n                                    }\n                                    Err(e) => {\n                                        trace!(\"Answer not accepted {count}/{MAX_TRIES}: {e}\");\n                                    }\n                                }\n                            }\n                            Err(e) => trace!(\n                                \"Unable to solve hash cash challenge {count}/{MAX_TRIES}: {e}\"\n                            ),\n                        }\n\n                        if count < MAX_TRIES {\n                            response = self.client_token_request(&request).await?;\n                        } else {\n                            return Err(Error::failed_precondition(format!(\n                                \"Unable to solve any of {MAX_TRIES} hash cash challenges\"\n                            )));\n                        }\n                    } else {\n                        return Err(Error::failed_precondition(\"No challenges found\"));\n                    }\n                }\n\n                Some(unknown) => {\n                    return Err(Error::unimplemented(format!(\n                        \"Unknown client token response type: {unknown:?}\"\n                    )));\n                }\n                None => return Err(Error::failed_precondition(\"No client token response type\")),\n            }\n        };\n\n        let granted_token = token_response.granted_token();\n        let access_token = granted_token.token.to_owned();\n\n        self.lock(|inner| {\n            let client_token = Token {\n                access_token: access_token.clone(),\n                expires_in: Duration::from_secs(\n                    granted_token\n                        .refresh_after_seconds\n                        .try_into()\n                        .unwrap_or(7200),\n                ),\n                token_type: \"client-token\".to_string(),\n                scopes: granted_token\n                    .domains\n                    .iter()\n                    .map(|d| d.domain.clone())\n                    .collect(),\n                timestamp: SystemTime::now(),\n            };\n\n            inner.client_token = Some(client_token);\n        });\n\n        trace!(\"Got client token: {granted_token:?}\");\n\n        Ok(access_token)\n    }\n\n    pub async fn request_with_protobuf<M: Message + MessageFull>(\n        &self,\n        method: &Method,\n        endpoint: &str,\n        headers: Option<HeaderMap>,\n        message: &M,\n    ) -> SpClientResult {\n        self.request_with_protobuf_and_options(\n            method,\n            endpoint,\n            headers,\n            message,\n            &Default::default(),\n        )\n        .await\n    }\n\n    pub async fn request_with_protobuf_and_options<M: Message + MessageFull>(\n        &self,\n        method: &Method,\n        endpoint: &str,\n        headers: Option<HeaderMap>,\n        message: &M,\n        options: &RequestOptions,\n    ) -> SpClientResult {\n        let body = message.write_to_bytes()?;\n\n        let mut headers = headers.unwrap_or_default();\n        headers.insert(\n            CONTENT_TYPE,\n            HeaderValue::from_static(\"application/x-protobuf\"),\n        );\n\n        self.request_with_options(method, endpoint, Some(headers), Some(&body), options)\n            .await\n    }\n\n    pub async fn request_as_json(\n        &self,\n        method: &Method,\n        endpoint: &str,\n        headers: Option<HeaderMap>,\n        body: Option<&str>,\n    ) -> SpClientResult {\n        let mut headers = headers.unwrap_or_default();\n        headers.insert(ACCEPT, HeaderValue::from_static(\"application/json\"));\n\n        self.request(method, endpoint, Some(headers), body.map(str::as_bytes))\n            .await\n    }\n\n    pub async fn request(\n        &self,\n        method: &Method,\n        endpoint: &str,\n        headers: Option<HeaderMap>,\n        body: Option<&[u8]>,\n    ) -> SpClientResult {\n        self.request_with_options(method, endpoint, headers, body, &Default::default())\n            .await\n    }\n\n    pub async fn request_with_options(\n        &self,\n        method: &Method,\n        endpoint: &str,\n        headers: Option<HeaderMap>,\n        body: Option<&[u8]>,\n        options: &RequestOptions,\n    ) -> SpClientResult {\n        let mut tries: usize = 0;\n        let mut last_response;\n\n        let body = body.unwrap_or_default();\n\n        loop {\n            tries += 1;\n\n            // Reconnection logic: retrieve the endpoint every iteration, so we can try\n            // another access point when we are experiencing network issues (see below).\n            let mut url = match options.base_url {\n                Some(base_url) => base_url.to_string(),\n                None => self.base_url().await?,\n            };\n            url.push_str(endpoint);\n\n            // Add metrics. There is also an optional `partner` key with a value like\n            // `vodafone-uk` but we've yet to discover how we can find that value.\n            // For the sake of documentation you could also do \"product=free\" but\n            // we only support premium anyway.\n            if options.metrics && !url.contains(\"product=0\") {\n                let _ = write!(\n                    url,\n                    \"{}product=0&country={}\",\n                    util::get_next_query_separator(&url),\n                    self.session().country()\n                );\n            }\n\n            // Defeat caches. Spotify-generated URLs already contain this.\n            if options.salt && !url.contains(\"salt=\") {\n                let _ = write!(\n                    url,\n                    \"{}salt={}\",\n                    util::get_next_query_separator(&url),\n                    rand::rng().next_u32()\n                );\n            }\n\n            let mut request = Request::builder()\n                .method(method)\n                .uri(url)\n                .header(CONTENT_LENGTH, body.len())\n                .body(Bytes::copy_from_slice(body))?;\n\n            // Reconnection logic: keep getting (cached) tokens because they might have expired.\n            let token = self.session().login5().auth_token().await?;\n\n            let headers_mut = request.headers_mut();\n            if let Some(ref headers) = headers {\n                for (name, value) in headers {\n                    headers_mut.insert(name, value.clone());\n                }\n            }\n\n            headers_mut.insert(\n                AUTHORIZATION,\n                HeaderValue::from_str(&format!(\"{} {}\", token.token_type, token.access_token,))?,\n            );\n\n            match self.client_token().await {\n                Ok(client_token) => {\n                    let _ = headers_mut.insert(CLIENT_TOKEN, HeaderValue::from_str(&client_token)?);\n                }\n                Err(e) => {\n                    // currently these endpoints seem to work fine without it\n                    warn!(\"Unable to get client token: {e} Trying to continue without...\")\n                }\n            }\n\n            last_response = self.session().http_client().request_body(request).await;\n\n            if last_response.is_ok() {\n                return last_response;\n            }\n\n            // Break before the reconnection logic below, so that the current access point\n            // is retained when max_tries == 1. Leave it up to the caller when to flush.\n            if let RequestStrategy::TryTimes(max_tries) = self.lock(|inner| inner.strategy) {\n                if tries >= max_tries {\n                    break;\n                }\n            }\n\n            // Reconnection logic: drop the current access point if we are experiencing issues.\n            // This will cause the next call to base_url() to resolve a new one.\n            if let Err(ref network_error) = last_response {\n                match network_error.kind {\n                    ErrorKind::Unavailable | ErrorKind::DeadlineExceeded => {\n                        // Keep trying the current access point three times before dropping it.\n                        if tries % 3 == 0 {\n                            self.flush_accesspoint().await\n                        }\n                    }\n                    _ => break, // if we can't build the request now, then we won't ever\n                }\n            }\n\n            debug!(\"Error was: {last_response:?}\");\n        }\n\n        last_response\n    }\n\n    pub async fn put_connect_state_request(&self, state: &PutStateRequest) -> SpClientResult {\n        let endpoint = format!(\"/connect-state/v1/devices/{}\", self.session().device_id());\n\n        let mut headers = HeaderMap::new();\n        headers.insert(CONNECTION_ID, self.session().connection_id().parse()?);\n\n        self.request_with_protobuf(&Method::PUT, &endpoint, Some(headers), state)\n            .await\n    }\n\n    pub async fn delete_connect_state_request(&self) -> SpClientResult {\n        let endpoint = format!(\"/connect-state/v1/devices/{}\", self.session().device_id());\n        self.request(&Method::DELETE, &endpoint, None, None).await\n    }\n\n    pub async fn put_connect_state_inactive(&self, notify: bool) -> SpClientResult {\n        let endpoint = format!(\n            \"/connect-state/v1/devices/{}/inactive?notify={notify}\",\n            self.session().device_id()\n        );\n\n        let mut headers = HeaderMap::new();\n        headers.insert(CONNECTION_ID, self.session().connection_id().parse()?);\n\n        self.request(&Method::PUT, &endpoint, Some(headers), None)\n            .await\n    }\n\n    pub async fn get_extended_metadata(\n        &self,\n        request: BatchedEntityRequest,\n    ) -> Result<BatchedExtensionResponse, Error> {\n        let res = self\n            .request_with_protobuf(\n                &Method::POST,\n                \"/extended-metadata/v0/extended-metadata\",\n                None,\n                &request,\n            )\n            .await?;\n        Ok(BatchedExtensionResponse::parse_from_bytes(&res)?)\n    }\n\n    pub async fn get_metadata(&self, kind: ExtensionKind, id: &SpotifyUri) -> SpClientResult {\n        let req = BatchedEntityRequest {\n            entity_request: vec![EntityRequest {\n                entity_uri: id.to_uri(),\n                query: vec![ExtensionQuery {\n                    extension_kind: EnumOrUnknown::new(kind),\n                    ..Default::default()\n                }],\n                ..Default::default()\n            }],\n            ..Default::default()\n        };\n\n        let mut res = self.get_extended_metadata(req).await?;\n        let mut extended_metadata = res\n            .extended_metadata\n            .pop()\n            .ok_or(SpClientError::ExpectedEntry(\"extended_metadata\"))?;\n\n        let mut data = extended_metadata\n            .extension_data\n            .pop()\n            .ok_or(SpClientError::ExpectedEntry(\"extension_data\"))?;\n\n        match data.extension_data.take() {\n            None => Err(SpClientError::ExpectedEntry(\"data\").into()),\n            Some(data) => Ok(Bytes::from(data.value)),\n        }\n    }\n\n    pub async fn get_track_metadata(&self, track_uri: &SpotifyUri) -> SpClientResult {\n        self.get_metadata(ExtensionKind::TRACK_V4, track_uri).await\n    }\n\n    pub async fn get_episode_metadata(&self, episode_uri: &SpotifyUri) -> SpClientResult {\n        self.get_metadata(ExtensionKind::EPISODE_V4, episode_uri)\n            .await\n    }\n\n    pub async fn get_album_metadata(&self, album_uri: &SpotifyUri) -> SpClientResult {\n        self.get_metadata(ExtensionKind::ALBUM_V4, album_uri).await\n    }\n\n    pub async fn get_artist_metadata(&self, artist_uri: &SpotifyUri) -> SpClientResult {\n        self.get_metadata(ExtensionKind::ARTIST_V4, artist_uri)\n            .await\n    }\n\n    pub async fn get_show_metadata(&self, show_uri: &SpotifyUri) -> SpClientResult {\n        self.get_metadata(ExtensionKind::SHOW_V4, show_uri).await\n    }\n\n    pub async fn get_lyrics(&self, track_id: &SpotifyId) -> SpClientResult {\n        let endpoint = format!(\"/color-lyrics/v2/track/{}\", track_id.to_base62());\n\n        self.request_as_json(&Method::GET, &endpoint, None, None)\n            .await\n    }\n\n    pub async fn get_lyrics_for_image(\n        &self,\n        track_id: &SpotifyId,\n        image_id: &FileId,\n    ) -> SpClientResult {\n        let endpoint = format!(\n            \"/color-lyrics/v2/track/{}/image/spotify:image:{}\",\n            track_id.to_base62(),\n            image_id\n        );\n\n        self.request_as_json(&Method::GET, &endpoint, None, None)\n            .await\n    }\n\n    pub async fn get_playlist(&self, playlist_id: &SpotifyId) -> SpClientResult {\n        let endpoint = format!(\"/playlist/v2/playlist/{}\", playlist_id.to_base62());\n\n        self.request(&Method::GET, &endpoint, None, None).await\n    }\n\n    pub async fn get_user_profile(\n        &self,\n        username: &str,\n        playlist_limit: Option<u32>,\n        artist_limit: Option<u32>,\n    ) -> SpClientResult {\n        let mut endpoint = format!(\"/user-profile-view/v3/profile/{username}\");\n\n        if playlist_limit.is_some() || artist_limit.is_some() {\n            let _ = write!(endpoint, \"?\");\n\n            if let Some(limit) = playlist_limit {\n                let _ = write!(endpoint, \"playlist_limit={limit}\");\n                if artist_limit.is_some() {\n                    let _ = write!(endpoint, \"&\");\n                }\n            }\n\n            if let Some(limit) = artist_limit {\n                let _ = write!(endpoint, \"artist_limit={limit}\");\n            }\n        }\n\n        self.request_as_json(&Method::GET, &endpoint, None, None)\n            .await\n    }\n\n    pub async fn get_user_followers(&self, username: &str) -> SpClientResult {\n        let endpoint = format!(\"/user-profile-view/v3/profile/{username}/followers\");\n\n        self.request_as_json(&Method::GET, &endpoint, None, None)\n            .await\n    }\n\n    pub async fn get_user_following(&self, username: &str) -> SpClientResult {\n        let endpoint = format!(\"/user-profile-view/v3/profile/{username}/following\");\n\n        self.request_as_json(&Method::GET, &endpoint, None, None)\n            .await\n    }\n\n    pub async fn get_radio_for_track(&self, track_uri: &SpotifyUri) -> SpClientResult {\n        let endpoint = format!(\n            \"/inspiredby-mix/v2/seed_to_playlist/{}?response-format=json\",\n            track_uri.to_uri()\n        );\n\n        self.request_as_json(&Method::GET, &endpoint, None, None)\n            .await\n    }\n\n    // Known working scopes: stations, tracks\n    // For others see: https://gist.github.com/roderickvd/62df5b74d2179a12de6817a37bb474f9\n    //\n    // Seen-in-the-wild but unimplemented query parameters:\n    // - image_style=gradient_overlay\n    // - excludeClusters=true\n    // - language=en\n    // - count_tracks=0\n    // - market=from_token\n    pub async fn get_apollo_station(\n        &self,\n        scope: &str,\n        context_uri: &str,\n        count: Option<usize>,\n        previous_tracks: Vec<SpotifyId>,\n        autoplay: bool,\n    ) -> SpClientResult {\n        let mut endpoint = format!(\"/radio-apollo/v3/{scope}/{context_uri}?autoplay={autoplay}\");\n\n        // Spotify has a default of 50\n        if let Some(count) = count {\n            let _ = write!(endpoint, \"&count={count}\");\n        }\n\n        let previous_track_str = previous_tracks\n            .iter()\n            .map(SpotifyId::to_base62)\n            .collect::<Vec<_>>()\n            .join(\",\");\n        // better than checking `previous_tracks.len() > 0` because the `filter_map` could still return 0 items\n        if !previous_track_str.is_empty() {\n            let _ = write!(endpoint, \"&prev_tracks={previous_track_str}\");\n        }\n\n        self.request_as_json(&Method::GET, &endpoint, None, None)\n            .await\n    }\n\n    pub async fn get_next_page(&self, next_page_uri: &str) -> SpClientResult {\n        let endpoint = next_page_uri.trim_start_matches(\"hm:/\");\n        self.request_as_json(&Method::GET, endpoint, None, None)\n            .await\n    }\n\n    // TODO: Seen-in-the-wild but unimplemented endpoints\n    // - /presence-view/v1/buddylist\n\n    pub async fn get_audio_storage(&self, file_id: &FileId) -> SpClientResult {\n        let endpoint = format!(\n            \"/storage-resolve/files/audio/interactive/{}\",\n            file_id.to_base16()\n        );\n        self.request(&Method::GET, &endpoint, None, None).await\n    }\n\n    pub fn stream_from_cdn<U>(\n        &self,\n        cdn_url: U,\n        offset: usize,\n        length: usize,\n    ) -> Result<IntoStream<ResponseFuture>, Error>\n    where\n        U: TryInto<Uri>,\n        <U as TryInto<Uri>>::Error: Into<http::Error>,\n    {\n        let req = Request::builder()\n            .method(&Method::GET)\n            .uri(cdn_url)\n            .header(\n                RANGE,\n                HeaderValue::from_str(&format!(\"bytes={}-{}\", offset, offset + length - 1))?,\n            )\n            .body(Bytes::new())?;\n\n        let stream = self.session().http_client().request_stream(req)?;\n\n        Ok(stream)\n    }\n\n    pub async fn request_url(&self, url: &str) -> SpClientResult {\n        let request = Request::builder()\n            .method(&Method::GET)\n            .uri(url)\n            .body(Bytes::new())?;\n\n        self.session().http_client().request_body(request).await\n    }\n\n    // Audio preview in 96 kbps MP3, unencrypted\n    pub async fn get_audio_preview(&self, preview_id: &FileId) -> SpClientResult {\n        const ATTRIBUTE: &str = \"audio-preview-url-template\";\n        let template = self\n            .session()\n            .get_user_attribute(ATTRIBUTE)\n            .ok_or_else(|| SpClientError::Attribute(ATTRIBUTE.to_string()))?;\n\n        let mut url = template.replace(\"{id}\", &preview_id.to_base16());\n        let separator = match url.find('?') {\n            Some(_) => \"&\",\n            None => \"?\",\n        };\n        let _ = write!(url, \"{}cid={}\", separator, self.session().client_id());\n\n        self.request_url(&url).await\n    }\n\n    // The first 128 kB of a track, unencrypted\n    pub async fn get_head_file(&self, file_id: &FileId) -> SpClientResult {\n        const ATTRIBUTE: &str = \"head-files-url\";\n        let template = self\n            .session()\n            .get_user_attribute(ATTRIBUTE)\n            .ok_or_else(|| SpClientError::Attribute(ATTRIBUTE.to_string()))?;\n\n        let url = template.replace(\"{file_id}\", &file_id.to_base16());\n\n        self.request_url(&url).await\n    }\n\n    pub async fn get_image(&self, image_id: &FileId) -> SpClientResult {\n        const ATTRIBUTE: &str = \"image-url\";\n        let template = self\n            .session()\n            .get_user_attribute(ATTRIBUTE)\n            .ok_or_else(|| SpClientError::Attribute(ATTRIBUTE.to_string()))?;\n        let url = template.replace(\"{file_id}\", &image_id.to_base16());\n\n        self.request_url(&url).await\n    }\n\n    /// Request the context for an uri\n    ///\n    /// All [SpotifyId] uris are supported in addition to the following special uris:\n    /// - liked songs:\n    ///   - all: `spotify:user:<user_id>:collection`\n    ///   - of artist: `spotify:user:<user_id>:collection:artist:<artist_id>`\n    /// - search: `spotify:search:<search+query>` (whitespaces are replaced with `+`)\n    ///\n    /// ## Query params found in the wild:\n    /// - include_video=true\n    ///\n    /// ## Known results of uri types:\n    /// - uris of type `track`\n    ///   - returns a single page with a single track\n    ///   - when requesting a single track with a query in the request, the returned track uri\n    ///     **will** contain the query\n    /// - uris of type `artist`\n    ///   - returns 2 pages with tracks: 10 most popular tracks and latest/popular album\n    ///   - remaining pages are artist albums sorted by popularity (only provided as page_url)\n    /// - uris of type `search`\n    ///   - is massively influenced by the provided query\n    ///   - the query result shown by the search expects no query at all\n    ///   - uri looks like `spotify:search:never+gonna`\n    pub async fn get_context(&self, uri: &str) -> Result<Context, Error> {\n        let uri = format!(\"/context-resolve/v1/{uri}\");\n\n        let res = self\n            .request_with_options(&Method::GET, &uri, None, None, &NO_METRICS_AND_SALT)\n            .await?;\n        let ctx_json = String::from_utf8(res.to_vec())?;\n        if ctx_json.is_empty() {\n            Err(SpClientError::NoData)?\n        }\n\n        let ctx = protobuf_json_mapping::parse_from_str::<Context>(&ctx_json);\n\n        if ctx.is_err() {\n            trace!(\"failed parsing context: {ctx_json}\")\n        }\n\n        Ok(ctx?)\n    }\n\n    pub async fn get_autoplay_context(\n        &self,\n        context_request: &AutoplayContextRequest,\n    ) -> Result<Context, Error> {\n        let res = self\n            .request_with_protobuf_and_options(\n                &Method::POST,\n                \"/context-resolve/v1/autoplay\",\n                None,\n                context_request,\n                &NO_METRICS_AND_SALT,\n            )\n            .await?;\n\n        let ctx_json = String::from_utf8(res.to_vec())?;\n        if ctx_json.is_empty() {\n            Err(SpClientError::NoData)?\n        }\n\n        let ctx = protobuf_json_mapping::parse_from_str::<Context>(&ctx_json);\n\n        if ctx.is_err() {\n            trace!(\"failed parsing context: {ctx_json}\")\n        }\n\n        Ok(ctx?)\n    }\n\n    pub async fn get_rootlist(&self, from: usize, length: Option<usize>) -> SpClientResult {\n        let length = length.unwrap_or(120);\n        let user = self.session().username();\n        let endpoint = format!(\n            \"/playlist/v2/user/{user}/rootlist?decorate=revision,attributes,length,owner,capabilities,status_code&from={from}&length={length}\"\n        );\n\n        self.request(&Method::GET, &endpoint, None, None).await\n    }\n\n    /// Triggers the transfers of the playback from one device to another\n    ///\n    /// Using the same `device_id` for `from_device_id` and `to_device_id`, initiates the transfer\n    /// from the currently active device.\n    pub async fn transfer(\n        &self,\n        from_device_id: &str,\n        to_device_id: &str,\n        transfer_request: Option<&TransferRequest>,\n    ) -> SpClientResult {\n        let body = transfer_request.map(serde_json::to_string).transpose()?;\n\n        let endpoint =\n            format!(\"/connect-state/v1/connect/transfer/from/{from_device_id}/to/{to_device_id}\");\n        self.request_with_options(\n            &Method::POST,\n            &endpoint,\n            None,\n            body.as_deref().map(str::as_bytes),\n            &NO_METRICS_AND_SALT,\n        )\n        .await\n    }\n}\n"
  },
  {
    "path": "core/src/spotify_id.rs",
    "content": "use std::fmt;\n\nuse thiserror::Error;\n\nuse crate::{Error, SpotifyUri};\n\n// re-export FileId for historic reasons, when it was part of this mod\npub use crate::FileId;\n\n#[derive(Clone, Copy, PartialEq, Eq, Hash)]\npub struct SpotifyId {\n    pub id: u128,\n}\n\n#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)]\npub enum SpotifyIdError {\n    #[error(\"ID cannot be parsed\")]\n    InvalidId,\n    #[error(\"not a valid Spotify ID\")]\n    InvalidFormat,\n}\n\nimpl From<SpotifyIdError> for Error {\n    fn from(err: SpotifyIdError) -> Self {\n        Error::invalid_argument(err)\n    }\n}\n\npub type SpotifyIdResult = Result<SpotifyId, Error>;\n\nconst BASE62_DIGITS: &[u8; 62] = b\"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ\";\n\nimpl SpotifyId {\n    const SIZE: usize = 16;\n    const SIZE_BASE62: usize = 22;\n\n    /// Parses a base16 (hex) encoded [Spotify ID] into a `SpotifyId`.\n    ///\n    /// `src` is expected to be 32 bytes long and encoded using valid characters.\n    ///\n    /// [Spotify ID]: https://developer.spotify.com/documentation/web-api/concepts/spotify-uris-ids\n    pub fn from_base16(src: &str) -> SpotifyIdResult {\n        if src.len() != 32 {\n            return Err(SpotifyIdError::InvalidId.into());\n        }\n        let id = u128::from_str_radix(src, 16).map_err(|_| SpotifyIdError::InvalidId)?;\n\n        Ok(Self { id })\n    }\n\n    /// Parses a base62 encoded [Spotify ID] into a `u128`.\n    ///\n    /// `src` is expected to be 22 bytes long and encoded using valid characters.\n    ///\n    /// [Spotify ID]: https://developer.spotify.com/documentation/web-api/concepts/spotify-uris-ids\n    pub fn from_base62(src: &str) -> SpotifyIdResult {\n        if src.len() != Self::SIZE_BASE62 {\n            return Err(SpotifyIdError::InvalidId.into());\n        }\n        let mut dst: u128 = 0;\n\n        for c in src.as_bytes() {\n            let p = match c {\n                b'0'..=b'9' => c - b'0',\n                b'a'..=b'z' => c - b'a' + 10,\n                b'A'..=b'Z' => c - b'A' + 36,\n                _ => return Err(SpotifyIdError::InvalidId.into()),\n            } as u128;\n\n            dst = dst.checked_mul(62).ok_or(SpotifyIdError::InvalidId)?;\n            dst = dst.checked_add(p).ok_or(SpotifyIdError::InvalidId)?;\n        }\n\n        Ok(Self { id: dst })\n    }\n\n    /// Creates a `u128` from a copy of `SpotifyId::SIZE` (16) bytes in big-endian order.\n    ///\n    /// The resulting `SpotifyId` will default to a `SpotifyItemType::Unknown`.\n    pub fn from_raw(src: &[u8]) -> SpotifyIdResult {\n        match src.try_into() {\n            Ok(dst) => Ok(Self {\n                id: u128::from_be_bytes(dst),\n            }),\n            Err(_) => Err(SpotifyIdError::InvalidId.into()),\n        }\n    }\n\n    /// Returns the `SpotifyId` as a base16 (hex) encoded, 32-character long `String`.\n    #[allow(clippy::wrong_self_convention)]\n    pub fn to_base16(&self) -> String {\n        format!(\"{:032x}\", self.id)\n    }\n\n    /// Returns the `SpotifyId` as a [canonically] base62 encoded, `SpotifyId::SIZE_BASE62` (22)\n    /// character long `String`.\n    ///\n    /// [canonically]: https://developer.spotify.com/documentation/web-api/concepts/spotify-uris-ids\n    #[allow(clippy::wrong_self_convention)]\n    pub fn to_base62(&self) -> String {\n        let mut dst = [0u8; 22];\n        let mut i = 0;\n        let n = self.id;\n\n        // The algorithm is based on:\n        // https://github.com/trezor/trezor-crypto/blob/c316e775a2152db255ace96b6b65ac0f20525ec0/base58.c\n        //\n        // We are not using naive division of self.id as it is an u128 and div + mod are software\n        // emulated at runtime (and unoptimized into mul + shift) on non-128bit platforms,\n        // making them very expensive.\n        //\n        // Trezor's algorithm allows us to stick to arithmetic on native registers making this\n        // an order of magnitude faster. Additionally, as our sizes are known, instead of\n        // dealing with the ID on a byte by byte basis, we decompose it into four u32s and\n        // use 64-bit arithmetic on them for an additional speedup.\n        for shift in &[96, 64, 32, 0] {\n            let mut carry = (n >> shift) as u32 as u64;\n\n            for b in &mut dst[..i] {\n                carry += (*b as u64) << 32;\n                *b = (carry % 62) as u8;\n                carry /= 62;\n            }\n\n            while carry > 0 {\n                dst[i] = (carry % 62) as u8;\n                carry /= 62;\n                i += 1;\n            }\n        }\n\n        let mut s = String::with_capacity(dst.len());\n        for &b in dst.iter().rev() {\n            s.push(BASE62_DIGITS[b as usize] as char);\n        }\n\n        s\n    }\n\n    /// Returns a copy of the `SpotifyId` as an array of `SpotifyId::SIZE` (16) bytes in\n    /// big-endian order.\n    #[allow(clippy::wrong_self_convention)]\n    pub fn to_raw(&self) -> [u8; Self::SIZE] {\n        self.id.to_be_bytes()\n    }\n}\n\nimpl fmt::Debug for SpotifyId {\n    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n        f.debug_tuple(\"SpotifyId\").field(&self.to_base62()).finish()\n    }\n}\n\nimpl fmt::Display for SpotifyId {\n    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n        f.write_str(&self.to_base62())\n    }\n}\n\nimpl TryFrom<&[u8]> for SpotifyId {\n    type Error = crate::Error;\n    fn try_from(src: &[u8]) -> Result<Self, Self::Error> {\n        Self::from_raw(src)\n    }\n}\n\nimpl TryFrom<&str> for SpotifyId {\n    type Error = crate::Error;\n    fn try_from(src: &str) -> Result<Self, Self::Error> {\n        Self::from_base62(src)\n    }\n}\n\nimpl TryFrom<String> for SpotifyId {\n    type Error = crate::Error;\n    fn try_from(src: String) -> Result<Self, Self::Error> {\n        Self::try_from(src.as_str())\n    }\n}\n\nimpl TryFrom<&Vec<u8>> for SpotifyId {\n    type Error = crate::Error;\n    fn try_from(src: &Vec<u8>) -> Result<Self, Self::Error> {\n        Self::try_from(src.as_slice())\n    }\n}\n\nimpl TryFrom<&SpotifyUri> for SpotifyId {\n    type Error = crate::Error;\n    fn try_from(value: &SpotifyUri) -> Result<Self, Self::Error> {\n        match value {\n            SpotifyUri::Album { id }\n            | SpotifyUri::Artist { id }\n            | SpotifyUri::Episode { id }\n            | SpotifyUri::Playlist { id, .. }\n            | SpotifyUri::Show { id }\n            | SpotifyUri::Track { id } => Ok(*id),\n            SpotifyUri::Local { .. } | SpotifyUri::Unknown { .. } => {\n                Err(SpotifyIdError::InvalidFormat.into())\n            }\n        }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    struct ConversionCase {\n        id: u128,\n        base16: &'static str,\n        base62: &'static str,\n        raw: &'static [u8],\n    }\n\n    static CONV_VALID: [ConversionCase; 5] = [\n        ConversionCase {\n            id: 238762092608182713602505436543891614649,\n            base16: \"b39fe8081e1f4c54be38e8d6f9f12bb9\",\n            base62: \"5sWHDYs0csV6RS48xBl0tH\",\n            raw: &[\n                179, 159, 232, 8, 30, 31, 76, 84, 190, 56, 232, 214, 249, 241, 43, 185,\n            ],\n        },\n        ConversionCase {\n            id: 204841891221366092811751085145916697048,\n            base16: \"9a1b1cfbc6f244569ae0356c77bbe9d8\",\n            base62: \"4GNcXTGWmnZ3ySrqvol3o4\",\n            raw: &[\n                154, 27, 28, 251, 198, 242, 68, 86, 154, 224, 53, 108, 119, 187, 233, 216,\n            ],\n        },\n        ConversionCase {\n            id: 204841891221366092811751085145916697048,\n            base16: \"9a1b1cfbc6f244569ae0356c77bbe9d8\",\n            base62: \"4GNcXTGWmnZ3ySrqvol3o4\",\n            raw: &[\n                154, 27, 28, 251, 198, 242, 68, 86, 154, 224, 53, 108, 119, 187, 233, 216,\n            ],\n        },\n        ConversionCase {\n            id: 204841891221366092811751085145916697048,\n            base16: \"9a1b1cfbc6f244569ae0356c77bbe9d8\",\n            base62: \"4GNcXTGWmnZ3ySrqvol3o4\",\n            raw: &[\n                154, 27, 28, 251, 198, 242, 68, 86, 154, 224, 53, 108, 119, 187, 233, 216,\n            ],\n        },\n        ConversionCase {\n            id: 0,\n            base16: \"00000000000000000000000000000000\",\n            base62: \"0000000000000000000000\",\n            raw: &[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],\n        },\n    ];\n\n    static CONV_INVALID: [ConversionCase; 5] = [\n        ConversionCase {\n            id: 0,\n            base16: \"ZZZZZ8081e1f4c54be38e8d6f9f12bb9\",\n            base62: \"!!!!!Ys0csV6RS48xBl0tH\",\n            raw: &[\n                // Invalid length.\n                154, 27, 28, 251, 198, 242, 68, 86, 154, 224, 5, 3, 108, 119, 187, 233, 216, 255,\n            ],\n        },\n        ConversionCase {\n            id: 0,\n            base16: \"--------------------\",\n            base62: \"....................\",\n            raw: &[\n                // Invalid length.\n                154, 27, 28, 251,\n            ],\n        },\n        ConversionCase {\n            id: 0,\n            // too long, should return error but not panic overflow\n            base16: \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\n            // too long, should return error but not panic overflow\n            base62: \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\n            raw: &[\n                // Invalid length.\n                154, 27, 28, 251,\n            ],\n        },\n        ConversionCase {\n            id: 0,\n            base16: \"--------------------\",\n            // too short to encode a 128 bits int\n            base62: \"aa\",\n            raw: &[\n                // Invalid length.\n                154, 27, 28, 251,\n            ],\n        },\n        ConversionCase {\n            id: 0,\n            base16: \"--------------------\",\n            // too high of a value, this would need a 132 bits int\n            base62: \"ZZZZZZZZZZZZZZZZZZZZZZ\",\n            raw: &[\n                // Invalid length.\n                154, 27, 28, 251,\n            ],\n        },\n    ];\n\n    #[test]\n    fn from_base62() {\n        for c in &CONV_VALID {\n            assert_eq!(SpotifyId::from_base62(c.base62).unwrap().id, c.id);\n        }\n\n        for c in &CONV_INVALID {\n            assert!(SpotifyId::from_base62(c.base62).is_err(),);\n        }\n    }\n\n    #[test]\n    fn to_base62() {\n        for c in &CONV_VALID {\n            let id = SpotifyId { id: c.id };\n\n            assert_eq!(id.to_base62(), c.base62);\n        }\n    }\n\n    #[test]\n    fn from_base16() {\n        for c in &CONV_VALID {\n            assert_eq!(SpotifyId::from_base16(c.base16).unwrap().id, c.id);\n        }\n\n        for c in &CONV_INVALID {\n            assert!(SpotifyId::from_base16(c.base16).is_err(),);\n        }\n    }\n\n    #[test]\n    fn to_base16() {\n        for c in &CONV_VALID {\n            let id = SpotifyId { id: c.id };\n\n            assert_eq!(id.to_base16(), c.base16);\n        }\n    }\n\n    #[test]\n    fn from_raw() {\n        for c in &CONV_VALID {\n            assert_eq!(SpotifyId::from_raw(c.raw).unwrap().id, c.id);\n        }\n\n        for c in &CONV_INVALID {\n            assert!(SpotifyId::from_raw(c.raw).is_err());\n        }\n    }\n}\n"
  },
  {
    "path": "core/src/spotify_uri.rs",
    "content": "use crate::{Error, SpotifyId};\nuse std::{borrow::Cow, fmt, str::FromStr, time::Duration};\nuse thiserror::Error;\n\nuse librespot_protocol as protocol;\n\nconst SPOTIFY_ITEM_TYPE_ALBUM: &str = \"album\";\nconst SPOTIFY_ITEM_TYPE_ARTIST: &str = \"artist\";\nconst SPOTIFY_ITEM_TYPE_EPISODE: &str = \"episode\";\nconst SPOTIFY_ITEM_TYPE_PLAYLIST: &str = \"playlist\";\nconst SPOTIFY_ITEM_TYPE_SHOW: &str = \"show\";\nconst SPOTIFY_ITEM_TYPE_TRACK: &str = \"track\";\nconst SPOTIFY_ITEM_TYPE_LOCAL: &str = \"local\";\nconst SPOTIFY_ITEM_TYPE_UNKNOWN: &str = \"unknown\";\n\n#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)]\npub enum SpotifyUriError {\n    #[error(\"not a valid Spotify URI\")]\n    InvalidFormat,\n    #[error(\"URI does not belong to Spotify\")]\n    InvalidRoot,\n}\n\nimpl From<SpotifyUriError> for Error {\n    fn from(err: SpotifyUriError) -> Self {\n        Error::invalid_argument(err)\n    }\n}\n\npub type SpotifyUriResult = Result<SpotifyUri, Error>;\n\n#[derive(Clone, PartialEq, Eq, Hash)]\npub enum SpotifyUri {\n    Album {\n        id: SpotifyId,\n    },\n    Artist {\n        id: SpotifyId,\n    },\n    Episode {\n        id: SpotifyId,\n    },\n    Playlist {\n        user: Option<String>,\n        id: SpotifyId,\n    },\n    Show {\n        id: SpotifyId,\n    },\n    Track {\n        id: SpotifyId,\n    },\n    Local {\n        artist: String,\n        album_title: String,\n        track_title: String,\n        duration: std::time::Duration,\n    },\n    Unknown {\n        kind: Cow<'static, str>,\n        id: String,\n    },\n}\n\nimpl SpotifyUri {\n    /// Returns whether this `SpotifyUri` is for a playable audio item, if known.\n    pub fn is_playable(&self) -> bool {\n        matches!(\n            self,\n            SpotifyUri::Episode { .. } | SpotifyUri::Track { .. } | SpotifyUri::Local { .. }\n        )\n    }\n\n    /// Gets the item type of this URI as a static string\n    pub fn item_type(&self) -> &'static str {\n        match &self {\n            SpotifyUri::Album { .. } => SPOTIFY_ITEM_TYPE_ALBUM,\n            SpotifyUri::Artist { .. } => SPOTIFY_ITEM_TYPE_ARTIST,\n            SpotifyUri::Episode { .. } => SPOTIFY_ITEM_TYPE_EPISODE,\n            SpotifyUri::Playlist { .. } => SPOTIFY_ITEM_TYPE_PLAYLIST,\n            SpotifyUri::Show { .. } => SPOTIFY_ITEM_TYPE_SHOW,\n            SpotifyUri::Track { .. } => SPOTIFY_ITEM_TYPE_TRACK,\n            SpotifyUri::Local { .. } => SPOTIFY_ITEM_TYPE_LOCAL,\n            SpotifyUri::Unknown { .. } => SPOTIFY_ITEM_TYPE_UNKNOWN,\n        }\n    }\n\n    /// Gets the ID of this URI. The resource ID is the component of the URI that identifies\n    /// the resource after its type label. If `self` is a named ID, the user will be omitted.\n    pub fn to_id(&self) -> String {\n        match &self {\n            SpotifyUri::Album { id }\n            | SpotifyUri::Artist { id }\n            | SpotifyUri::Episode { id }\n            | SpotifyUri::Playlist { id, .. }\n            | SpotifyUri::Show { id }\n            | SpotifyUri::Track { id } => id.to_base62(),\n            SpotifyUri::Local {\n                artist,\n                album_title,\n                track_title,\n                duration,\n            } => {\n                let duration_secs = duration.as_secs();\n                format!(\"{artist}:{album_title}:{track_title}:{duration_secs}\")\n            }\n            SpotifyUri::Unknown { id, .. } => id.clone(),\n        }\n    }\n\n    /// Parses a [Spotify URI] into a `SpotifyUri`.\n    ///\n    /// `uri` is expected to be in the canonical form `spotify:{type}:{id}`, where `{type}`\n    /// can be arbitrary while `{id}` is in a format that varies based on the `{type}`:\n    ///\n    ///  - For most item types, a 22-character long, base62 encoded Spotify ID is expected.\n    ///  - For local files, an arbitrary length string with the fields\n    ///    `{artist}:{album_title}:{track_title}:{duration_in_seconds}` is expected.\n    ///\n    /// Spotify URI: https://developer.spotify.com/documentation/web-api/concepts/spotify-uris-ids\n    pub fn from_uri(src: &str) -> SpotifyUriResult {\n        // Basic: `spotify:{type}:{id}`\n        // Named: `spotify:user:{user}:{type}:{id}`\n        // Local: `spotify:local:{artist}:{album_title}:{track_title}:{duration_in_seconds}`\n        let mut parts = src.split(':');\n\n        let scheme = parts.next().ok_or(SpotifyUriError::InvalidFormat)?;\n\n        if scheme != \"spotify\" {\n            return Err(SpotifyUriError::InvalidRoot.into());\n        }\n\n        let mut username: Option<String> = None;\n\n        let item_type = {\n            let next = parts.next().ok_or(SpotifyUriError::InvalidFormat)?;\n            if next == \"user\" {\n                username.replace(\n                    parts\n                        .next()\n                        .ok_or(SpotifyUriError::InvalidFormat)?\n                        .to_owned(),\n                );\n                parts.next().ok_or(SpotifyUriError::InvalidFormat)?\n            } else {\n                next\n            }\n        };\n\n        let name = parts.next().ok_or(SpotifyUriError::InvalidFormat)?;\n\n        match item_type {\n            SPOTIFY_ITEM_TYPE_ALBUM => Ok(Self::Album {\n                id: SpotifyId::from_base62(name)?,\n            }),\n            SPOTIFY_ITEM_TYPE_ARTIST => Ok(Self::Artist {\n                id: SpotifyId::from_base62(name)?,\n            }),\n            SPOTIFY_ITEM_TYPE_EPISODE => Ok(Self::Episode {\n                id: SpotifyId::from_base62(name)?,\n            }),\n            SPOTIFY_ITEM_TYPE_PLAYLIST => Ok(Self::Playlist {\n                id: SpotifyId::from_base62(name)?,\n                user: username,\n            }),\n            SPOTIFY_ITEM_TYPE_SHOW => Ok(Self::Show {\n                id: SpotifyId::from_base62(name)?,\n            }),\n            SPOTIFY_ITEM_TYPE_TRACK => Ok(Self::Track {\n                id: SpotifyId::from_base62(name)?,\n            }),\n            SPOTIFY_ITEM_TYPE_LOCAL => {\n                let artist = name;\n                let album_title = parts.next().ok_or(SpotifyUriError::InvalidFormat)?;\n                let track_title = parts.next().ok_or(SpotifyUriError::InvalidFormat)?;\n                let duration_secs = parts\n                    .next()\n                    .and_then(|f| u64::from_str(f).ok())\n                    .ok_or(SpotifyUriError::InvalidFormat)?;\n\n                Ok(Self::Local {\n                    artist: artist.to_owned(),\n                    album_title: album_title.to_owned(),\n                    track_title: track_title.to_owned(),\n                    duration: Duration::from_secs(duration_secs),\n                })\n            }\n            _ => Ok(Self::Unknown {\n                kind: item_type.to_owned().into(),\n                id: name.to_owned(),\n            }),\n        }\n    }\n\n    /// Returns the `SpotifyUri` as a [Spotify URI] in the canonical form `spotify:{type}:{id}`,\n    /// where `{type}` is an arbitrary string and `{id}` is a 22-character long, base62 encoded\n    /// Spotify ID.\n    ///\n    /// If the `SpotifyUri` has an associated type unrecognized by the library, `{type}` will\n    /// be encoded as `unknown`.\n    ///\n    /// If the `SpotifyUri` is named, it will be returned in the form\n    /// `spotify:user:{user}:{type}:{id}`.\n    ///\n    /// [Spotify URI]: https://developer.spotify.com/documentation/web-api/concepts/spotify-uris-ids\n    pub fn to_uri(&self) -> String {\n        let item_type = self.item_type();\n\n        if let SpotifyUri::Playlist {\n            id,\n            user: Some(user),\n        } = self\n        {\n            format!(\"spotify:user:{user}:{item_type}:{id}\")\n        } else {\n            let name = self.to_id();\n            format!(\"spotify:{item_type}:{name}\")\n        }\n    }\n\n    /// Gets the name of this URI. The resource name is the component of the URI that identifies\n    /// the resource after its type label. If `self` is a named ID, the user will be omitted.\n    ///\n    /// Deprecated: not all IDs can be represented in Base62, so this function has been renamed to\n    /// [SpotifyUri::to_id], which this implementation forwards to.\n    #[deprecated(since = \"0.8.0\", note = \"use to_name instead\")]\n    pub fn to_base62(&self) -> String {\n        self.to_id()\n    }\n}\n\nimpl fmt::Debug for SpotifyUri {\n    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n        f.debug_tuple(\"SpotifyUri\").field(&self.to_uri()).finish()\n    }\n}\n\nimpl fmt::Display for SpotifyUri {\n    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n        f.write_str(&self.to_uri())\n    }\n}\n\nimpl TryFrom<&protocol::metadata::Album> for SpotifyUri {\n    type Error = crate::Error;\n    fn try_from(album: &protocol::metadata::Album) -> Result<Self, Self::Error> {\n        Ok(Self::Album {\n            id: SpotifyId::from_raw(album.gid())?,\n        })\n    }\n}\n\nimpl TryFrom<&protocol::metadata::Artist> for SpotifyUri {\n    type Error = crate::Error;\n    fn try_from(artist: &protocol::metadata::Artist) -> Result<Self, Self::Error> {\n        Ok(Self::Artist {\n            id: SpotifyId::from_raw(artist.gid())?,\n        })\n    }\n}\n\nimpl TryFrom<&protocol::metadata::Episode> for SpotifyUri {\n    type Error = crate::Error;\n    fn try_from(episode: &protocol::metadata::Episode) -> Result<Self, Self::Error> {\n        Ok(Self::Episode {\n            id: SpotifyId::from_raw(episode.gid())?,\n        })\n    }\n}\n\nimpl TryFrom<&protocol::metadata::Track> for SpotifyUri {\n    type Error = crate::Error;\n    fn try_from(track: &protocol::metadata::Track) -> Result<Self, Self::Error> {\n        Ok(Self::Track {\n            id: SpotifyId::from_raw(track.gid())?,\n        })\n    }\n}\n\nimpl TryFrom<&protocol::metadata::Show> for SpotifyUri {\n    type Error = crate::Error;\n    fn try_from(show: &protocol::metadata::Show) -> Result<Self, Self::Error> {\n        Ok(Self::Show {\n            id: SpotifyId::from_raw(show.gid())?,\n        })\n    }\n}\n\nimpl TryFrom<&protocol::metadata::ArtistWithRole> for SpotifyUri {\n    type Error = crate::Error;\n    fn try_from(artist: &protocol::metadata::ArtistWithRole) -> Result<Self, Self::Error> {\n        Ok(Self::Artist {\n            id: SpotifyId::from_raw(artist.artist_gid())?,\n        })\n    }\n}\n\nimpl TryFrom<&protocol::playlist4_external::Item> for SpotifyUri {\n    type Error = crate::Error;\n    fn try_from(item: &protocol::playlist4_external::Item) -> Result<Self, Self::Error> {\n        Self::from_uri(item.uri())\n    }\n}\n\n// Note that this is the unique revision of an item's metadata on a playlist,\n// not the ID of that item or playlist.\nimpl TryFrom<&protocol::playlist4_external::MetaItem> for SpotifyUri {\n    type Error = crate::Error;\n    fn try_from(item: &protocol::playlist4_external::MetaItem) -> Result<Self, Self::Error> {\n        Ok(Self::Unknown {\n            kind: \"MetaItem\".into(),\n            id: SpotifyId::try_from(item.revision())?.to_base62(),\n        })\n    }\n}\n\n// Note that this is the unique revision of a playlist, not the ID of that playlist.\nimpl TryFrom<&protocol::playlist4_external::SelectedListContent> for SpotifyUri {\n    type Error = crate::Error;\n    fn try_from(\n        playlist: &protocol::playlist4_external::SelectedListContent,\n    ) -> Result<Self, Self::Error> {\n        Ok(Self::Unknown {\n            kind: \"SelectedListContent\".into(),\n            id: SpotifyId::try_from(playlist.revision())?.to_base62(),\n        })\n    }\n}\n\n// TODO: check meaning and format of this field in the wild. This might be a FileId,\n// which is why we now don't create a separate `Playlist` enum value yet and choose\n// to discard any item type.\nimpl TryFrom<&protocol::playlist_annotate3::TranscodedPicture> for SpotifyUri {\n    type Error = crate::Error;\n    fn try_from(\n        picture: &protocol::playlist_annotate3::TranscodedPicture,\n    ) -> Result<Self, Self::Error> {\n        Ok(Self::Unknown {\n            kind: \"TranscodedPicture\".into(),\n            id: picture.uri().to_owned(),\n        })\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    struct ConversionCase {\n        parsed: SpotifyUri,\n        uri: &'static str,\n        base62: &'static str,\n    }\n\n    static CONV_VALID: [ConversionCase; 4] = [\n        ConversionCase {\n            parsed: SpotifyUri::Track {\n                id: SpotifyId {\n                    id: 238762092608182713602505436543891614649,\n                },\n            },\n            uri: \"spotify:track:5sWHDYs0csV6RS48xBl0tH\",\n            base62: \"5sWHDYs0csV6RS48xBl0tH\",\n        },\n        ConversionCase {\n            parsed: SpotifyUri::Track {\n                id: SpotifyId {\n                    id: 204841891221366092811751085145916697048,\n                },\n            },\n            uri: \"spotify:track:4GNcXTGWmnZ3ySrqvol3o4\",\n            base62: \"4GNcXTGWmnZ3ySrqvol3o4\",\n        },\n        ConversionCase {\n            parsed: SpotifyUri::Episode {\n                id: SpotifyId {\n                    id: 204841891221366092811751085145916697048,\n                },\n            },\n            uri: \"spotify:episode:4GNcXTGWmnZ3ySrqvol3o4\",\n            base62: \"4GNcXTGWmnZ3ySrqvol3o4\",\n        },\n        ConversionCase {\n            parsed: SpotifyUri::Show {\n                id: SpotifyId {\n                    id: 204841891221366092811751085145916697048,\n                },\n            },\n            uri: \"spotify:show:4GNcXTGWmnZ3ySrqvol3o4\",\n            base62: \"4GNcXTGWmnZ3ySrqvol3o4\",\n        },\n    ];\n\n    static CONV_INVALID: [ConversionCase; 5] = [\n        ConversionCase {\n            parsed: SpotifyUri::Track {\n                id: SpotifyId { id: 0 },\n            },\n            // Invalid ID in the URI.\n            uri: \"spotify:track:5sWHDYs0Bl0tH\",\n            base62: \"!!!!!Ys0csV6RS48xBl0tH\",\n        },\n        ConversionCase {\n            parsed: SpotifyUri::Track {\n                id: SpotifyId { id: 0 },\n            },\n            // Missing colon between ID and type.\n            uri: \"spotify:arbitrarywhatever5sWHDYs0csV6RS48xBl0tH\",\n            base62: \"....................\",\n        },\n        ConversionCase {\n            parsed: SpotifyUri::Track {\n                id: SpotifyId { id: 0 },\n            },\n            // Uri too short\n            uri: \"spotify:track:aRS48xBl0tH\",\n            // too long, should return error but not panic overflow\n            base62: \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\n        },\n        ConversionCase {\n            parsed: SpotifyUri::Track {\n                id: SpotifyId { id: 0 },\n            },\n            // Uri too short\n            uri: \"spotify:track:aRS48xBl0tH\",\n            // too short to encode a 128 bits int\n            base62: \"aa\",\n        },\n        ConversionCase {\n            parsed: SpotifyUri::Track {\n                id: SpotifyId { id: 0 },\n            },\n            uri: \"cleary invalid uri\",\n            // too high of a value, this would need a 132 bits int\n            base62: \"ZZZZZZZZZZZZZZZZZZZZZZ\",\n        },\n    ];\n\n    struct ItemTypeCase {\n        uri: SpotifyUri,\n        expected_type: &'static str,\n    }\n\n    static ITEM_TYPES: [ItemTypeCase; 6] = [\n        ItemTypeCase {\n            uri: SpotifyUri::Album {\n                id: SpotifyId { id: 0 },\n            },\n            expected_type: \"album\",\n        },\n        ItemTypeCase {\n            uri: SpotifyUri::Artist {\n                id: SpotifyId { id: 0 },\n            },\n            expected_type: \"artist\",\n        },\n        ItemTypeCase {\n            uri: SpotifyUri::Episode {\n                id: SpotifyId { id: 0 },\n            },\n            expected_type: \"episode\",\n        },\n        ItemTypeCase {\n            uri: SpotifyUri::Playlist {\n                user: None,\n                id: SpotifyId { id: 0 },\n            },\n            expected_type: \"playlist\",\n        },\n        ItemTypeCase {\n            uri: SpotifyUri::Show {\n                id: SpotifyId { id: 0 },\n            },\n            expected_type: \"show\",\n        },\n        ItemTypeCase {\n            uri: SpotifyUri::Track {\n                id: SpotifyId { id: 0 },\n            },\n            expected_type: \"track\",\n        },\n    ];\n\n    #[test]\n    fn to_id() {\n        for c in &CONV_VALID {\n            assert_eq!(c.parsed.to_id(), c.base62);\n        }\n    }\n\n    #[test]\n    fn item_type() {\n        for i in &ITEM_TYPES {\n            assert_eq!(i.uri.item_type(), i.expected_type);\n        }\n\n        // These need to use methods that can't be used in the static context like to_owned() and\n        // into().\n\n        let local_file = SpotifyUri::Local {\n            artist: \"\".to_owned(),\n            album_title: \"\".to_owned(),\n            track_title: \"\".to_owned(),\n            duration: Default::default(),\n        };\n\n        assert_eq!(local_file.item_type(), \"local\");\n\n        let unknown = SpotifyUri::Unknown {\n            kind: \"not used\".into(),\n            id: \"\".to_owned(),\n        };\n\n        assert_eq!(unknown.item_type(), \"unknown\");\n    }\n\n    #[test]\n    fn from_uri() {\n        for c in &CONV_VALID {\n            let actual = SpotifyUri::from_uri(c.uri).unwrap();\n\n            assert_eq!(actual, c.parsed);\n        }\n\n        for c in &CONV_INVALID {\n            assert!(SpotifyUri::from_uri(c.uri).is_err());\n        }\n    }\n\n    #[test]\n    fn from_invalid_type_uri() {\n        let actual =\n            SpotifyUri::from_uri(\"spotify:arbitrarywhatever:5sWHDYs0csV6RS48xBl0tH\").unwrap();\n\n        assert_eq!(\n            actual,\n            SpotifyUri::Unknown {\n                kind: \"arbitrarywhatever\".into(),\n                id: \"5sWHDYs0csV6RS48xBl0tH\".to_owned()\n            }\n        )\n    }\n\n    #[test]\n    fn from_local_uri() {\n        let actual = SpotifyUri::from_uri(\n            \"spotify:local:David+Wise:Donkey+Kong+Country%3A+Tropical+Freeze:Snomads+Island:127\",\n        )\n        .unwrap();\n\n        assert_eq!(\n            actual,\n            SpotifyUri::Local {\n                artist: \"David+Wise\".to_owned(),\n                album_title: \"Donkey+Kong+Country%3A+Tropical+Freeze\".to_owned(),\n                track_title: \"Snomads+Island\".to_owned(),\n                duration: Duration::from_secs(127),\n            }\n        );\n    }\n\n    #[test]\n    fn from_local_uri_missing_fields() {\n        let actual = SpotifyUri::from_uri(\"spotify:local:::Snomads+Island:127\").unwrap();\n\n        assert_eq!(\n            actual,\n            SpotifyUri::Local {\n                artist: \"\".to_owned(),\n                album_title: \"\".to_owned(),\n                track_title: \"Snomads+Island\".to_owned(),\n                duration: Duration::from_secs(127),\n            }\n        );\n    }\n\n    #[test]\n    fn from_named_uri() {\n        let actual =\n            SpotifyUri::from_uri(\"spotify:user:spotify:playlist:37i9dQZF1DWSw8liJZcPOI\").unwrap();\n\n        let SpotifyUri::Playlist { ref user, id } = actual else {\n            panic!(\"wrong id type\");\n        };\n\n        assert_eq!(*user, Some(\"spotify\".to_owned()));\n        assert_eq!(\n            id,\n            SpotifyId {\n                id: 136159921382084734723401526672209703396\n            },\n        );\n    }\n\n    #[test]\n    fn to_uri() {\n        for c in &CONV_VALID {\n            assert_eq!(c.parsed.to_uri(), c.uri);\n        }\n    }\n\n    #[test]\n    fn to_named_uri() {\n        let string = \"spotify:user:spotify:playlist:37i9dQZF1DWSw8liJZcPOI\";\n\n        let actual =\n            SpotifyUri::from_uri(\"spotify:user:spotify:playlist:37i9dQZF1DWSw8liJZcPOI\").unwrap();\n\n        assert_eq!(actual.to_uri(), string);\n    }\n}\n"
  },
  {
    "path": "core/src/token.rs",
    "content": "// Ported from librespot-java. Relicensed under MIT with permission.\n\n// Known scopes:\n//   ugc-image-upload, playlist-read-collaborative, playlist-modify-private,\n//   playlist-modify-public, playlist-read-private, user-read-playback-position,\n//   user-read-recently-played, user-top-read, user-modify-playback-state,\n//   user-read-currently-playing, user-read-playback-state, user-read-private, user-read-email,\n//   user-library-modify, user-library-read, user-follow-modify, user-follow-read, streaming,\n//   app-remote-control\n\nuse std::time::{Duration, SystemTime};\n\nuse serde::Deserialize;\nuse thiserror::Error;\n\nuse crate::Error;\n\ncomponent! {\n    TokenProvider : TokenProviderInner {\n        tokens: Vec<Token> = vec![],\n    }\n}\n\n#[derive(Debug, Error)]\npub enum TokenError {\n    #[error(\"no tokens available\")]\n    Empty,\n}\n\nimpl From<TokenError> for Error {\n    fn from(err: TokenError) -> Self {\n        Error::unavailable(err)\n    }\n}\n\n#[derive(Clone, Debug)]\npub struct Token {\n    pub access_token: String,\n    pub expires_in: Duration,\n    pub token_type: String,\n    pub scopes: Vec<String>,\n    pub timestamp: SystemTime,\n}\n\n#[derive(Deserialize)]\n#[serde(rename_all = \"camelCase\")]\nstruct TokenData {\n    access_token: String,\n    expires_in: u64,\n    token_type: String,\n    scope: Vec<String>,\n}\n\nimpl TokenProvider {\n    fn find_token(&self, scopes: Vec<&str>) -> Option<usize> {\n        self.lock(|inner| {\n            (0..inner.tokens.len()).find(|&i| inner.tokens[i].in_scopes(scopes.clone()))\n        })\n    }\n\n    // Not all combinations of scopes and client ID are allowed.\n    // Depending on the client ID currently used, the function may return an error for specific scopes.\n    // In this case get_token_with_client_id() can be used, where an appropriate client ID can be provided.\n    // scopes must be comma-separated\n    pub async fn get_token(&self, scopes: &str) -> Result<Token, Error> {\n        let client_id = self.session().client_id();\n        self.get_token_with_client_id(scopes, &client_id).await\n    }\n\n    pub async fn get_token_with_client_id(\n        &self,\n        scopes: &str,\n        client_id: &str,\n    ) -> Result<Token, Error> {\n        if client_id.is_empty() {\n            return Err(Error::invalid_argument(\"Client ID cannot be empty\"));\n        }\n\n        if let Some(index) = self.find_token(scopes.split(',').collect()) {\n            let cached_token = self.lock(|inner| inner.tokens[index].clone());\n            if cached_token.is_expired() {\n                self.lock(|inner| inner.tokens.remove(index));\n            } else {\n                return Ok(cached_token);\n            }\n        }\n\n        trace!(\n            \"Requested token in scopes {scopes:?} unavailable or expired, requesting new token.\"\n        );\n\n        let query_uri = format!(\n            \"hm://keymaster/token/authenticated?scope={}&client_id={}&device_id={}\",\n            scopes,\n            client_id,\n            self.session().device_id(),\n        );\n        let request = self.session().mercury().get(query_uri)?;\n        let response = request.await?;\n        let data = response.payload.first().ok_or(TokenError::Empty)?.to_vec();\n        let token = Token::from_json(String::from_utf8(data)?)?;\n        trace!(\"Got token: {token:#?}\");\n        self.lock(|inner| inner.tokens.push(token.clone()));\n        Ok(token)\n    }\n}\n\nimpl Token {\n    const EXPIRY_THRESHOLD: Duration = Duration::from_secs(10);\n\n    pub fn from_json(body: String) -> Result<Self, Error> {\n        let data: TokenData = serde_json::from_slice(body.as_ref())?;\n        Ok(Self {\n            access_token: data.access_token,\n            expires_in: Duration::from_secs(data.expires_in),\n            token_type: data.token_type,\n            scopes: data.scope,\n            timestamp: SystemTime::now(),\n        })\n    }\n\n    pub fn is_expired(&self) -> bool {\n        self.timestamp + self.expires_in.saturating_sub(Self::EXPIRY_THRESHOLD) < SystemTime::now()\n    }\n\n    pub fn in_scope(&self, scope: &str) -> bool {\n        for s in &self.scopes {\n            if *s == scope {\n                return true;\n            }\n        }\n        false\n    }\n\n    pub fn in_scopes(&self, scopes: Vec<&str>) -> bool {\n        for s in scopes {\n            if !self.in_scope(s) {\n                return false;\n            }\n        }\n        true\n    }\n}\n"
  },
  {
    "path": "core/src/util.rs",
    "content": "use crate::Error;\nuse byteorder::{BigEndian, ByteOrder};\nuse futures_core::ready;\nuse futures_util::{FutureExt, Sink, SinkExt, future};\nuse hmac::digest::Digest;\nuse sha1::Sha1;\nuse std::time::{Duration, Instant};\nuse std::{\n    future::Future,\n    mem,\n    pin::Pin,\n    task::{Context, Poll},\n};\nuse tokio::{task::JoinHandle, time::timeout};\n\n/// Returns a future that will flush the sink, even if flushing is temporarily completed.\n/// Finishes only if the sink throws an error.\npub(crate) fn keep_flushing<'a, T, S: Sink<T> + Unpin + 'a>(\n    mut s: S,\n) -> impl Future<Output = S::Error> + 'a {\n    future::poll_fn(move |cx| match s.poll_flush_unpin(cx) {\n        Poll::Ready(Err(e)) => Poll::Ready(e),\n        _ => Poll::Pending,\n    })\n}\n\npub struct CancelOnDrop<T>(pub JoinHandle<T>);\n\nimpl<T> Future for CancelOnDrop<T> {\n    type Output = <JoinHandle<T> as Future>::Output;\n\n    fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {\n        self.0.poll_unpin(cx)\n    }\n}\n\nimpl<T> Drop for CancelOnDrop<T> {\n    fn drop(&mut self) {\n        self.0.abort();\n    }\n}\n\npub struct TimeoutOnDrop<T: Send + 'static> {\n    handle: Option<JoinHandle<T>>,\n    timeout: tokio::time::Duration,\n}\n\nimpl<T: Send + 'static> TimeoutOnDrop<T> {\n    pub fn new(handle: JoinHandle<T>, timeout: tokio::time::Duration) -> Self {\n        Self {\n            handle: Some(handle),\n            timeout,\n        }\n    }\n\n    pub fn take(&mut self) -> Option<JoinHandle<T>> {\n        self.handle.take()\n    }\n}\n\nimpl<T: Send + 'static> Future for TimeoutOnDrop<T> {\n    type Output = <JoinHandle<T> as Future>::Output;\n\n    fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {\n        let r = ready!(\n            self.handle\n                .as_mut()\n                .expect(\"Polled after ready\")\n                .poll_unpin(cx)\n        );\n        self.handle = None;\n        Poll::Ready(r)\n    }\n}\n\nimpl<T: Send + 'static> Drop for TimeoutOnDrop<T> {\n    fn drop(&mut self) {\n        let mut handle = if let Some(handle) = self.handle.take() {\n            handle\n        } else {\n            return;\n        };\n\n        if (&mut handle).now_or_never().is_some() {\n            // Already finished\n            return;\n        }\n\n        match tokio::runtime::Handle::try_current() {\n            Ok(h) => {\n                h.spawn(timeout(self.timeout, CancelOnDrop(handle)));\n            }\n            Err(_) => {\n                // Not in tokio context, can't spawn\n                handle.abort();\n            }\n        }\n    }\n}\n\npub trait Seq {\n    fn next(&self) -> Self;\n}\n\nmacro_rules! impl_seq {\n    ($($ty:ty)*) => { $(\n        impl Seq for $ty {\n            fn next(&self) -> Self { (*self).wrapping_add(1) }\n        }\n    )* }\n}\n\nimpl_seq!(u8 u16 u32 u64 usize);\n\n#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, PartialOrd, Ord, Default)]\npub struct SeqGenerator<T: Seq>(T);\n\nimpl<T: Seq> SeqGenerator<T> {\n    pub fn new(value: T) -> Self {\n        SeqGenerator(value)\n    }\n\n    pub fn get(&mut self) -> T {\n        let value = self.0.next();\n        mem::replace(&mut self.0, value)\n    }\n}\n\npub fn solve_hash_cash(\n    ctx: &[u8],\n    prefix: &[u8],\n    length: i32,\n    dst: &mut [u8],\n) -> Result<Duration, Error> {\n    // after a certain number of seconds, the challenge expires\n    const TIMEOUT: u64 = 5; // seconds\n    let now = Instant::now();\n\n    let md = Sha1::digest(ctx);\n\n    let mut counter: i64 = 0;\n    let target: i64 = BigEndian::read_i64(&md[12..20]);\n\n    let suffix = loop {\n        if now.elapsed().as_secs() >= TIMEOUT {\n            return Err(Error::deadline_exceeded(format!(\n                \"{TIMEOUT} seconds expired\"\n            )));\n        }\n\n        let suffix = [(target + counter).to_be_bytes(), counter.to_be_bytes()].concat();\n\n        let mut hasher = Sha1::new();\n        hasher.update(prefix);\n        hasher.update(&suffix);\n        let md = hasher.finalize();\n\n        if BigEndian::read_i64(&md[12..20]).trailing_zeros() >= (length as u32) {\n            break suffix;\n        }\n\n        counter += 1;\n    };\n\n    dst.copy_from_slice(&suffix);\n\n    Ok(now.elapsed())\n}\n\npub fn get_next_query_separator(url: &str) -> &'static str {\n    match url.find('?') {\n        Some(_) => \"&\",\n        None => \"?\",\n    }\n}\n"
  },
  {
    "path": "core/src/version.rs",
    "content": "/// Version string of the form \"librespot-\\<sha\\>\"\npub const VERSION_STRING: &str = concat!(\"librespot-\", env!(\"VERGEN_GIT_SHA\"));\n\n/// Generate a timestamp string representing the build date (UTC).\npub const BUILD_DATE: &str = env!(\"VERGEN_BUILD_DATE\");\n\n/// Short sha of the latest git commit.\npub const SHA_SHORT: &str = env!(\"VERGEN_GIT_SHA\");\n\n/// Date of the latest git commit.\npub const COMMIT_DATE: &str = env!(\"VERGEN_GIT_COMMIT_DATE\");\n\n/// Librespot crate version.\npub const SEMVER: &str = env!(\"CARGO_PKG_VERSION\");\n\n/// A random build id.\npub const BUILD_ID: &str = env!(\"LIBRESPOT_BUILD_ID\");\n\n/// The protocol version of the Spotify desktop client.\npub const SPOTIFY_VERSION: u64 = 124200290;\n\n/// The semantic version of the Spotify desktop client.\npub const SPOTIFY_SEMANTIC_VERSION: &str = \"1.2.52.442\";\n\n/// `property_set_id` related to desktop version 1.2.52.442\npub const SPOTIFY_PROPERTY_SET_ID: &str = \"b4c7e4b5835079ed94391b2e65fca0fdba65eb50\";\n\n/// The protocol version of the Spotify mobile app.\npub const SPOTIFY_MOBILE_VERSION: &str = \"8.9.82.620\";\n\n/// `property_set_id` related to mobile version 8.9.82.620\npub const SPOTIFY_MOBILE_PROPERTY_SET_ID: &str =\n    \"5ec87c2cc32e7c509703582cfaaa3c7ad253129d5701127c1f5eab5c9531736c\";\n\n/// The general spirc version\npub const SPOTIFY_SPIRC_VERSION: &str = \"3.2.6\";\n\n/// The user agent to fall back to, if one could not be determined dynamically.\npub const FALLBACK_USER_AGENT: &str = \"Spotify/124200290 Linux/0 (librespot)\";\n\npub fn spotify_version() -> String {\n    match crate::config::OS {\n        \"android\" | \"ios\" => SPOTIFY_MOBILE_VERSION.to_owned(),\n        _ => SPOTIFY_VERSION.to_string(),\n    }\n}\n\npub fn spotify_semantic_version() -> String {\n    match crate::config::OS {\n        \"android\" | \"ios\" => SPOTIFY_MOBILE_VERSION.to_owned(),\n        _ => SPOTIFY_SEMANTIC_VERSION.to_string(),\n    }\n}\n"
  },
  {
    "path": "core/tests/connect.rs",
    "content": "use std::time::Duration;\n\nuse tokio::time::timeout;\n\nuse librespot_core::{authentication::Credentials, config::SessionConfig, session::Session};\n\n#[tokio::test]\nasync fn test_connection() {\n    timeout(Duration::from_secs(30), async {\n        let result = Session::new(SessionConfig::default(), None)\n            .connect(Credentials::with_password(\"test\", \"test\"), false)\n            .await;\n\n        match result {\n            Ok(_) => panic!(\"Authentication succeeded despite of bad credentials.\"),\n            Err(e) => assert!(!e.to_string().is_empty()), // there should be some error message\n        }\n    })\n    .await\n    .unwrap();\n}\n"
  },
  {
    "path": "discovery/Cargo.toml",
    "content": "[package]\nname = \"librespot-discovery\"\nversion = \"0.8.0\"\nrust-version.workspace = true\nauthors = [\"Paul Lietar <paul@lietar.net>\"]\nlicense.workspace = true\ndescription = \"The discovery logic for librespot\"\nrepository.workspace = true\nedition.workspace = true\n\n[features]\n# Refer to the workspace Cargo.toml for the list of features\ndefault = [\"with-libmdns\", \"native-tls\"]\n\n# Discovery backends\nwith-avahi = [\"dep:serde\", \"dep:zbus\", \"futures-util/async-await-macro\"]\nwith-dns-sd = [\"dep:dns-sd\"]\nwith-libmdns = [\"dep:libmdns\"]\n\n# TLS backend propagation\nnative-tls = [\"librespot-core/native-tls\"]\nrustls-tls-native-roots = [\"librespot-core/rustls-tls-native-roots\"]\nrustls-tls-webpki-roots = [\"librespot-core/rustls-tls-webpki-roots\"]\n\n[dependencies]\nlibrespot-core = { version = \"0.8.0\", path = \"../core\", default-features = false }\n\naes = \"0.8\"\nbase64 = \"0.22\"\nbytes = \"1\"\nctr = \"0.9\"\ndns-sd = { version = \"0.1\", optional = true }\nform_urlencoded = \"1.2\"\nfutures-core = \"0.3\"\nfutures-util = { version = \"0.3\", default-features = false, features = [\"std\"] }\nhmac = \"0.12\"\nhttp-body-util = \"0.1\"\nhyper = { version = \"1.6\", features = [\"http1\"] }\nhyper-util = { version = \"0.1\", features = [\n    \"server-auto\",\n    \"server-graceful\",\n    \"service\",\n] }\nlibmdns = { version = \"0.10\", optional = true }\nlog = \"0.4\"\nrand = { version = \"0.9\", default-features = false, features = [\"thread_rng\"] }\nserde = { version = \"1\", default-features = false, features = [\n    \"derive\",\n], optional = true }\nserde_repr = \"0.1\"\nserde_json = \"1.0\"\nsha1 = \"0.10\"\nthiserror = \"2\"\ntokio = { version = \"1\", features = [\"sync\", \"rt\"] }\nzbus = { version = \"5\", default-features = false, features = [\n    \"tokio\",\n], optional = true }\n\n[dev-dependencies]\nfutures = \"0.3\"\nhex = \"0.4\"\ntokio = { version = \"1\", features = [\"macros\", \"rt\"] }\n\n[lints]\nworkspace = true\n"
  },
  {
    "path": "discovery/examples/discovery.rs",
    "content": "use futures::StreamExt;\nuse librespot_core::SessionConfig;\nuse librespot_discovery::DeviceType;\nuse sha1::{Digest, Sha1};\n\n#[tokio::main(flavor = \"current_thread\")]\nasync fn main() {\n    let name = \"Librespot\";\n    let device_id = hex::encode(Sha1::digest(name.as_bytes()));\n\n    let mut server =\n        librespot_discovery::Discovery::builder(device_id, SessionConfig::default().client_id)\n            .name(name)\n            .device_type(DeviceType::Computer)\n            .launch()\n            .unwrap();\n\n    while let Some(x) = server.next().await {\n        println!(\"Received {x:?}\");\n    }\n}\n"
  },
  {
    "path": "discovery/examples/discovery_group.rs",
    "content": "use futures::StreamExt;\nuse librespot_core::SessionConfig;\nuse librespot_discovery::DeviceType;\nuse sha1::{Digest, Sha1};\n\n#[tokio::main(flavor = \"current_thread\")]\nasync fn main() {\n    let name = \"Librespot Group\";\n    let device_id = hex::encode(Sha1::digest(name.as_bytes()));\n\n    let mut server =\n        librespot_discovery::Discovery::builder(device_id, SessionConfig::default().client_id)\n            .name(name)\n            .device_type(DeviceType::Speaker)\n            .is_group(true)\n            .launch()\n            .unwrap();\n\n    while let Some(x) = server.next().await {\n        println!(\"Received {x:?}\");\n    }\n}\n"
  },
  {
    "path": "discovery/src/avahi.rs",
    "content": "#![cfg(feature = \"with-avahi\")]\n\n#[allow(unused)]\npub use server::ServerProxy;\n\n#[allow(unused)]\npub use entry_group::{\n    EntryGroupProxy, EntryGroupState, StateChangedStream as EntryGroupStateChangedStream,\n};\n\nmod server {\n    // This is not the full interface, just the methods we need!\n    // Avahi also implements a newer version of the interface (\"org.freedesktop.Avahi.Server2\"), but\n    // the additions are not relevant for us, and the older version is not intended to be deprecated.\n    // cf. the release notes for 0.8 at https://github.com/avahi/avahi/blob/master/docs/NEWS\n    #[zbus::proxy(\n        interface = \"org.freedesktop.Avahi.Server\",\n        default_service = \"org.freedesktop.Avahi\",\n        default_path = \"/\",\n        gen_blocking = false\n    )]\n    pub trait Server {\n        /// EntryGroupNew method\n        #[zbus(object = \"super::entry_group::EntryGroup\")]\n        fn entry_group_new(&self);\n\n        /// GetState method\n        fn get_state(&self) -> zbus::Result<i32>;\n\n        /// StateChanged signal\n        #[zbus(signal)]\n        fn state_changed(&self, state: i32, error: &str) -> zbus::Result<()>;\n    }\n}\n\nmod entry_group {\n    use serde_repr::Deserialize_repr;\n    use zbus::zvariant;\n\n    #[derive(Clone, Copy, Debug, Deserialize_repr)]\n    #[repr(i32)]\n    pub enum EntryGroupState {\n        // The group has not yet been committed, the user must still call avahi_entry_group_commit()\n        Uncommited = 0,\n        // The entries of the group are currently being registered\n        Registering = 1,\n        // The entries have successfully been established\n        Established = 2,\n        // A name collision for one of the entries in the group has been detected, the entries have been withdrawn\n        Collision = 3,\n        // Some kind of failure happened, the entries have been withdrawn\n        Failure = 4,\n    }\n\n    impl zvariant::Type for EntryGroupState {\n        const SIGNATURE: &'static zvariant::Signature = &zvariant::Signature::I32;\n    }\n\n    #[zbus::proxy(\n        interface = \"org.freedesktop.Avahi.EntryGroup\",\n        default_service = \"org.freedesktop.Avahi\",\n        gen_blocking = false\n    )]\n    pub trait EntryGroup {\n        /// AddAddress method\n        fn add_address(\n            &self,\n            interface: i32,\n            protocol: i32,\n            flags: u32,\n            name: &str,\n            address: &str,\n        ) -> zbus::Result<()>;\n\n        /// AddRecord method\n        #[allow(clippy::too_many_arguments)]\n        fn add_record(\n            &self,\n            interface: i32,\n            protocol: i32,\n            flags: u32,\n            name: &str,\n            clazz: u16,\n            type_: u16,\n            ttl: u32,\n            rdata: &[u8],\n        ) -> zbus::Result<()>;\n\n        /// AddService method\n        #[allow(clippy::too_many_arguments)]\n        fn add_service(\n            &self,\n            interface: i32,\n            protocol: i32,\n            flags: u32,\n            name: &str,\n            type_: &str,\n            domain: &str,\n            host: &str,\n            port: u16,\n            txt: &[&[u8]],\n        ) -> zbus::Result<()>;\n\n        /// AddServiceSubtype method\n        #[allow(clippy::too_many_arguments)]\n        fn add_service_subtype(\n            &self,\n            interface: i32,\n            protocol: i32,\n            flags: u32,\n            name: &str,\n            type_: &str,\n            domain: &str,\n            subtype: &str,\n        ) -> zbus::Result<()>;\n\n        /// Commit method\n        fn commit(&self) -> zbus::Result<()>;\n\n        /// Free method\n        fn free(&self) -> zbus::Result<()>;\n\n        /// GetState method\n        fn get_state(&self) -> zbus::Result<EntryGroupState>;\n\n        /// IsEmpty method\n        fn is_empty(&self) -> zbus::Result<bool>;\n\n        /// Reset method\n        fn reset(&self) -> zbus::Result<()>;\n\n        /// UpdateServiceTxt method\n        #[allow(clippy::too_many_arguments)]\n        fn update_service_txt(\n            &self,\n            interface: i32,\n            protocol: i32,\n            flags: u32,\n            name: &str,\n            type_: &str,\n            domain: &str,\n            txt: &[&[u8]],\n        ) -> zbus::Result<()>;\n\n        /// StateChanged signal\n        #[zbus(signal)]\n        fn state_changed(&self, state: EntryGroupState, error: &str) -> zbus::Result<()>;\n    }\n}\n"
  },
  {
    "path": "discovery/src/lib.rs",
    "content": "//! Advertises this device to Spotify clients in the local network.\n//!\n//! This device will show up in the list of \"available devices\".\n//! Once it is selected from the list, [`Credentials`] are received.\n//! Those can be used to establish a new Session with [`librespot_core`].\n//!\n//! This library uses mDNS and DNS-SD so that other devices can find it,\n//! and spawns an http server to answer requests of Spotify clients.\n\nmod avahi;\nmod server;\n\nuse std::{\n    borrow::Cow,\n    error::Error as StdError,\n    pin::Pin,\n    task::{Context, Poll},\n};\n\nuse futures_core::Stream;\nuse thiserror::Error;\nuse tokio::sync::{mpsc, oneshot};\n\nuse self::server::DiscoveryServer;\n\npub use crate::core::Error;\nuse librespot_core as core;\n\n/// Credentials to be used in [`librespot`](`librespot_core`).\npub use crate::core::authentication::Credentials;\n\n/// Determining the icon in the list of available devices.\npub use crate::core::config::DeviceType;\n\npub enum DiscoveryEvent {\n    Credentials(Credentials),\n    ServerError(DiscoveryError),\n    ZeroconfError(DiscoveryError),\n}\n\nenum ZeroconfCmd {\n    Shutdown,\n}\n\npub struct DnsSdHandle {\n    task_handle: tokio::task::JoinHandle<()>,\n    shutdown_tx: oneshot::Sender<ZeroconfCmd>,\n}\n\nimpl DnsSdHandle {\n    async fn shutdown(self) {\n        log::debug!(\"Shutting down zeroconf responder\");\n        let Self {\n            task_handle,\n            shutdown_tx,\n        } = self;\n        if shutdown_tx.send(ZeroconfCmd::Shutdown).is_err() {\n            log::warn!(\"Zeroconf responder unexpectedly disappeared\");\n        } else {\n            let _ = task_handle.await;\n            log::debug!(\"Zeroconf responder stopped\");\n        }\n    }\n}\n\npub type DnsSdServiceBuilder = fn(\n    Cow<'static, str>,\n    Vec<std::net::IpAddr>,\n    u16,\n    mpsc::UnboundedSender<DiscoveryEvent>,\n) -> Result<DnsSdHandle, Error>;\n\n// Default goes first: This matches the behaviour when feature flags were exlusive, i.e. when there\n// was only `feature = \"with-dns-sd\"` or `not(feature = \"with-dns-sd\")`\npub const BACKENDS: &[(\n    &str,\n    // If None, the backend is known but wasn't compiled.\n    Option<DnsSdServiceBuilder>,\n)] = &[\n    #[cfg(feature = \"with-avahi\")]\n    (\"avahi\", Some(launch_avahi)),\n    #[cfg(not(feature = \"with-avahi\"))]\n    (\"avahi\", None),\n    #[cfg(feature = \"with-dns-sd\")]\n    (\"dns-sd\", Some(launch_dns_sd)),\n    #[cfg(not(feature = \"with-dns-sd\"))]\n    (\"dns-sd\", None),\n    #[cfg(feature = \"with-libmdns\")]\n    (\"libmdns\", Some(launch_libmdns)),\n    #[cfg(not(feature = \"with-libmdns\"))]\n    (\"libmdns\", None),\n];\n\npub fn find(name: Option<&str>) -> Result<DnsSdServiceBuilder, Error> {\n    if let Some(ref name) = name {\n        match BACKENDS.iter().find(|(id, _)| name == id) {\n            Some((_id, Some(launch_svc))) => Ok(*launch_svc),\n            Some((_id, None)) => Err(Error::unavailable(format!(\n                \"librespot built without '{name}' support\"\n            ))),\n            None => Err(Error::not_found(format!(\n                \"unknown zeroconf backend '{name}'\"\n            ))),\n        }\n    } else {\n        BACKENDS\n            .iter()\n            .find_map(|(_, launch_svc)| *launch_svc)\n            .ok_or(Error::unavailable(\n                \"librespot built without zeroconf backends\",\n            ))\n    }\n}\n\n/// Makes this device visible to Spotify clients in the local network.\n///\n/// `Discovery` implements the [`Stream`] trait. Every time this device\n/// is selected in the list of available devices, it yields [`Credentials`].\npub struct Discovery {\n    server: DiscoveryServer,\n\n    /// An opaque handle to the DNS-SD service. Dropping this will unregister the service.\n    #[allow(unused)]\n    svc: DnsSdHandle,\n\n    event_rx: mpsc::UnboundedReceiver<DiscoveryEvent>,\n}\n\n/// A builder for [`Discovery`].\npub struct Builder {\n    server_config: server::Config,\n    port: u16,\n    zeroconf_ip: Vec<std::net::IpAddr>,\n    zeroconf_backend: Option<DnsSdServiceBuilder>,\n}\n\n/// Errors that can occur while setting up a [`Discovery`] instance.\n#[derive(Debug, Error)]\npub enum DiscoveryError {\n    #[error(\"Creating SHA1 block cipher failed\")]\n    AesError(#[from] aes::cipher::InvalidLength),\n\n    #[error(\"Setting up dns-sd failed: {0}\")]\n    DnsSdError(#[source] Box<dyn StdError + Send + Sync>),\n\n    #[error(\"Creating SHA1 HMAC failed for base key {0:?}\")]\n    HmacError(Vec<u8>),\n\n    #[error(\"Setting up the HTTP server failed: {0}\")]\n    HttpServerError(#[from] hyper::Error),\n\n    #[error(\"Missing params for key {0}\")]\n    ParamsError(&'static str),\n}\n\n#[cfg(feature = \"with-avahi\")]\nimpl From<zbus::Error> for DiscoveryError {\n    fn from(error: zbus::Error) -> Self {\n        Self::DnsSdError(Box::new(error))\n    }\n}\n\nimpl From<DiscoveryError> for Error {\n    fn from(err: DiscoveryError) -> Self {\n        match err {\n            DiscoveryError::AesError(_) => Error::unavailable(err),\n            DiscoveryError::DnsSdError(_) => Error::unavailable(err),\n            DiscoveryError::HmacError(_) => Error::invalid_argument(err),\n            DiscoveryError::HttpServerError(_) => Error::unavailable(err),\n            DiscoveryError::ParamsError(_) => Error::invalid_argument(err),\n        }\n    }\n}\n\n#[allow(unused)]\nconst DNS_SD_SERVICE_NAME: &str = \"_spotify-connect._tcp\";\n#[allow(unused)]\nconst TXT_RECORD: [&str; 2] = [\"VERSION=1.0\", \"CPath=/\"];\n\n#[cfg(feature = \"with-avahi\")]\nasync fn avahi_task(\n    name: Cow<'static, str>,\n    port: u16,\n    entry_group: &mut Option<avahi::EntryGroupProxy<'_>>,\n) -> Result<(), DiscoveryError> {\n    use self::avahi::{EntryGroupState, ServerProxy};\n    use futures_util::StreamExt;\n\n    let conn = zbus::Connection::system().await?;\n\n    // Wait for the daemon to show up.\n    // On error: Failed to listen for NameOwnerChanged signal => Fatal DBus issue\n    let bus = zbus::fdo::DBusProxy::new(&conn).await?;\n    let mut stream = bus\n        .receive_name_owner_changed_with_args(&[(0, \"org.freedesktop.Avahi\")])\n        .await?;\n\n    loop {\n        // Wait for Avahi daemon to be started\n        'wait_avahi: {\n            while let Poll::Ready(Some(_)) = futures_util::poll!(stream.next()) {\n                // Drain queued name owner changes, since we're going to connect in a second\n            }\n\n            // Ping after we connected to the signal since it might have shown up in the meantime\n            if let Ok(avahi_peer) =\n                zbus::fdo::PeerProxy::new(&conn, \"org.freedesktop.Avahi\", \"/\").await\n            {\n                if avahi_peer.ping().await.is_ok() {\n                    log::debug!(\"Pinged Avahi: Available\");\n                    break 'wait_avahi;\n                }\n            }\n            log::warn!(\n                \"Failed to connect to Avahi, zeroconf discovery will not work until avahi-daemon is started. Check that it is installed and running\"\n            );\n\n            // If it didn't, wait for the signal\n            match stream.next().await {\n                Some(_signal) => {\n                    log::debug!(\"Avahi appeared\");\n                    break 'wait_avahi;\n                }\n                // The stream ended, but this should never happen\n                None => {\n                    return Err(zbus::Error::Failure(\"DBus disappeared\".to_owned()).into());\n                }\n            }\n        }\n\n        // Connect to Avahi and publish the service\n        let avahi_server = ServerProxy::new(&conn).await?;\n        log::trace!(\"Connected to Avahi\");\n\n        *entry_group = Some(avahi_server.entry_group_new().await?);\n\n        let mut entry_group_state_stream = entry_group\n            .as_mut()\n            .unwrap()\n            .receive_state_changed()\n            .await?;\n\n        entry_group\n            .as_mut()\n            .unwrap()\n            .add_service(\n                -1, // AVAHI_IF_UNSPEC\n                -1, // IPv4 and IPv6\n                0,  // flags\n                &name,\n                DNS_SD_SERVICE_NAME, // type\n                \"\",                  // domain: let the server choose\n                \"\",                  // host: let the server choose\n                port,\n                &TXT_RECORD.map(|s| s.as_bytes()),\n            )\n            .await?;\n\n        entry_group.as_mut().unwrap().commit().await?;\n        log::debug!(\"Commited zeroconf service with name {}\", &name);\n\n        'monitor_service: loop {\n            tokio::select! {\n                Some(state_changed) = entry_group_state_stream.next() => {\n                    let (state, error) = match state_changed.args() {\n                        Ok(sc) => (sc.state, sc.error),\n                        Err(e) => {\n                            log::warn!(\"Error on receiving EntryGroup state from Avahi: {}\", e);\n                            continue 'monitor_service;\n                        }\n                    };\n                    match state {\n                        EntryGroupState::Uncommited | EntryGroupState::Registering => {\n                            // Not yet registered, ignore.\n                        }\n                        EntryGroupState::Established => {\n                            log::info!(\"Published zeroconf service\");\n                        }\n                        EntryGroupState::Collision => {\n                            // This most likely means that librespot has unintentionally been started twice.\n                            // Thus, don't retry with a new name, but abort.\n                            //\n                            // Note that the error would usually already be returned by\n                            // entry_group.add_service above, so this state_changed handler\n                            // won't be hit.\n                            //\n                            // EntryGroup has been withdrawn at this point already!\n                            log::error!(\"zeroconf collision for name '{}'\", &name);\n                            return Err(zbus::Error::Failure(format!(\"zeroconf collision for name: {name}\")).into());\n                        }\n                        EntryGroupState::Failure => {\n                            // TODO: Back off/treat as fatal?\n                            // EntryGroup has been withdrawn at this point already!\n                            // There seems to be no code in Avahi that actually sets this state.\n                            log::error!(\"zeroconf failure: {}\", error);\n                            return Err(zbus::Error::Failure(format!(\"zeroconf failure: {error}\")).into());\n                        }\n                    }\n                }\n                _name_owner_change = stream.next() => {\n                    break 'monitor_service;\n                }\n            }\n        }\n\n        // Avahi disappeared (or the service was immediately taken over by a\n        // new daemon) => drop all handles, and reconnect\n        log::info!(\"Avahi disappeared, trying to reconnect\");\n    }\n}\n\n#[cfg(feature = \"with-avahi\")]\nfn launch_avahi(\n    name: Cow<'static, str>,\n    _zeroconf_ip: Vec<std::net::IpAddr>,\n    port: u16,\n    status_tx: mpsc::UnboundedSender<DiscoveryEvent>,\n) -> Result<DnsSdHandle, Error> {\n    let (shutdown_tx, shutdown_rx) = oneshot::channel();\n\n    let task_handle = tokio::spawn(async move {\n        let mut entry_group = None;\n        tokio::select! {\n            res = avahi_task(name, port, &mut entry_group) => {\n                if let Err(e) = res {\n                    log::error!(\"Avahi error: {}\", e);\n                    let _ = status_tx.send(DiscoveryEvent::ZeroconfError(e));\n                }\n            },\n            _ = shutdown_rx => {\n                if let Some(entry_group) = entry_group.as_mut() {\n                    if let Err(e) = entry_group.free().await {\n                        log::warn!(\"Failed to un-publish zeroconf service: {}\", e);\n                    } else {\n                        log::debug!(\"Un-published zeroconf service\");\n                    }\n                }\n            },\n        }\n    });\n\n    Ok(DnsSdHandle {\n        task_handle,\n        shutdown_tx,\n    })\n}\n\n#[cfg(feature = \"with-dns-sd\")]\nfn launch_dns_sd(\n    name: Cow<'static, str>,\n    _zeroconf_ip: Vec<std::net::IpAddr>,\n    port: u16,\n    status_tx: mpsc::UnboundedSender<DiscoveryEvent>,\n) -> Result<DnsSdHandle, Error> {\n    let (shutdown_tx, shutdown_rx) = oneshot::channel();\n\n    let task_handle = tokio::task::spawn_blocking(move || {\n        let inner = move || -> Result<(), DiscoveryError> {\n            let svc = dns_sd::DNSService::register(\n                Some(name.as_ref()),\n                DNS_SD_SERVICE_NAME,\n                None,\n                None,\n                port,\n                &TXT_RECORD,\n            )\n            .map_err(|e| DiscoveryError::DnsSdError(Box::new(e)))?;\n\n            let _ = shutdown_rx.blocking_recv();\n\n            std::mem::drop(svc);\n\n            Ok(())\n        };\n\n        if let Err(e) = inner() {\n            log::error!(\"dns_sd error: {}\", e);\n            let _ = status_tx.send(DiscoveryEvent::ZeroconfError(e));\n        }\n    });\n\n    Ok(DnsSdHandle {\n        shutdown_tx,\n        task_handle,\n    })\n}\n\n#[cfg(feature = \"with-libmdns\")]\nfn launch_libmdns(\n    name: Cow<'static, str>,\n    zeroconf_ip: Vec<std::net::IpAddr>,\n    port: u16,\n    status_tx: mpsc::UnboundedSender<DiscoveryEvent>,\n) -> Result<DnsSdHandle, Error> {\n    let (shutdown_tx, shutdown_rx) = oneshot::channel();\n\n    let task_handle = tokio::task::spawn_blocking(move || {\n        let inner = move || -> Result<(), DiscoveryError> {\n            let responder = if !zeroconf_ip.is_empty() {\n                libmdns::Responder::spawn_with_ip_list(\n                    &tokio::runtime::Handle::current(),\n                    zeroconf_ip,\n                )\n            } else {\n                libmdns::Responder::spawn(&tokio::runtime::Handle::current())\n            }\n            .map_err(|e| DiscoveryError::DnsSdError(Box::new(e)))?;\n\n            let svc = responder.register(DNS_SD_SERVICE_NAME, &name, port, &TXT_RECORD);\n\n            let _ = shutdown_rx.blocking_recv();\n\n            std::mem::drop(svc);\n\n            Ok(())\n        };\n\n        if let Err(e) = inner() {\n            log::error!(\"libmdns error: {e}\");\n            let _ = status_tx.send(DiscoveryEvent::ZeroconfError(e));\n        }\n    });\n\n    Ok(DnsSdHandle {\n        shutdown_tx,\n        task_handle,\n    })\n}\n\nimpl Builder {\n    /// Starts a new builder using the provided device and client IDs.\n    pub fn new<T: Into<String>>(device_id: T, client_id: T) -> Self {\n        Self {\n            server_config: server::Config {\n                name: \"Librespot\".into(),\n                device_type: DeviceType::default(),\n                is_group: false,\n                device_id: device_id.into(),\n                client_id: client_id.into(),\n                aliases: Vec::new(),\n            },\n            port: 0,\n            zeroconf_ip: vec![],\n            zeroconf_backend: None,\n        }\n    }\n\n    /// Sets the name to be displayed. Default is `\"Librespot\"`.\n    pub fn name(mut self, name: impl Into<Cow<'static, str>>) -> Self {\n        self.server_config.name = name.into();\n        self\n    }\n\n    /// Sets the device type which is visible as icon in other Spotify clients. Default is `Speaker`.\n    pub fn device_type(mut self, device_type: DeviceType) -> Self {\n        self.server_config.device_type = device_type;\n        self\n    }\n\n    /// Sets whether the device is a group. This affects the icon in Spotify clients. Default is `false`.\n    pub fn is_group(mut self, is_group: bool) -> Self {\n        self.server_config.is_group = is_group;\n        self\n    }\n\n    /// Adds an alias for this device. Multiple aliases can be added by calling this method multiple times.\n    pub fn add_alias(\n        mut self,\n        alias: impl Into<Cow<'static, str>>,\n        id: u32,\n        is_group: bool,\n    ) -> Self {\n        self.server_config.aliases.push(server::Alias {\n            name: alias.into(),\n            id,\n            is_group,\n        });\n        self\n    }\n\n    /// Set the ip addresses on which it should listen to incoming connections. The default is all interfaces.\n    pub fn zeroconf_ip(mut self, zeroconf_ip: Vec<std::net::IpAddr>) -> Self {\n        self.zeroconf_ip = zeroconf_ip;\n        self\n    }\n\n    /// Set the zeroconf (MDNS and DNS-SD) implementation to use.\n    pub fn zeroconf_backend(mut self, zeroconf_backend: DnsSdServiceBuilder) -> Self {\n        self.zeroconf_backend = Some(zeroconf_backend);\n        self\n    }\n\n    /// Sets the port on which it should listen to incoming connections.\n    /// The default value `0` means any port.\n    pub fn port(mut self, port: u16) -> Self {\n        self.port = port;\n        self\n    }\n\n    /// Sets up the [`Discovery`] instance.\n    ///\n    /// # Errors\n    /// If setting up the mdns service or creating the server fails, this function returns an error.\n    pub fn launch(self) -> Result<Discovery, Error> {\n        let name = self.server_config.name.clone();\n        let zeroconf_ip = self.zeroconf_ip;\n\n        let (event_tx, event_rx) = mpsc::unbounded_channel();\n\n        let mut port = self.port;\n        let server = DiscoveryServer::new(self.server_config, &mut port, event_tx.clone())?;\n\n        let launch_svc = self.zeroconf_backend.unwrap_or(find(None)?);\n        let svc = launch_svc(name, zeroconf_ip, port, event_tx)?;\n        Ok(Discovery {\n            server,\n            svc,\n            event_rx,\n        })\n    }\n}\n\nimpl Discovery {\n    /// Starts a [`Builder`] with the provided device id.\n    pub fn builder<T: Into<String>>(device_id: T, client_id: T) -> Builder {\n        Builder::new(device_id, client_id)\n    }\n\n    /// Create a new instance with the specified device id and default paramaters.\n    pub fn new<T: Into<String>>(device_id: T, client_id: T) -> Result<Self, Error> {\n        Self::builder(device_id, client_id).launch()\n    }\n\n    pub async fn shutdown(self) {\n        tokio::join!(self.server.shutdown(), self.svc.shutdown(),);\n    }\n}\n\nimpl Stream for Discovery {\n    type Item = Credentials;\n\n    fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {\n        match Pin::new(&mut self.event_rx).poll_recv(cx) {\n            // Yields credentials\n            Poll::Ready(Some(DiscoveryEvent::Credentials(creds))) => Poll::Ready(Some(creds)),\n            // Also terminate the stream on fatal server or MDNS/DNS-SD errors.\n            Poll::Ready(Some(\n                DiscoveryEvent::ServerError(_) | DiscoveryEvent::ZeroconfError(_),\n            )) => Poll::Ready(None),\n            Poll::Ready(None) => Poll::Ready(None),\n            Poll::Pending => Poll::Pending,\n        }\n    }\n}\n"
  },
  {
    "path": "discovery/src/server.rs",
    "content": "use std::{\n    borrow::Cow,\n    collections::BTreeMap,\n    net::{Ipv4Addr, Ipv6Addr, SocketAddr, TcpListener},\n    sync::{Arc, Mutex},\n};\n\nuse aes::cipher::{KeyIvInit, StreamCipher};\nuse base64::engine::Engine as _;\nuse base64::engine::general_purpose::STANDARD as BASE64;\nuse bytes::Bytes;\nuse futures_util::{FutureExt, TryFutureExt};\nuse hmac::{Hmac, Mac};\nuse http_body_util::{BodyExt, Full};\nuse hyper::{Method, Request, Response, StatusCode, body::Incoming};\n\nuse hyper_util::{rt::TokioIo, server::graceful::GracefulShutdown};\nuse log::{debug, error, warn};\nuse serde_json::json;\nuse sha1::{Digest, Sha1};\nuse tokio::sync::{mpsc, oneshot};\n\nuse super::{DiscoveryError, DiscoveryEvent};\n\nuse crate::{\n    core::config::DeviceType,\n    core::{Error, authentication::Credentials, diffie_hellman::DhLocalKeys},\n};\n\ntype Aes128Ctr = ctr::Ctr128BE<aes::Aes128>;\n\ntype Params<'a> = BTreeMap<Cow<'a, str>, Cow<'a, str>>;\n\npub struct Alias {\n    pub name: Cow<'static, str>,\n    pub id: u32,\n    pub is_group: bool,\n}\n\npub struct Config {\n    pub name: Cow<'static, str>,\n    pub device_type: DeviceType,\n    pub device_id: String,\n    pub is_group: bool,\n    pub client_id: String,\n    pub aliases: Vec<Alias>,\n}\n\nstruct RequestHandler {\n    config: Config,\n    username: Mutex<Option<String>>,\n    keys: DhLocalKeys,\n    event_tx: mpsc::UnboundedSender<DiscoveryEvent>,\n}\n\nimpl RequestHandler {\n    fn new(config: Config, event_tx: mpsc::UnboundedSender<DiscoveryEvent>) -> Self {\n        Self {\n            config,\n            username: Mutex::new(None),\n            keys: DhLocalKeys::random(&mut rand::rng()),\n            event_tx,\n        }\n    }\n\n    fn active_user(&self) -> String {\n        if let Ok(maybe_username) = self.username.lock() {\n            maybe_username.clone().unwrap_or(String::new())\n        } else {\n            warn!(\"username lock corrupted; read failed\");\n            String::from(\"!\")\n        }\n    }\n\n    fn handle_get_info(&self) -> Response<Full<Bytes>> {\n        let public_key = BASE64.encode(self.keys.public_key());\n        let device_type: &str = self.config.device_type.into();\n        let active_user = self.active_user();\n\n        // options based on zeroconf guide, search for `groupStatus` on page\n        let group_status = if self.config.is_group {\n            \"GROUP\"\n        } else {\n            \"NONE\"\n        };\n\n        // See: https://developer.spotify.com/documentation/commercial-hardware/implementation/guides/zeroconf/\n        let body = json!({\n            \"status\": 101,\n            \"statusString\": \"OK\",\n            \"spotifyError\": 0,\n            // departing from the Spotify documentation, Google Cast uses \"5.0.0\"\n            \"version\": \"2.9.0\",\n            \"deviceID\": (self.config.device_id),\n            \"deviceType\": (device_type),\n            \"remoteName\": (self.config.name),\n            // valid value seen in the wild: \"empty\"\n            \"publicKey\": (public_key),\n            \"brandDisplayName\": \"librespot\",\n            \"modelDisplayName\": \"librespot\",\n            \"libraryVersion\": crate::core::version::SEMVER,\n            \"resolverVersion\": \"1\",\n            // valid values are \"GROUP\" and \"NONE\"\n            \"groupStatus\": group_status,\n            // valid value documented & seen in the wild: \"accesstoken\"\n            // Using it will cause clients to fail to connect.\n            \"tokenType\": \"default\",\n            \"clientID\": (self.config.client_id),\n            \"productID\": 0,\n            // Other known scope: client-authorization-universal\n            // Comma-separated.\n            \"scope\": \"streaming\",\n            \"availability\": \"\",\n            \"supported_drm_media_formats\": [],\n            // TODO: bitmask but what are the flags?\n            \"supported_capabilities\": 1,\n            // undocumented but should still work\n            \"accountReq\": \"PREMIUM\",\n            \"activeUser\": active_user,\n            \"aliases\": self.config.aliases.iter().map(|alias| {\n                json!({\n                    \"name\": alias.name,\n                    \"id\": alias.id.to_string(),\n                    \"isGroup\": alias.is_group.to_string(),\n                })\n            }).collect::<Vec<_>>(),\n            // others seen-in-the-wild:\n            // - \"deviceAPI_isGroup\": False\n        })\n        .to_string();\n        let body = Bytes::from(body);\n        Response::new(Full::new(body))\n    }\n\n    fn handle_add_user(&self, params: &Params<'_>) -> Result<Response<Full<Bytes>>, Error> {\n        let username_key = \"userName\";\n        let username = params\n            .get(username_key)\n            .ok_or(DiscoveryError::ParamsError(username_key))?\n            .as_ref();\n\n        let blob_key = \"blob\";\n        let encrypted_blob = params\n            .get(blob_key)\n            .ok_or(DiscoveryError::ParamsError(blob_key))?;\n\n        let clientkey_key = \"clientKey\";\n        let client_key = params\n            .get(clientkey_key)\n            .ok_or(DiscoveryError::ParamsError(clientkey_key))?;\n\n        let encrypted_blob = BASE64.decode(encrypted_blob.as_bytes())?;\n\n        let client_key = BASE64.decode(client_key.as_bytes())?;\n        let shared_key = self.keys.shared_secret(&client_key);\n\n        let encrypted_blob_len = encrypted_blob.len();\n        if encrypted_blob_len < 16 {\n            return Err(DiscoveryError::HmacError(encrypted_blob.to_vec()).into());\n        }\n\n        let iv = &encrypted_blob[0..16];\n        let encrypted = &encrypted_blob[16..encrypted_blob_len - 20];\n        let cksum = &encrypted_blob[encrypted_blob_len - 20..encrypted_blob_len];\n\n        let base_key = Sha1::digest(shared_key);\n        let base_key = &base_key[..16];\n\n        let checksum_key = {\n            let mut h = Hmac::<Sha1>::new_from_slice(base_key)\n                .map_err(|_| DiscoveryError::HmacError(base_key.to_vec()))?;\n            h.update(b\"checksum\");\n            h.finalize().into_bytes()\n        };\n\n        let encryption_key = {\n            let mut h = Hmac::<Sha1>::new_from_slice(base_key)\n                .map_err(|_| DiscoveryError::HmacError(base_key.to_vec()))?;\n            h.update(b\"encryption\");\n            h.finalize().into_bytes()\n        };\n\n        let mut h = Hmac::<Sha1>::new_from_slice(&checksum_key)\n            .map_err(|_| DiscoveryError::HmacError(base_key.to_vec()))?;\n        h.update(encrypted);\n        if h.verify_slice(cksum).is_err() {\n            warn!(\"Login error for user {username:?}: MAC mismatch\");\n            let result = json!({\n                \"status\": 102,\n                \"spotifyError\": 1,\n                \"statusString\": \"ERROR-MAC\"\n            });\n\n            let body = result.to_string();\n            let body = Bytes::from(body);\n            return Ok(Response::new(Full::new(body)));\n        }\n\n        let decrypted = {\n            let mut data = encrypted.to_vec();\n            let mut cipher = Aes128Ctr::new_from_slices(&encryption_key[0..16], iv)\n                .map_err(DiscoveryError::AesError)?;\n            cipher.apply_keystream(&mut data);\n            data\n        };\n\n        let credentials = Credentials::with_blob(username, decrypted, &self.config.device_id)?;\n\n        {\n            let maybe_username = self.username.lock();\n            self.event_tx\n                .send(DiscoveryEvent::Credentials(credentials))?;\n            if let Ok(mut username_field) = maybe_username {\n                *username_field = Some(String::from(username));\n            } else {\n                warn!(\"username lock corrupted; write failed\");\n            }\n        }\n\n        let result = json!({\n            \"status\": 101,\n            \"spotifyError\": 0,\n            \"statusString\": \"OK\",\n        });\n\n        let body = result.to_string();\n        let body = Bytes::from(body);\n        Ok(Response::new(Full::new(body)))\n    }\n\n    fn not_found(&self) -> Response<Full<Bytes>> {\n        let mut res = Response::default();\n        *res.status_mut() = StatusCode::NOT_FOUND;\n        res\n    }\n\n    async fn handle(\n        self: Arc<Self>,\n        request: Request<Incoming>,\n    ) -> Result<hyper::Result<Response<Full<Bytes>>>, Error> {\n        let mut params = Params::new();\n\n        let (parts, body) = request.into_parts();\n\n        if let Some(query) = parts.uri.query() {\n            let query_params = form_urlencoded::parse(query.as_bytes());\n            params.extend(query_params);\n        }\n\n        if parts.method != Method::GET {\n            debug!(\"{:?} {:?} {:?}\", parts.method, parts.uri.path(), params);\n        }\n\n        let body = body.collect().await?.to_bytes();\n\n        params.extend(form_urlencoded::parse(&body));\n\n        let action = params.get(\"action\").map(Cow::as_ref);\n\n        Ok(Ok(match (parts.method, action) {\n            (Method::GET, Some(\"getInfo\")) => self.handle_get_info(),\n            (Method::POST, Some(\"addUser\")) => self.handle_add_user(&params)?,\n            _ => self.not_found(),\n        }))\n    }\n}\n\npub(crate) enum DiscoveryServerCmd {\n    Shutdown,\n}\n\npub struct DiscoveryServer {\n    close_tx: oneshot::Sender<DiscoveryServerCmd>,\n    task_handle: tokio::task::JoinHandle<()>,\n}\n\nimpl DiscoveryServer {\n    pub fn new(\n        config: Config,\n        port: &mut u16,\n        event_tx: mpsc::UnboundedSender<DiscoveryEvent>,\n    ) -> Result<Self, Error> {\n        let discovery = RequestHandler::new(config, event_tx);\n        let address = if cfg!(windows) {\n            SocketAddr::new(Ipv4Addr::UNSPECIFIED.into(), *port)\n        } else {\n            // this creates a dual stack socket on non-windows systems\n            SocketAddr::new(Ipv6Addr::UNSPECIFIED.into(), *port)\n        };\n\n        let (close_tx, close_rx) = oneshot::channel();\n\n        let listener = match TcpListener::bind(address) {\n            Ok(listener) => listener,\n            Err(e) => {\n                warn!(\"Discovery server failed to start: {e}\");\n                return Err(e.into());\n            }\n        };\n\n        listener.set_nonblocking(true)?;\n        let listener = tokio::net::TcpListener::from_std(listener)?;\n\n        match listener.local_addr() {\n            Ok(addr) => {\n                *port = addr.port();\n                debug!(\"Zeroconf server listening on 0.0.0.0:{}\", *port);\n            }\n            Err(e) => {\n                warn!(\"Discovery server failed to start: {e}\");\n                return Err(e.into());\n            }\n        }\n\n        let task_handle = tokio::spawn(async move {\n            let discovery = Arc::new(discovery);\n\n            let server = hyper::server::conn::http1::Builder::new();\n            let graceful = GracefulShutdown::new();\n            let mut close_rx = std::pin::pin!(close_rx);\n            loop {\n                tokio::select! {\n                    Ok((stream, _)) = listener.accept() => {\n                        let io = TokioIo::new(stream);\n                        let discovery = discovery.clone();\n\n                        let svc = hyper::service::service_fn(move |request| {\n                            discovery\n                                .clone()\n                                .handle(request)\n                                .inspect_err(|e| error!(\"could not handle discovery request: {e}\"))\n                                .and_then(|x| async move { Ok(x) })\n                                .map(Result::unwrap) // guaranteed by `and_then` above\n                        });\n\n                        let conn = server.serve_connection(io, svc);\n                        let fut = graceful.watch(conn);\n                        tokio::spawn(async move {\n                            // Errors are logged in the service_fn\n                            let _ = fut.await;\n                        });\n                    }\n                    _ = &mut close_rx => {\n                        break;\n                    }\n                }\n            }\n\n            graceful.shutdown().await;\n        });\n\n        Ok(Self {\n            close_tx,\n            task_handle,\n        })\n    }\n\n    pub async fn shutdown(self) {\n        let Self {\n            close_tx,\n            task_handle,\n            ..\n        } = self;\n        log::debug!(\"Shutting down discovery server\");\n        if close_tx.send(DiscoveryServerCmd::Shutdown).is_err() {\n            log::warn!(\"Discovery server unexpectedly disappeared\");\n        } else {\n            let _ = task_handle.await;\n            log::debug!(\"Discovery server stopped\");\n        }\n    }\n}\n"
  },
  {
    "path": "docs/authentication.md",
    "content": "# Authentication\nOnce the connection is setup, the client can authenticate with the AP. For this, it sends an\n`ClientResponseEncrypted` message, using packet type `0xab`.\n\nA few different authentication methods are available. They are described below.\n\nThe AP will then reply with either a `APWelcome` message using packet type `0xac` if authentication\nis successful, or an `APLoginFailed` with packet type `0xad` otherwise.\n\n## Password based Authentication\nPassword authentication is trivial.\nThe `ClientResponseEncrypted` message's `LoginCredentials` is simply filled with the username\nand setting the password as the `auth_data`, and type `AUTHENTICATION_USER_PASS`.\n\n## Zeroconf based Authentication\nRather than relying on the user entering a username and password, devices can use zeroconf based\nauthentication. This is especially useful for headless Spotify Connect devices.\n\nIn this case, an already authenticated device, a phone or computer for example, discovers Spotify\nConnect receivers on the local network using Zeroconf. The receiver exposes an HTTP server with\nservice type `_spotify-connect._tcp`,\n\nTwo actions on the HTTP server are exposed, `getInfo` and `addUser`.\nThe former returns information about the receiver, including its DH public key, in JSON format.\nThe latter is used to send the username, the controller's DH public key, as well as the encrypted\nblob used to authenticate with Spotify's servers.\n\nThe blob is decrypted using the following algorithm.\n\n```\n# encrypted_blob is the blob sent by the controller, decoded using base64\n# shared_secret is the result of the DH key exchange\n\nIV = encrypted_blob[:0x10]\nexpected_mac = encrypted_blob[-0x14:]\nencrypted = encrypted_blob[0x10:-0x14]\n\nbase_key       = SHA1(shared_secret)\nchecksum_key   = HMAC-SHA1(base_key, \"checksum\")\nencryption_key = HMAC-SHA1(base_key, \"encryption\")[:0x10]\n\nmac = HMAC-SHA1(checksum_key, encrypted)\nassert mac == expected_mac\n\nblob = AES128-CTR-DECRYPT(encryption_key, IV, encrypted)\n```\n\nThe blob is then used as described in the next section.\n\n## Blob based Authentication\n\n```\ndata = b64_decode(blob)\nbase_key = PBKDF2(SHA1(deviceID), username, 0x100, 1)\nkey = SHA1(base_key) || htonl(len(base_key))\nlogin_data = AES192-DECRYPT(key, data)\n```\n\n## Facebook based Authentication\nFacebook authentication is currently broken due to Spotify changing the authentication flow. The details of how the new flow works are detailed in https://github.com/librespot-org/librespot/issues/244 and will be implemented at some point in the future.\n"
  },
  {
    "path": "docs/connection.md",
    "content": "# Connection Setup\n## Access point Connection\nThe first step to connecting to Spotify's servers is finding an Access Point (AP) to do so.\nClients make an HTTP GET request to `http://apresolve.spotify.com` to retrieve a list of hostname an port combination in JSON format.\nAn AP is randomly picked from that list to connect to.\n\nThe connection is done using a bare TCP socket. Despite many APs using ports 80 and 443, neither HTTP nor TLS are used to connect.\n\nIf `http://apresolve.spotify.com` is unresponsive, `ap.spotify.com:443` is used as a fallback.\n\n## Connection Hello\nThe first 3 packets exchanged are unencrypted, and have the following format :\n\nheader   | length | payload\n---------|--------|---------\nvariable |   32   | variable\n\nLength is a 32 bit, big endian encoded, integer.\nIt is the length of the entire packet, ie `len(header) + 4 + len(payload)`.\n\nThe header is only present in the very first packet sent by the client, and is two bytes long, `[0, 4]`.\nIt probably corresponds to the protocol version used.\n\nThe payload is a protobuf encoded message.\n\nThe client starts by sending a `ClientHello` message, describing the client info, a random nonce and client's Diffie Hellman public key.\n\nThe AP replies by a `APResponseMessage` message, containing a random nonce and the server's DH key.\n\nThe client solves a challenge based on these two packets, and sends it back using a `ClientResponsePlaintext`.\nIt also computes the shared keys used to encrypt the rest of the communication.\n\n## Login challenge and cipher key computation.\nThe client starts by computing the DH shared secret using its private key and the server's public key.\nHMAC-SHA1 is then used to compute the send and receive keys, as well as the login challenge.\n\n```\ndata = []\nfor i in 1..6 {\n    data += HMAC(client_hello || ap_response || [ i ], shared)\n}\n\nchallenge = HMAC(client_hello || ap_response, data[:20])\nsend_key = data[20:52]\nrecv_key = data[52:84]\n```\n\n`client_hello` and `ap_response` are the first packets sent respectively by the client and the AP.\nThese include the header and length fields.\n\n## Encrypted packets\nEvery packet after ClientResponsePlaintext is encrypted using a Shannon cipher.\n\nThe cipher is setup with 4 bytes big endian nonce, incremented after each packet, starting at zero.\nTwo independent ciphers and accompanying nonces are used, one for transmission and one for reception,\nusing respectively `send_key` and `recv_key` as keys.\n\nThe packet format is as followed :\n\ncmd | length | payload  | mac\n----|--------|----------|----\n 8  |   16   | variable | 32\n\nEach packet has a type identified by the 8 bit `cmd` field.\nThe 16 bit big endian length only includes the length of the payload.\n\n"
  },
  {
    "path": "docs/dealer.md",
    "content": "# Dealer\n\nWhen talking about the dealer, we are speaking about a websocket that represents the player as\nspotify-connect device. The dealer is primarily used to receive updates and not to update the\nstate.\n\n## Messages and Requests\n\nThere are two types of messages that are received via the dealer, Messages and Requests.\nMessages are fire-and-forget and don't need a responses, while request expect a reply if the\nrequest was processed successfully or failed.\n\nBecause we publish our device with support for gzip, the message payload might be BASE64 encoded\nand gzip compressed. If that is the case, the related headers send an entry for \"Transfer-Encoding\"\nwith the value of \"gzip\".\n\n### Messages\n\nMost messages librespot handles send bytes that can be easily converted into their respective\nprotobuf definition. Some outliers send json that can be usually mapped to an existing protobuf\ndefinition. We use `protobuf-json-mapping` to a similar protobuf definition\n\n> Note: The json sometimes doesn't map exactly and can provide more fields than the protobuf\n> definition expects. For messages, we usually ignore unknown fields.\n\nThere are two types of messages, \"informational\" and \"fire and forget commands\".\n\n**Informational:**\n\nInformational messages send any changes done by the current user or of a client where the current user\nis logged in. These messages contain for example changes to a own playlist, additions to the liked songs\nor any update that a client sends.\n\n**Fire and Forget commands:**\n\nThese are messages that send information that are requests to the current player. These are only send to\nthe active player. Volume update requests and the logout request are send as fire-forget-commands.\n\n### Requests\n\nThe request payload is sent as json. There are almost usable protobuf definitions (see\nfiles named like `es_<command in snakecase>(_request).proto`) for the commands, but they don't\nalign up with the expected values and are missing some major information we need for handling some\ncommands. Because of that we have our own model for the specific commands, see\n[core/src/dealer/protocol/request.rs](../core/src/dealer/protocol/request.rs).\n\nAll request modify the player-state.\n\n## Details\n\nThis sections is for details and special hiccups in regards to handling that isn't completely intuitive.\n\n### UIDs\n\nA spotify item is identifiable by their uri. The `ContextTrack` and `ProvidedTrack` both have a `uid` \nfield. When we receive a context via the `context-resolver` it can return items (`ContextTrack`) that\nmay have their respective uid set. Some context like the collection and albums don't provide this \ninformation.\n\nWhen a `uid` is missing, resorting the next tracks in an official client gets confused and sends \nincorrect data via the `set_queue` request. To prevent this behavior we generate a uid for each \ntrack that doesn't have an uid. Queue items become a \"queue-uid\" which is just a `q` with an \nincrementing number.\n\n### Metadata\n\nFor some client's (especially mobile) the metadata of a track is very important to display the \ncontext correct. For example the \"autoplay\" metadata is relevant to display the correct context \ninfo.\n\nMetadata can also be used to store data like the iteration when repeating a context.\n\n### Repeat\n\nThe context repeating implementation is partly mimicked from the official client. The official \nclient allows skipping into negative iterations, this is currently not supported.\n\nRepeating is realized by filling the next tracks with multiple contexts separated by delimiters.\nBy that we only have to handle the delimiter when skipping to the next and previous track.\n"
  },
  {
    "path": "examples/README.md",
    "content": "# Examples\n\nThis folder contains examples of how to use the `librespot` library for various purposes.\n\n## How to run the examples\n\nIn general, to invoke an example, clone down the repo and use `cargo` as follows:\n\n```\ncargo run --example [filename]\n```\n\nin which `filename` is the file name of the example, for instance `get_token` or `play`.\n\n### Acquiring an access token\n\nMost examples require an access token as the first positional argument. **Note that an access token\ngained by the client credentials flow will not work**. `librespot-oauth` provides a utility to \nacquire an access token using an OAuth flow, which will be able to run the examples. To invoke this, \nrun:\n\n```\ncargo run --package librespot-oauth --example oauth_sync\n```\n\nA browser window will open and prompt you to authorize with Spotify. Once done, take the \n`access_token` property from the dumped object response and proceed to use it in examples. You may\nfind it convenient to save it in a shell variable like `$ACCESS_TOKEN`.\n\nOnce you have obtained the token you can proceed to run the example. Check each individual\nfile to see what arguments are expected. As a demonstration, here is how to invoke the `play` \nexample to play a song -- the second argument is the URI of the track to play.\n\n```\ncargo run --example play \"$ACCESS_TOKEN\" 2WUy2Uywcj5cP0IXQagO3z\n```"
  },
  {
    "path": "examples/get_token.rs",
    "content": "use std::env;\n\nuse librespot::core::{authentication::Credentials, config::SessionConfig, session::Session};\n\nconst SCOPES: &str =\n    \"streaming,user-read-playback-state,user-modify-playback-state,user-read-currently-playing\";\n\n#[tokio::main]\nasync fn main() {\n    let mut builder = env_logger::Builder::new();\n    builder.parse_filters(\"librespot=trace\");\n    builder.init();\n\n    let mut session_config = SessionConfig::default();\n\n    let args: Vec<_> = env::args().collect();\n    if args.len() == 3 {\n        // Only special client IDs have sufficient privileges e.g. Spotify's.\n        session_config.client_id = args[2].clone()\n    } else if args.len() != 2 {\n        eprintln!(\"Usage: {} ACCESS_TOKEN [CLIENT_ID]\", args[0]);\n        return;\n    }\n    let access_token = &args[1];\n\n    // Now create a new session with that token.\n    let session = Session::new(session_config.clone(), None);\n    let credentials = Credentials::with_access_token(access_token);\n    println!(\"Connecting with token..\");\n    match session.connect(credentials, false).await {\n        Ok(()) => println!(\"Session username: {:#?}\", session.username()),\n        Err(e) => {\n            println!(\"Error connecting: {e}\");\n            return;\n        }\n    };\n\n    let token = session.token_provider().get_token(SCOPES).await.unwrap();\n    println!(\"Got me a token: {token:#?}\");\n}\n"
  },
  {
    "path": "examples/play.rs",
    "content": "use std::{env, process::exit};\n\nuse librespot::{\n    core::{\n        SpotifyUri, authentication::Credentials, config::SessionConfig, session::Session,\n        spotify_id::SpotifyId,\n    },\n    playback::{\n        audio_backend,\n        config::{AudioFormat, PlayerConfig},\n        mixer::NoOpVolume,\n        player::Player,\n    },\n};\n\n#[tokio::main]\nasync fn main() {\n    let session_config = SessionConfig::default();\n    let player_config = PlayerConfig::default();\n    let audio_format = AudioFormat::default();\n\n    let args: Vec<_> = env::args().collect();\n    if args.len() != 3 {\n        eprintln!(\"Usage: {} ACCESS_TOKEN TRACK\", args[0]);\n        return;\n    }\n    let credentials = Credentials::with_access_token(&args[1]);\n\n    let track = SpotifyUri::Track {\n        id: SpotifyId::from_base62(&args[2]).unwrap(),\n    };\n\n    let backend = audio_backend::find(None).unwrap();\n\n    println!(\"Connecting...\");\n    let session = Session::new(session_config, None);\n    if let Err(e) = session.connect(credentials, false).await {\n        println!(\"Error connecting: {e}\");\n        exit(1);\n    }\n\n    let player = Player::new(player_config, session, Box::new(NoOpVolume), move || {\n        backend(None, audio_format)\n    });\n\n    player.load(track, true, 0);\n\n    println!(\"Playing...\");\n\n    player.await_end_of_track().await;\n\n    println!(\"Done\");\n}\n"
  },
  {
    "path": "examples/play_connect.rs",
    "content": "use librespot::{\n    connect::{ConnectConfig, LoadRequest, LoadRequestOptions, Spirc},\n    core::{\n        Error, authentication::Credentials, cache::Cache, config::SessionConfig, session::Session,\n    },\n    playback::mixer::MixerConfig,\n    playback::{\n        audio_backend,\n        config::{AudioFormat, PlayerConfig},\n        mixer,\n        player::Player,\n    },\n};\n\nuse log::LevelFilter;\n\nconst CACHE: &str = \".cache\";\nconst CACHE_FILES: &str = \".cache/files\";\n\n#[tokio::main]\nasync fn main() -> Result<(), Error> {\n    env_logger::builder()\n        .filter_module(\"librespot\", LevelFilter::Debug)\n        .init();\n\n    let session_config = SessionConfig::default();\n    let player_config = PlayerConfig::default();\n    let audio_format = AudioFormat::default();\n    let connect_config = ConnectConfig::default();\n    let mixer_config = MixerConfig::default();\n    let request_options = LoadRequestOptions::default();\n\n    let sink_builder = audio_backend::find(None).unwrap();\n    let mixer_builder = mixer::find(None).unwrap();\n\n    let cache = Cache::new(Some(CACHE), Some(CACHE), Some(CACHE_FILES), None)?;\n    let credentials = cache\n        .credentials()\n        .ok_or(Error::unavailable(\"credentials not cached\"))\n        .or_else(|_| {\n            librespot_oauth::OAuthClientBuilder::new(\n                &session_config.client_id,\n                \"http://127.0.0.1:8898/login\",\n                vec![\"streaming\"],\n            )\n            .open_in_browser()\n            .build()?\n            .get_access_token()\n            .map(|t| Credentials::with_access_token(t.access_token))\n        })?;\n\n    let session = Session::new(session_config, Some(cache));\n    let mixer = mixer_builder(mixer_config)?;\n\n    let player = Player::new(\n        player_config,\n        session.clone(),\n        mixer.get_soft_volume(),\n        move || sink_builder(None, audio_format),\n    );\n\n    let (spirc, spirc_task) =\n        Spirc::new(connect_config, session.clone(), credentials, player, mixer).await?;\n\n    // these calls can be seen as \"queued\"\n    spirc.activate()?;\n    spirc.load(LoadRequest::from_context_uri(\n        format!(\"spotify:user:{}:collection\", session.username()),\n        request_options,\n    ))?;\n    spirc.play()?;\n\n    // starting the connect device and processing the previously \"queued\" calls\n    spirc_task.await;\n\n    Ok(())\n}\n"
  },
  {
    "path": "examples/playlist_tracks.rs",
    "content": "use std::{env, process::exit};\n\nuse librespot::{\n    core::{\n        authentication::Credentials, config::SessionConfig, session::Session,\n        spotify_uri::SpotifyUri,\n    },\n    metadata::{Metadata, Playlist, Track},\n};\n\n#[tokio::main]\nasync fn main() {\n    env_logger::init();\n    let session_config = SessionConfig::default();\n\n    let args: Vec<_> = env::args().collect();\n    if args.len() != 3 {\n        eprintln!(\"Usage: {} ACCESS_TOKEN PLAYLIST\", args[0]);\n        return;\n    }\n    let credentials = Credentials::with_access_token(&args[1]);\n\n    let plist_uri = SpotifyUri::from_uri(&args[2]).unwrap_or_else(|_| {\n        eprintln!(\n            \"PLAYLIST should be a playlist URI such as: \\\n                \\\"spotify:playlist:37i9dQZF1DXec50AjHrNTq\\\"\"\n        );\n        exit(1);\n    });\n\n    let session = Session::new(session_config, None);\n    if let Err(e) = session.connect(credentials, false).await {\n        println!(\"Error connecting: {e}\");\n        exit(1);\n    }\n\n    let plist = Playlist::get(&session, &plist_uri).await.unwrap();\n    println!(\"{plist:?}\");\n    for track_id in plist.tracks() {\n        let plist_track = Track::get(&session, track_id).await.unwrap();\n        println!(\"track: {} \", plist_track.name);\n    }\n}\n"
  },
  {
    "path": "metadata/Cargo.toml",
    "content": "[package]\nname = \"librespot-metadata\"\nversion = \"0.8.0\"\nrust-version.workspace = true\nauthors = [\"Paul Lietar <paul@lietar.net>\"]\nlicense.workspace = true\ndescription = \"The metadata logic for librespot\"\nrepository.workspace = true\nedition.workspace = true\n\n[features]\n# Refer to the workspace Cargo.toml for the list of features\ndefault = [\"native-tls\"]\n\n# TLS backend propagation\nnative-tls = [\"librespot-core/native-tls\"]\nrustls-tls-native-roots = [\"librespot-core/rustls-tls-native-roots\"]\nrustls-tls-webpki-roots = [\"librespot-core/rustls-tls-webpki-roots\"]\n\n[dependencies]\nlibrespot-core = { version = \"0.8.0\", path = \"../core\", default-features = false }\nlibrespot-protocol = { version = \"0.8.0\", path = \"../protocol\", default-features = false }\n\nasync-trait = \"0.1\"\nbytes = \"1\"\nlog = \"0.4\"\nprotobuf = \"3.7\"\nserde = { version = \"1.0\", features = [\"derive\"] }\nserde_json = \"1.0\"\nthiserror = \"2\"\nuuid = { version = \"1\", default-features = false }\n\n[lints]\nworkspace = true\n"
  },
  {
    "path": "metadata/src/album.rs",
    "content": "use std::{\n    fmt::Debug,\n    ops::{Deref, DerefMut},\n};\n\nuse crate::{\n    Metadata,\n    artist::Artists,\n    availability::Availabilities,\n    copyright::Copyrights,\n    external_id::ExternalIds,\n    image::Images,\n    request::RequestResult,\n    restriction::Restrictions,\n    sale_period::SalePeriods,\n    track::Tracks,\n    util::{impl_deref_wrapped, impl_try_from_repeated},\n};\n\nuse librespot_core::{Error, Session, SpotifyUri, date::Date};\n\nuse librespot_protocol as protocol;\nuse protocol::metadata::Disc as DiscMessage;\npub use protocol::metadata::album::Type as AlbumType;\n\n#[derive(Debug, Clone)]\npub struct Album {\n    pub id: SpotifyUri,\n    pub name: String,\n    pub artists: Artists,\n    pub album_type: AlbumType,\n    pub label: String,\n    pub date: Date,\n    pub popularity: i32,\n    pub covers: Images,\n    pub external_ids: ExternalIds,\n    pub discs: Discs,\n    pub reviews: Vec<String>,\n    pub copyrights: Copyrights,\n    pub restrictions: Restrictions,\n    pub related: Albums,\n    pub sale_periods: SalePeriods,\n    pub cover_group: Images,\n    pub original_title: String,\n    pub version_title: String,\n    pub type_str: String,\n    pub availability: Availabilities,\n}\n\n#[derive(Debug, Clone, Default)]\npub struct Albums(pub Vec<SpotifyUri>);\n\nimpl_deref_wrapped!(Albums, Vec<SpotifyUri>);\n\n#[derive(Debug, Clone)]\npub struct Disc {\n    pub number: i32,\n    pub name: String,\n    pub tracks: Tracks,\n}\n\n#[derive(Debug, Clone, Default)]\npub struct Discs(pub Vec<Disc>);\n\nimpl_deref_wrapped!(Discs, Vec<Disc>);\n\nimpl Album {\n    pub fn tracks(&self) -> impl Iterator<Item = &SpotifyUri> {\n        self.discs.iter().flat_map(|disc| disc.tracks.iter())\n    }\n}\n\n#[async_trait]\nimpl Metadata for Album {\n    type Message = protocol::metadata::Album;\n\n    async fn request(session: &Session, album_uri: &SpotifyUri) -> RequestResult {\n        let SpotifyUri::Album { .. } = album_uri else {\n            return Err(Error::invalid_argument(\"album_uri\"));\n        };\n\n        session.spclient().get_album_metadata(album_uri).await\n    }\n\n    fn parse(msg: &Self::Message, _: &SpotifyUri) -> Result<Self, Error> {\n        Self::try_from(msg)\n    }\n}\n\nimpl TryFrom<&<Self as Metadata>::Message> for Album {\n    type Error = librespot_core::Error;\n    fn try_from(album: &<Self as Metadata>::Message) -> Result<Self, Self::Error> {\n        Ok(Self {\n            id: album.try_into()?,\n            name: album.name().to_owned(),\n            artists: album.artist.as_slice().try_into()?,\n            album_type: album.type_(),\n            label: album.label().to_owned(),\n            date: album.date.get_or_default().try_into()?,\n            popularity: album.popularity(),\n            covers: album.cover_group.get_or_default().into(),\n            external_ids: album.external_id.as_slice().into(),\n            discs: album.disc.as_slice().try_into()?,\n            reviews: album.review.to_vec(),\n            copyrights: album.copyright.as_slice().into(),\n            restrictions: album.restriction.as_slice().into(),\n            related: album.related.as_slice().try_into()?,\n            sale_periods: album.sale_period.as_slice().try_into()?,\n            cover_group: album.cover_group.image.as_slice().into(),\n            original_title: album.original_title().to_owned(),\n            version_title: album.version_title().to_owned(),\n            type_str: album.type_str().to_owned(),\n            availability: album.availability.as_slice().try_into()?,\n        })\n    }\n}\n\nimpl_try_from_repeated!(<Album as Metadata>::Message, Albums);\n\nimpl TryFrom<&DiscMessage> for Disc {\n    type Error = librespot_core::Error;\n    fn try_from(disc: &DiscMessage) -> Result<Self, Self::Error> {\n        Ok(Self {\n            number: disc.number(),\n            name: disc.name().to_owned(),\n            tracks: disc.track.as_slice().try_into()?,\n        })\n    }\n}\n\nimpl_try_from_repeated!(DiscMessage, Discs);\n"
  },
  {
    "path": "metadata/src/artist.rs",
    "content": "use std::{\n    fmt::Debug,\n    ops::{Deref, DerefMut},\n};\n\nuse crate::{\n    Metadata,\n    album::Albums,\n    availability::Availabilities,\n    external_id::ExternalIds,\n    image::Images,\n    request::RequestResult,\n    restriction::Restrictions,\n    sale_period::SalePeriods,\n    track::Tracks,\n    util::{impl_deref_wrapped, impl_from_repeated, impl_try_from_repeated},\n};\n\nuse librespot_core::{Error, Session, SpotifyUri};\n\nuse librespot_protocol as protocol;\npub use protocol::metadata::artist_with_role::ArtistRole;\n\nuse protocol::metadata::ActivityPeriod as ActivityPeriodMessage;\nuse protocol::metadata::AlbumGroup as AlbumGroupMessage;\nuse protocol::metadata::ArtistWithRole as ArtistWithRoleMessage;\nuse protocol::metadata::Biography as BiographyMessage;\nuse protocol::metadata::TopTracks as TopTracksMessage;\n\n#[derive(Debug, Clone)]\npub struct Artist {\n    pub id: SpotifyUri,\n    pub name: String,\n    pub popularity: i32,\n    pub top_tracks: CountryTopTracks,\n    pub albums: AlbumGroups,\n    pub singles: AlbumGroups,\n    pub compilations: AlbumGroups,\n    pub appears_on_albums: AlbumGroups,\n    pub external_ids: ExternalIds,\n    pub portraits: Images,\n    pub biographies: Biographies,\n    pub activity_periods: ActivityPeriods,\n    pub restrictions: Restrictions,\n    pub related: Artists,\n    pub is_portrait_album_cover: bool,\n    pub portrait_group: Images,\n    pub sales_periods: SalePeriods,\n    pub availabilities: Availabilities,\n}\n\n#[derive(Debug, Clone, Default)]\npub struct Artists(pub Vec<Artist>);\n\nimpl_deref_wrapped!(Artists, Vec<Artist>);\n\n#[derive(Debug, Clone)]\npub struct ArtistWithRole {\n    pub id: SpotifyUri,\n    pub name: String,\n    pub role: ArtistRole,\n}\n\n#[derive(Debug, Clone, Default)]\npub struct ArtistsWithRole(pub Vec<ArtistWithRole>);\n\nimpl_deref_wrapped!(ArtistsWithRole, Vec<ArtistWithRole>);\n\n#[derive(Debug, Clone)]\npub struct TopTracks {\n    pub country: String,\n    pub tracks: Tracks,\n}\n\n#[derive(Debug, Clone, Default)]\npub struct CountryTopTracks(pub Vec<TopTracks>);\n\nimpl_deref_wrapped!(CountryTopTracks, Vec<TopTracks>);\n\n#[derive(Debug, Clone, Default)]\npub struct AlbumGroup(pub Albums);\n\nimpl_deref_wrapped!(AlbumGroup, Albums);\n\n/// `AlbumGroups` contains collections of album variants (different releases of the same album).\n/// Ignoring the wrapping types it is structured roughly like this:\n/// ```text\n/// AlbumGroups [\n///     [Album1], [Album2-relelease, Album2-older-release], [Album3]\n/// ]\n/// ```\n/// In most cases only the current variant of each album is needed. A list of every album in its\n/// current release variant can be obtained by using [`AlbumGroups::current_releases`]\n#[derive(Debug, Clone, Default)]\npub struct AlbumGroups(pub Vec<AlbumGroup>);\n\nimpl_deref_wrapped!(AlbumGroups, Vec<AlbumGroup>);\n\n#[derive(Debug, Clone)]\npub struct Biography {\n    pub text: String,\n    pub portraits: Images,\n    pub portrait_group: Vec<Images>,\n}\n\n#[derive(Debug, Clone, Default)]\npub struct Biographies(pub Vec<Biography>);\n\nimpl_deref_wrapped!(Biographies, Vec<Biography>);\n\n#[derive(Debug, Clone)]\npub enum ActivityPeriod {\n    Timespan {\n        start_year: u16,\n        end_year: Option<u16>,\n    },\n    Decade(u16),\n}\n\n#[derive(Debug, Clone, Default)]\npub struct ActivityPeriods(pub Vec<ActivityPeriod>);\n\nimpl_deref_wrapped!(ActivityPeriods, Vec<ActivityPeriod>);\n\nimpl CountryTopTracks {\n    pub fn for_country(&self, country: &str) -> Tracks {\n        if let Some(country) = self.0.iter().find(|top_track| top_track.country == country) {\n            return country.tracks.clone();\n        }\n\n        if let Some(global) = self.0.iter().find(|top_track| top_track.country.is_empty()) {\n            return global.tracks.clone();\n        }\n\n        Tracks(vec![]) // none found\n    }\n}\n\nimpl Artist {\n    /// Get the full list of albums, not containing duplicate variants of the same albums.\n    ///\n    /// See also [`AlbumGroups`](struct@AlbumGroups) and [`AlbumGroups::current_releases`]\n    pub fn albums_current(&self) -> impl Iterator<Item = &SpotifyUri> {\n        self.albums.current_releases()\n    }\n\n    /// Get the full list of singles, not containing duplicate variants of the same singles.\n    ///\n    /// See also [`AlbumGroups`](struct@AlbumGroups) and [`AlbumGroups::current_releases`]\n    pub fn singles_current(&self) -> impl Iterator<Item = &SpotifyUri> {\n        self.singles.current_releases()\n    }\n\n    /// Get the full list of compilations, not containing duplicate variants of the same\n    /// compilations.\n    ///\n    /// See also [`AlbumGroups`](struct@AlbumGroups) and [`AlbumGroups::current_releases`]\n    pub fn compilations_current(&self) -> impl Iterator<Item = &SpotifyUri> {\n        self.compilations.current_releases()\n    }\n\n    /// Get the full list of albums, not containing duplicate variants of the same albums.\n    ///\n    /// See also [`AlbumGroups`](struct@AlbumGroups) and [`AlbumGroups::current_releases`]\n    pub fn appears_on_albums_current(&self) -> impl Iterator<Item = &SpotifyUri> {\n        self.appears_on_albums.current_releases()\n    }\n}\n\n#[async_trait]\nimpl Metadata for Artist {\n    type Message = protocol::metadata::Artist;\n\n    async fn request(session: &Session, artist_uri: &SpotifyUri) -> RequestResult {\n        let SpotifyUri::Artist { .. } = artist_uri else {\n            return Err(Error::invalid_argument(\"artist_uri\"));\n        };\n\n        session.spclient().get_artist_metadata(artist_uri).await\n    }\n\n    fn parse(msg: &Self::Message, _: &SpotifyUri) -> Result<Self, Error> {\n        Self::try_from(msg)\n    }\n}\n\nimpl TryFrom<&<Self as Metadata>::Message> for Artist {\n    type Error = librespot_core::Error;\n    fn try_from(artist: &<Self as Metadata>::Message) -> Result<Self, Self::Error> {\n        Ok(Self {\n            id: artist.try_into()?,\n            name: artist.name().to_owned(),\n            popularity: artist.popularity(),\n            top_tracks: artist.top_track.as_slice().try_into()?,\n            albums: artist.album_group.as_slice().try_into()?,\n            singles: artist.single_group.as_slice().try_into()?,\n            compilations: artist.compilation_group.as_slice().try_into()?,\n            appears_on_albums: artist.appears_on_group.as_slice().try_into()?,\n            external_ids: artist.external_id.as_slice().into(),\n            portraits: artist.portrait.as_slice().into(),\n            biographies: artist.biography.as_slice().into(),\n            activity_periods: artist.activity_period.as_slice().try_into()?,\n            restrictions: artist.restriction.as_slice().into(),\n            related: artist.related.as_slice().try_into()?,\n            is_portrait_album_cover: artist.is_portrait_album_cover(),\n            portrait_group: artist\n                .portrait_group\n                .get_or_default()\n                .image\n                .as_slice()\n                .into(),\n            sales_periods: artist.sale_period.as_slice().try_into()?,\n            availabilities: artist.availability.as_slice().try_into()?,\n        })\n    }\n}\n\nimpl_try_from_repeated!(<Artist as Metadata>::Message, Artists);\n\nimpl TryFrom<&ArtistWithRoleMessage> for ArtistWithRole {\n    type Error = librespot_core::Error;\n    fn try_from(artist_with_role: &ArtistWithRoleMessage) -> Result<Self, Self::Error> {\n        Ok(Self {\n            id: artist_with_role.try_into()?,\n            name: artist_with_role.artist_name().to_owned(),\n            role: artist_with_role.role(),\n        })\n    }\n}\n\nimpl_try_from_repeated!(ArtistWithRoleMessage, ArtistsWithRole);\n\nimpl TryFrom<&TopTracksMessage> for TopTracks {\n    type Error = librespot_core::Error;\n    fn try_from(top_tracks: &TopTracksMessage) -> Result<Self, Self::Error> {\n        Ok(Self {\n            country: top_tracks.country().to_owned(),\n            tracks: top_tracks.track.as_slice().try_into()?,\n        })\n    }\n}\n\nimpl_try_from_repeated!(TopTracksMessage, CountryTopTracks);\n\nimpl TryFrom<&AlbumGroupMessage> for AlbumGroup {\n    type Error = librespot_core::Error;\n    fn try_from(album_groups: &AlbumGroupMessage) -> Result<Self, Self::Error> {\n        Ok(Self(album_groups.album.as_slice().try_into()?))\n    }\n}\n\nimpl AlbumGroups {\n    /// Get the contained albums. This will only use the latest release / variant of an album if\n    /// multiple variants are available. This should be used if multiple variants of the same album\n    /// are not explicitely desired.\n    pub fn current_releases(&self) -> impl Iterator<Item = &SpotifyUri> {\n        self.iter().filter_map(|agrp| agrp.first())\n    }\n}\n\nimpl_try_from_repeated!(AlbumGroupMessage, AlbumGroups);\n\nimpl From<&BiographyMessage> for Biography {\n    fn from(biography: &BiographyMessage) -> Self {\n        let portrait_group = biography\n            .portrait_group\n            .iter()\n            .map(|it| it.image.as_slice().into())\n            .collect();\n\n        Self {\n            text: biography.text().to_owned(),\n            portraits: biography.portrait.as_slice().into(),\n            portrait_group,\n        }\n    }\n}\n\nimpl_from_repeated!(BiographyMessage, Biographies);\n\nimpl TryFrom<&ActivityPeriodMessage> for ActivityPeriod {\n    type Error = librespot_core::Error;\n\n    fn try_from(period: &ActivityPeriodMessage) -> Result<Self, Self::Error> {\n        let activity_period = match (\n            period.has_decade(),\n            period.has_start_year(),\n            period.has_end_year(),\n        ) {\n            // (decade, start_year, end_year)\n            (true, false, false) => Self::Decade(period.decade().try_into()?),\n            (false, true, closed_period) => Self::Timespan {\n                start_year: period.start_year().try_into()?,\n                end_year: closed_period\n                    .then(|| period.end_year().try_into())\n                    .transpose()?,\n            },\n            _ => {\n                return Err(librespot_core::Error::failed_precondition(\n                    \"ActivityPeriod is expected to be either a decade or timespan\",\n                ));\n            }\n        };\n        Ok(activity_period)\n    }\n}\n\nimpl_try_from_repeated!(ActivityPeriodMessage, ActivityPeriods);\n"
  },
  {
    "path": "metadata/src/audio/file.rs",
    "content": "use std::{\n    collections::HashMap,\n    fmt::Debug,\n    ops::{Deref, DerefMut},\n};\n\nuse librespot_core::FileId;\n\nuse crate::util::impl_deref_wrapped;\nuse librespot_protocol as protocol;\nuse protocol::metadata::AudioFile as AudioFileMessage;\n\nuse librespot_protocol::metadata::audio_file::Format;\nuse protobuf::Enum;\n\n#[allow(non_camel_case_types)]\n#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]\npub enum AudioFileFormat {\n    OGG_VORBIS_96,   // 0\n    OGG_VORBIS_160,  // 1\n    OGG_VORBIS_320,  // 2\n    MP3_256,         // 3\n    MP3_320,         // 4\n    MP3_160,         // 5\n    MP3_96,          // 6\n    MP3_160_ENC,     // 7\n    AAC_24,          // 8\n    AAC_48,          // 9\n    FLAC_FLAC,       // 16\n    XHE_AAC_24,      // 18\n    XHE_AAC_16,      // 19\n    XHE_AAC_12,      // 20\n    FLAC_FLAC_24BIT, // 22\n    // not defined in protobuf, but sometimes send\n    AAC_160, // 10\n    AAC_320, // 11\n    MP4_128, // 12\n    OTHER5,  // 13\n}\n\nimpl TryFrom<i32> for AudioFileFormat {\n    type Error = i32;\n\n    fn try_from(value: i32) -> Result<Self, Self::Error> {\n        Ok(match value {\n            10 => AudioFileFormat::AAC_160,\n            11 => AudioFileFormat::AAC_320,\n            12 => AudioFileFormat::MP4_128,\n            13 => AudioFileFormat::OTHER5,\n            _ => Format::from_i32(value).ok_or(value)?.into(),\n        })\n    }\n}\n\nimpl From<Format> for AudioFileFormat {\n    fn from(value: Format) -> Self {\n        match value {\n            Format::OGG_VORBIS_96 => AudioFileFormat::OGG_VORBIS_96,\n            Format::OGG_VORBIS_160 => AudioFileFormat::OGG_VORBIS_160,\n            Format::OGG_VORBIS_320 => AudioFileFormat::OGG_VORBIS_320,\n            Format::MP3_256 => AudioFileFormat::MP3_256,\n            Format::MP3_320 => AudioFileFormat::MP3_320,\n            Format::MP3_160 => AudioFileFormat::MP3_160,\n            Format::MP3_96 => AudioFileFormat::MP3_96,\n            Format::MP3_160_ENC => AudioFileFormat::MP3_160_ENC,\n            Format::AAC_24 => AudioFileFormat::AAC_24,\n            Format::AAC_48 => AudioFileFormat::AAC_48,\n            Format::FLAC_FLAC => AudioFileFormat::FLAC_FLAC,\n            Format::XHE_AAC_24 => AudioFileFormat::XHE_AAC_24,\n            Format::XHE_AAC_16 => AudioFileFormat::XHE_AAC_16,\n            Format::XHE_AAC_12 => AudioFileFormat::XHE_AAC_12,\n            Format::FLAC_FLAC_24BIT => AudioFileFormat::FLAC_FLAC_24BIT,\n        }\n    }\n}\n\n#[derive(Debug, Clone, Default)]\npub struct AudioFiles(pub HashMap<AudioFileFormat, FileId>);\n\nimpl_deref_wrapped!(AudioFiles, HashMap<AudioFileFormat, FileId>);\n\nimpl AudioFiles {\n    pub fn is_ogg_vorbis(format: AudioFileFormat) -> bool {\n        matches!(\n            format,\n            AudioFileFormat::OGG_VORBIS_320\n                | AudioFileFormat::OGG_VORBIS_160\n                | AudioFileFormat::OGG_VORBIS_96\n        )\n    }\n\n    pub fn is_mp3(format: AudioFileFormat) -> bool {\n        matches!(\n            format,\n            AudioFileFormat::MP3_320\n                | AudioFileFormat::MP3_256\n                | AudioFileFormat::MP3_160\n                | AudioFileFormat::MP3_96\n                | AudioFileFormat::MP3_160_ENC\n        )\n    }\n\n    pub fn is_flac(format: AudioFileFormat) -> bool {\n        matches!(format, AudioFileFormat::FLAC_FLAC)\n    }\n\n    pub fn mime_type(format: AudioFileFormat) -> Option<&'static str> {\n        if Self::is_ogg_vorbis(format) {\n            Some(\"audio/ogg\")\n        } else if Self::is_mp3(format) {\n            Some(\"audio/mpeg\")\n        } else if Self::is_flac(format) {\n            Some(\"audio/flac\")\n        } else {\n            None\n        }\n    }\n}\n\nimpl From<&[AudioFileMessage]> for AudioFiles {\n    fn from(files: &[AudioFileMessage]) -> Self {\n        let audio_files: HashMap<AudioFileFormat, FileId> = files\n            .iter()\n            .filter_map(|file| {\n                let file_id = FileId::from(file.file_id());\n                let format = file\n                    .format\n                    .ok_or(format!(\"Ignoring file <{file_id}> with unspecified format\",))\n                    .and_then(|format| match format.enum_value() {\n                        Ok(f) => Ok((f.into(), file_id)),\n                        Err(unknown) => Err(format!(\n                            \"Ignoring file <{file_id}> with unknown format {unknown}\",\n                        )),\n                    });\n\n                if let Err(ref why) = format {\n                    trace!(\"{why}\");\n                }\n\n                format.ok()\n            })\n            .collect();\n\n        AudioFiles(audio_files)\n    }\n}\n"
  },
  {
    "path": "metadata/src/audio/item.rs",
    "content": "use std::{fmt::Debug, path::PathBuf};\n\nuse crate::{\n    Metadata,\n    artist::ArtistsWithRole,\n    availability::{AudioItemAvailability, Availabilities, UnavailabilityReason},\n    episode::Episode,\n    error::MetadataError,\n    image::{ImageSize, Images},\n    restriction::Restrictions,\n    track::{Track, Tracks},\n};\n\nuse super::file::AudioFiles;\n\nuse librespot_core::{Error, Session, SpotifyUri, date::Date, session::UserData};\n\npub type AudioItemResult = Result<AudioItem, Error>;\n\n#[derive(Debug, Clone)]\npub struct CoverImage {\n    pub url: String,\n    pub size: ImageSize,\n    pub width: i32,\n    pub height: i32,\n}\n\n#[derive(Debug, Clone)]\npub struct AudioItem {\n    pub track_id: SpotifyUri,\n    pub uri: String,\n    pub files: AudioFiles,\n    pub name: String,\n    pub covers: Vec<CoverImage>,\n    pub language: Vec<String>,\n    pub duration_ms: u32,\n    pub is_explicit: bool,\n    pub availability: AudioItemAvailability,\n    pub alternatives: Option<Tracks>,\n    pub unique_fields: UniqueFields,\n}\n\n#[derive(Debug, Clone)]\npub enum UniqueFields {\n    Track {\n        artists: ArtistsWithRole,\n        album: String,\n        album_artists: Vec<String>,\n        popularity: u8,\n        number: u32,\n        disc_number: u32,\n    },\n    Local {\n        // artists / album_artists can't be a Vec here, they are retrieved from metadata as a String,\n        // and we cannot make any assumptions about them being e.g. comma-separated\n        artists: Option<String>,\n        album: Option<String>,\n        album_artists: Option<String>,\n        number: Option<u32>,\n        disc_number: Option<u32>,\n        path: PathBuf,\n    },\n    Episode {\n        description: String,\n        publish_time: Date,\n        show_name: String,\n    },\n}\n\nimpl AudioItem {\n    pub async fn get_file(session: &Session, uri: SpotifyUri) -> AudioItemResult {\n        let image_url = session\n            .get_user_attribute(\"image-url\")\n            .unwrap_or_else(|| String::from(\"https://i.scdn.co/image/{file_id}\"));\n\n        match uri {\n            SpotifyUri::Track { .. } => {\n                let track = Track::get(session, &uri).await?;\n\n                if track.duration <= 0 {\n                    return Err(Error::unavailable(MetadataError::InvalidDuration(\n                        track.duration,\n                    )));\n                }\n\n                if track.is_explicit && session.filter_explicit_content() {\n                    return Err(Error::unavailable(MetadataError::ExplicitContentFiltered));\n                }\n\n                let uri_string = uri.to_uri();\n                let album = track.album.name;\n\n                let album_artists = track\n                    .album\n                    .artists\n                    .0\n                    .into_iter()\n                    .map(|a| a.name)\n                    .collect::<Vec<String>>();\n\n                let covers = get_covers(track.album.covers, image_url);\n\n                let alternatives = if track.alternatives.is_empty() {\n                    None\n                } else {\n                    Some(track.alternatives)\n                };\n\n                let availability = if Date::now_utc() < track.earliest_live_timestamp {\n                    Err(UnavailabilityReason::Embargo)\n                } else {\n                    available_for_user(\n                        &session.user_data(),\n                        &track.availability,\n                        &track.restrictions,\n                    )\n                };\n\n                let popularity = track.popularity.clamp(0, 100) as u8;\n                let number = track.number.max(0) as u32;\n                let disc_number = track.disc_number.max(0) as u32;\n\n                let unique_fields = UniqueFields::Track {\n                    artists: track.artists_with_role,\n                    album,\n                    album_artists,\n                    popularity,\n                    number,\n                    disc_number,\n                };\n\n                Ok(Self {\n                    track_id: uri,\n                    uri: uri_string,\n                    files: track.files,\n                    name: track.name,\n                    covers,\n                    language: track.language_of_performance,\n                    duration_ms: track.duration as u32,\n                    is_explicit: track.is_explicit,\n                    availability,\n                    alternatives,\n                    unique_fields,\n                })\n            }\n            SpotifyUri::Episode { .. } => {\n                let episode = Episode::get(session, &uri).await?;\n\n                if episode.duration <= 0 {\n                    return Err(Error::unavailable(MetadataError::InvalidDuration(\n                        episode.duration,\n                    )));\n                }\n\n                if episode.is_explicit && session.filter_explicit_content() {\n                    return Err(Error::unavailable(MetadataError::ExplicitContentFiltered));\n                }\n\n                let uri_string = uri.to_uri();\n\n                let covers = get_covers(episode.covers, image_url);\n\n                let availability = available_for_user(\n                    &session.user_data(),\n                    &episode.availability,\n                    &episode.restrictions,\n                );\n\n                let unique_fields = UniqueFields::Episode {\n                    description: episode.description,\n                    publish_time: episode.publish_time,\n                    show_name: episode.show_name,\n                };\n\n                Ok(Self {\n                    track_id: uri,\n                    uri: uri_string,\n                    files: episode.audio,\n                    name: episode.name,\n                    covers,\n                    language: vec![episode.language],\n                    duration_ms: episode.duration as u32,\n                    is_explicit: episode.is_explicit,\n                    availability,\n                    alternatives: None,\n                    unique_fields,\n                })\n            }\n            _ => Err(Error::unavailable(MetadataError::NonPlayable)),\n        }\n    }\n}\n\nfn get_covers(covers: Images, image_url: String) -> Vec<CoverImage> {\n    let mut covers = covers;\n\n    covers.sort_by(|a, b| b.width.cmp(&a.width));\n\n    covers\n        .iter()\n        .filter_map(|cover| {\n            let cover_id = cover.id.to_string();\n\n            if !cover_id.is_empty() {\n                let cover_image = CoverImage {\n                    url: image_url.replace(\"{file_id}\", &cover_id),\n                    size: cover.size,\n                    width: cover.width,\n                    height: cover.height,\n                };\n\n                Some(cover_image)\n            } else {\n                None\n            }\n        })\n        .collect()\n}\n\nfn allowed_for_user(user_data: &UserData, restrictions: &Restrictions) -> AudioItemAvailability {\n    let country = &user_data.country;\n    let user_catalogue = match user_data.attributes.get(\"catalogue\") {\n        Some(catalogue) => catalogue,\n        None => \"premium\",\n    };\n\n    for premium_restriction in restrictions.iter().filter(|restriction| {\n        restriction\n            .catalogue_strs\n            .iter()\n            .any(|restricted_catalogue| restricted_catalogue == user_catalogue)\n    }) {\n        if let Some(allowed_countries) = &premium_restriction.countries_allowed {\n            // A restriction will specify either a whitelast *or* a blacklist,\n            // but not both. So restrict availability if there is a whitelist\n            // and the country isn't on it.\n            if allowed_countries.iter().any(|allowed| country == allowed) {\n                return Ok(());\n            } else {\n                return Err(UnavailabilityReason::NotWhitelisted);\n            }\n        }\n\n        if let Some(forbidden_countries) = &premium_restriction.countries_forbidden {\n            if forbidden_countries\n                .iter()\n                .any(|forbidden| country == forbidden)\n            {\n                return Err(UnavailabilityReason::Blacklisted);\n            } else {\n                return Ok(());\n            }\n        }\n    }\n\n    Ok(()) // no restrictions in place\n}\n\nfn available(availability: &Availabilities) -> AudioItemAvailability {\n    if availability.is_empty() {\n        // not all items have availability specified\n        return Ok(());\n    }\n\n    if !(availability\n        .iter()\n        .any(|availability| Date::now_utc() >= availability.start))\n    {\n        return Err(UnavailabilityReason::Embargo);\n    }\n\n    Ok(())\n}\n\nfn available_for_user(\n    user_data: &UserData,\n    availability: &Availabilities,\n    restrictions: &Restrictions,\n) -> AudioItemAvailability {\n    available(availability)?;\n    allowed_for_user(user_data, restrictions)?;\n    Ok(())\n}\n"
  },
  {
    "path": "metadata/src/audio/mod.rs",
    "content": "pub mod file;\npub mod item;\n\npub use file::{AudioFileFormat, AudioFiles};\npub use item::{AudioItem, UniqueFields};\n"
  },
  {
    "path": "metadata/src/availability.rs",
    "content": "use std::{\n    fmt::Debug,\n    ops::{Deref, DerefMut},\n};\n\nuse thiserror::Error;\n\nuse crate::util::{impl_deref_wrapped, impl_try_from_repeated};\n\nuse librespot_core::date::Date;\n\nuse librespot_protocol as protocol;\nuse protocol::metadata::Availability as AvailabilityMessage;\n\npub type AudioItemAvailability = Result<(), UnavailabilityReason>;\n\n#[derive(Debug, Clone)]\npub struct Availability {\n    pub catalogue_strs: Vec<String>,\n    pub start: Date,\n}\n\n#[derive(Debug, Clone, Default)]\npub struct Availabilities(pub Vec<Availability>);\n\nimpl_deref_wrapped!(Availabilities, Vec<Availability>);\n\n#[derive(Debug, Copy, Clone, Error)]\npub enum UnavailabilityReason {\n    #[error(\"blacklist present and country on it\")]\n    Blacklisted,\n    #[error(\"available date is in the future\")]\n    Embargo,\n    #[error(\"required data was not present\")]\n    NoData,\n    #[error(\"whitelist present and country not on it\")]\n    NotWhitelisted,\n}\n\nimpl TryFrom<&AvailabilityMessage> for Availability {\n    type Error = librespot_core::Error;\n    fn try_from(availability: &AvailabilityMessage) -> Result<Self, Self::Error> {\n        Ok(Self {\n            catalogue_strs: availability.catalogue_str.to_vec(),\n            start: availability.start.get_or_default().try_into()?,\n        })\n    }\n}\n\nimpl_try_from_repeated!(AvailabilityMessage, Availabilities);\n"
  },
  {
    "path": "metadata/src/content_rating.rs",
    "content": "use std::{\n    fmt::Debug,\n    ops::{Deref, DerefMut},\n};\n\nuse crate::util::{impl_deref_wrapped, impl_from_repeated};\n\nuse librespot_protocol as protocol;\nuse protocol::metadata::ContentRating as ContentRatingMessage;\n\n#[derive(Debug, Clone)]\npub struct ContentRating {\n    pub country: String,\n    pub tags: Vec<String>,\n}\n\n#[derive(Debug, Clone, Default)]\npub struct ContentRatings(pub Vec<ContentRating>);\n\nimpl_deref_wrapped!(ContentRatings, Vec<ContentRating>);\n\nimpl From<&ContentRatingMessage> for ContentRating {\n    fn from(content_rating: &ContentRatingMessage) -> Self {\n        Self {\n            country: content_rating.country().to_owned(),\n            tags: content_rating.tag.to_vec(),\n        }\n    }\n}\n\nimpl_from_repeated!(ContentRatingMessage, ContentRatings);\n"
  },
  {
    "path": "metadata/src/copyright.rs",
    "content": "use std::{\n    fmt::Debug,\n    ops::{Deref, DerefMut},\n};\n\nuse crate::util::{impl_deref_wrapped, impl_from_repeated};\n\nuse librespot_protocol as protocol;\nuse protocol::metadata::Copyright as CopyrightMessage;\npub use protocol::metadata::copyright::Type as CopyrightType;\n\n#[derive(Debug, Clone)]\npub struct Copyright {\n    pub copyright_type: CopyrightType,\n    pub text: String,\n}\n\n#[derive(Debug, Clone, Default)]\npub struct Copyrights(pub Vec<Copyright>);\n\nimpl_deref_wrapped!(Copyrights, Vec<Copyright>);\n\nimpl From<&CopyrightMessage> for Copyright {\n    fn from(copyright: &CopyrightMessage) -> Self {\n        Self {\n            copyright_type: copyright.type_(),\n            text: copyright.text().to_owned(),\n        }\n    }\n}\n\nimpl_from_repeated!(CopyrightMessage, Copyrights);\n"
  },
  {
    "path": "metadata/src/episode.rs",
    "content": "use std::{\n    fmt::Debug,\n    ops::{Deref, DerefMut},\n};\n\nuse crate::{\n    Metadata,\n    audio::file::AudioFiles,\n    availability::Availabilities,\n    content_rating::ContentRatings,\n    image::Images,\n    request::RequestResult,\n    restriction::Restrictions,\n    util::{impl_deref_wrapped, impl_try_from_repeated},\n    video::VideoFiles,\n};\n\nuse librespot_core::{Error, Session, SpotifyUri, date::Date};\n\nuse librespot_protocol as protocol;\npub use protocol::metadata::episode::EpisodeType;\n\n#[derive(Debug, Clone)]\npub struct Episode {\n    pub id: SpotifyUri,\n    pub name: String,\n    pub duration: i32,\n    pub audio: AudioFiles,\n    pub description: String,\n    pub number: i32,\n    pub publish_time: Date,\n    pub covers: Images,\n    pub language: String,\n    pub is_explicit: bool,\n    pub show_name: String,\n    pub videos: VideoFiles,\n    pub video_previews: VideoFiles,\n    pub audio_previews: AudioFiles,\n    pub restrictions: Restrictions,\n    pub freeze_frames: Images,\n    pub keywords: Vec<String>,\n    pub allow_background_playback: bool,\n    pub availability: Availabilities,\n    pub external_url: String,\n    pub episode_type: EpisodeType,\n    pub has_music_and_talk: bool,\n    pub content_rating: ContentRatings,\n    pub is_audiobook_chapter: bool,\n}\n\n#[derive(Debug, Clone, Default)]\npub struct Episodes(pub Vec<SpotifyUri>);\n\nimpl_deref_wrapped!(Episodes, Vec<SpotifyUri>);\n\n#[async_trait]\nimpl Metadata for Episode {\n    type Message = protocol::metadata::Episode;\n\n    async fn request(session: &Session, episode_uri: &SpotifyUri) -> RequestResult {\n        let SpotifyUri::Episode { .. } = episode_uri else {\n            return Err(Error::invalid_argument(\"episode_uri\"));\n        };\n\n        session.spclient().get_episode_metadata(episode_uri).await\n    }\n\n    fn parse(msg: &Self::Message, _: &SpotifyUri) -> Result<Self, Error> {\n        Self::try_from(msg)\n    }\n}\n\nimpl TryFrom<&<Self as Metadata>::Message> for Episode {\n    type Error = librespot_core::Error;\n    fn try_from(episode: &<Self as Metadata>::Message) -> Result<Self, Self::Error> {\n        Ok(Self {\n            id: episode.try_into()?,\n            name: episode.name().to_owned(),\n            duration: episode.duration().to_owned(),\n            audio: episode.audio.as_slice().into(),\n            description: episode.description().to_owned(),\n            number: episode.number(),\n            publish_time: episode.publish_time.get_or_default().try_into()?,\n            covers: episode.cover_image.image.as_slice().into(),\n            language: episode.language().to_owned(),\n            is_explicit: episode.explicit().to_owned(),\n            show_name: episode.show.name().to_owned(),\n            videos: episode.video.as_slice().into(),\n            video_previews: episode.video_preview.as_slice().into(),\n            audio_previews: episode.audio_preview.as_slice().into(),\n            restrictions: episode.restriction.as_slice().into(),\n            freeze_frames: episode.freeze_frame.image.as_slice().into(),\n            keywords: episode.keyword.to_vec(),\n            allow_background_playback: episode.allow_background_playback(),\n            availability: episode.availability.as_slice().try_into()?,\n            external_url: episode.external_url().to_owned(),\n            episode_type: episode.type_(),\n            has_music_and_talk: episode.music_and_talk(),\n            content_rating: episode.content_rating.as_slice().into(),\n            is_audiobook_chapter: episode.is_audiobook_chapter(),\n        })\n    }\n}\n\nimpl_try_from_repeated!(<Episode as Metadata>::Message, Episodes);\n"
  },
  {
    "path": "metadata/src/error.rs",
    "content": "use std::fmt::Debug;\nuse thiserror::Error;\n\n#[derive(Debug, Error)]\npub enum MetadataError {\n    #[error(\"empty response\")]\n    Empty,\n    #[error(\"audio item is non-playable when it should be\")]\n    NonPlayable,\n    #[error(\"audio item duration can not be: {0}\")]\n    InvalidDuration(i32),\n    #[error(\"track is marked as explicit, which client setting forbids\")]\n    ExplicitContentFiltered,\n}\n"
  },
  {
    "path": "metadata/src/external_id.rs",
    "content": "use std::{\n    fmt::Debug,\n    ops::{Deref, DerefMut},\n};\n\nuse crate::util::{impl_deref_wrapped, impl_from_repeated};\n\nuse librespot_protocol as protocol;\nuse protocol::metadata::ExternalId as ExternalIdMessage;\n\n#[derive(Debug, Clone)]\npub struct ExternalId {\n    pub external_type: String,\n    pub id: String, // this can be anything from a URL to a ISRC, EAN or UPC\n}\n\n#[derive(Debug, Clone, Default)]\npub struct ExternalIds(pub Vec<ExternalId>);\n\nimpl_deref_wrapped!(ExternalIds, Vec<ExternalId>);\n\nimpl From<&ExternalIdMessage> for ExternalId {\n    fn from(external_id: &ExternalIdMessage) -> Self {\n        Self {\n            external_type: external_id.type_().to_owned(),\n            id: external_id.id().to_owned(),\n        }\n    }\n}\n\nimpl_from_repeated!(ExternalIdMessage, ExternalIds);\n"
  },
  {
    "path": "metadata/src/image.rs",
    "content": "use std::{\n    fmt::Debug,\n    ops::{Deref, DerefMut},\n};\n\nuse crate::util::{impl_deref_wrapped, impl_from_repeated, impl_try_from_repeated};\n\nuse librespot_core::{FileId, SpotifyUri};\n\nuse librespot_protocol as protocol;\nuse protocol::metadata::Image as ImageMessage;\nuse protocol::metadata::ImageGroup;\npub use protocol::metadata::image::Size as ImageSize;\nuse protocol::playlist_annotate3::TranscodedPicture as TranscodedPictureMessage;\nuse protocol::playlist4_external::PictureSize as PictureSizeMessage;\n\n#[derive(Debug, Clone)]\npub struct Image {\n    pub id: FileId,\n    pub size: ImageSize,\n    pub width: i32,\n    pub height: i32,\n}\n\n#[derive(Debug, Clone, Default)]\npub struct Images(pub Vec<Image>);\n\nimpl From<&ImageGroup> for Images {\n    fn from(image_group: &ImageGroup) -> Self {\n        Self(image_group.image.iter().map(Into::into).collect())\n    }\n}\n\nimpl_deref_wrapped!(Images, Vec<Image>);\n\n#[derive(Debug, Clone)]\npub struct PictureSize {\n    pub target_name: String,\n    pub url: String,\n}\n\n#[derive(Debug, Clone, Default)]\npub struct PictureSizes(pub Vec<PictureSize>);\n\nimpl_deref_wrapped!(PictureSizes, Vec<PictureSize>);\n\n#[derive(Debug, Clone)]\npub struct TranscodedPicture {\n    pub target_name: String,\n    pub uri: SpotifyUri,\n}\n\n#[derive(Debug, Clone)]\npub struct TranscodedPictures(pub Vec<TranscodedPicture>);\n\nimpl_deref_wrapped!(TranscodedPictures, Vec<TranscodedPicture>);\n\nimpl From<&ImageMessage> for Image {\n    fn from(image: &ImageMessage) -> Self {\n        Self {\n            id: image.into(),\n            size: image.size(),\n            width: image.width(),\n            height: image.height(),\n        }\n    }\n}\n\nimpl_from_repeated!(ImageMessage, Images);\n\nimpl From<&PictureSizeMessage> for PictureSize {\n    fn from(size: &PictureSizeMessage) -> Self {\n        Self {\n            target_name: size.target_name().to_owned(),\n            url: size.url().to_owned(),\n        }\n    }\n}\n\nimpl_from_repeated!(PictureSizeMessage, PictureSizes);\n\nimpl TryFrom<&TranscodedPictureMessage> for TranscodedPicture {\n    type Error = librespot_core::Error;\n    fn try_from(picture: &TranscodedPictureMessage) -> Result<Self, Self::Error> {\n        Ok(Self {\n            target_name: picture.target_name().to_owned(),\n            uri: picture.try_into()?,\n        })\n    }\n}\n\nimpl_try_from_repeated!(TranscodedPictureMessage, TranscodedPictures);\n"
  },
  {
    "path": "metadata/src/lib.rs",
    "content": "#[macro_use]\nextern crate log;\n\n#[macro_use]\nextern crate async_trait;\n\nuse protobuf::Message;\n\nuse librespot_core::{Error, Session, SpotifyUri};\n\npub mod album;\npub mod artist;\npub mod audio;\npub mod availability;\npub mod content_rating;\npub mod copyright;\npub mod episode;\npub mod error;\npub mod external_id;\npub mod image;\npub mod lyrics;\npub mod playlist;\nmod request;\npub mod restriction;\npub mod sale_period;\npub mod show;\npub mod track;\nmod util;\npub mod video;\n\npub use error::MetadataError;\nuse request::RequestResult;\n\npub use album::Album;\npub use artist::Artist;\npub use episode::Episode;\npub use lyrics::Lyrics;\npub use playlist::Playlist;\npub use show::Show;\npub use track::Track;\n\n#[async_trait]\npub trait Metadata: Send + Sized + 'static {\n    type Message: protobuf::Message + std::fmt::Debug;\n\n    // Request a protobuf\n    async fn request(session: &Session, id: &SpotifyUri) -> RequestResult;\n\n    // Request a metadata struct\n    async fn get(session: &Session, id: &SpotifyUri) -> Result<Self, Error> {\n        let response = Self::request(session, id).await?;\n        let msg = Self::Message::parse_from_bytes(&response)?;\n        trace!(\"Received metadata: {msg:#?}\");\n        Self::parse(&msg, id)\n    }\n\n    fn parse(msg: &Self::Message, _: &SpotifyUri) -> Result<Self, Error>;\n}\n"
  },
  {
    "path": "metadata/src/lyrics.rs",
    "content": "use bytes::Bytes;\n\nuse librespot_core::{Error, FileId, Session, SpotifyId};\n\nimpl Lyrics {\n    pub async fn get(session: &Session, id: &SpotifyId) -> Result<Self, Error> {\n        let spclient = session.spclient();\n        let lyrics = spclient.get_lyrics(id).await?;\n        Self::try_from(&lyrics)\n    }\n\n    pub async fn get_for_image(\n        session: &Session,\n        id: &SpotifyId,\n        image_id: &FileId,\n    ) -> Result<Self, Error> {\n        let spclient = session.spclient();\n        let lyrics = spclient.get_lyrics_for_image(id, image_id).await?;\n        Self::try_from(&lyrics)\n    }\n}\n\nimpl TryFrom<&Bytes> for Lyrics {\n    type Error = Error;\n\n    fn try_from(lyrics: &Bytes) -> Result<Self, Self::Error> {\n        serde_json::from_slice(lyrics).map_err(Into::into)\n    }\n}\n\n#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct Lyrics {\n    pub colors: Colors,\n    pub has_vocal_removal: bool,\n    pub lyrics: LyricsInner,\n}\n\n#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct Colors {\n    pub background: i32,\n    pub highlight_text: i32,\n    pub text: i32,\n}\n\n#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct LyricsInner {\n    // TODO: 'alternatives' field as an array but I don't know what it's meant for\n    pub is_dense_typeface: bool,\n    pub is_rtl_language: bool,\n    pub language: String,\n    pub lines: Vec<Line>,\n    pub provider: String,\n    pub provider_display_name: String,\n    pub provider_lyrics_id: String,\n    pub sync_lyrics_uri: String,\n    pub sync_type: SyncType,\n}\n\n#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]\n#[serde(rename_all = \"SCREAMING_SNAKE_CASE\")]\npub enum SyncType {\n    Unsynced,\n    LineSynced,\n}\n\n#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct Line {\n    pub start_time_ms: String,\n    pub end_time_ms: String,\n    pub words: String,\n    // TODO: 'syllables' array\n}\n"
  },
  {
    "path": "metadata/src/playlist/annotation.rs",
    "content": "use std::fmt::Debug;\n\nuse protobuf::Message;\n\nuse crate::{\n    Metadata,\n    image::TranscodedPictures,\n    request::{MercuryRequest, RequestResult},\n};\n\nuse librespot_core::{Error, Session, SpotifyId, SpotifyUri};\nuse librespot_protocol as protocol;\npub use protocol::playlist_annotate3::AbuseReportState;\n\n#[derive(Debug, Clone)]\npub struct PlaylistAnnotation {\n    pub description: String,\n    pub picture: String,\n    pub transcoded_pictures: TranscodedPictures,\n    pub has_abuse_reporting: bool,\n    pub abuse_report_state: AbuseReportState,\n}\n\n#[async_trait]\nimpl Metadata for PlaylistAnnotation {\n    type Message = protocol::playlist_annotate3::PlaylistAnnotation;\n\n    async fn request(session: &Session, playlist_uri: &SpotifyUri) -> RequestResult {\n        let current_user = session.username();\n\n        let SpotifyUri::Playlist {\n            id: playlist_id, ..\n        } = playlist_uri\n        else {\n            return Err(Error::invalid_argument(\"playlist_uri\"));\n        };\n\n        Self::request_for_user(session, &current_user, playlist_id).await\n    }\n\n    fn parse(msg: &Self::Message, _: &SpotifyUri) -> Result<Self, Error> {\n        Ok(Self {\n            description: msg.description().to_owned(),\n            picture: msg.picture().to_owned(), // TODO: is this a URL or Spotify URI?\n            transcoded_pictures: msg.transcoded_picture.as_slice().try_into()?,\n            has_abuse_reporting: msg.is_abuse_reporting_enabled(),\n            abuse_report_state: msg.abuse_report_state(),\n        })\n    }\n}\n\nimpl PlaylistAnnotation {\n    async fn request_for_user(\n        session: &Session,\n        username: &str,\n        playlist_id: &SpotifyId,\n    ) -> RequestResult {\n        let uri = format!(\n            \"hm://playlist-annotate/v1/annotation/user/{}/playlist/{}\",\n            username,\n            playlist_id.to_base62()\n        );\n        <Self as MercuryRequest>::request(session, &uri).await\n    }\n\n    #[allow(dead_code)]\n    async fn get_for_user(\n        session: &Session,\n        username: &str,\n        playlist_uri: &SpotifyUri,\n    ) -> Result<Self, Error> {\n        let SpotifyUri::Playlist {\n            id: playlist_id, ..\n        } = playlist_uri\n        else {\n            return Err(Error::invalid_argument(\"playlist_uri\"));\n        };\n\n        let response = Self::request_for_user(session, username, playlist_id).await?;\n        let msg = <Self as Metadata>::Message::parse_from_bytes(&response)?;\n        Self::parse(&msg, playlist_uri)\n    }\n}\n\nimpl MercuryRequest for PlaylistAnnotation {}\n\nimpl TryFrom<&<PlaylistAnnotation as Metadata>::Message> for PlaylistAnnotation {\n    type Error = librespot_core::Error;\n    fn try_from(\n        annotation: &<PlaylistAnnotation as Metadata>::Message,\n    ) -> Result<Self, Self::Error> {\n        Ok(Self {\n            description: annotation.description().to_owned(),\n            picture: annotation.picture().to_owned(),\n            transcoded_pictures: annotation.transcoded_picture.as_slice().try_into()?,\n            has_abuse_reporting: annotation.is_abuse_reporting_enabled(),\n            abuse_report_state: annotation.abuse_report_state(),\n        })\n    }\n}\n"
  },
  {
    "path": "metadata/src/playlist/attribute.rs",
    "content": "use std::{\n    collections::HashMap,\n    fmt::Debug,\n    ops::{Deref, DerefMut},\n};\n\nuse crate::{\n    image::PictureSizes,\n    util::{impl_deref_wrapped, impl_from_repeated_copy},\n};\n\nuse librespot_core::date::Date;\n\nuse librespot_protocol as protocol;\nuse protocol::playlist4_external::FormatListAttribute as PlaylistFormatAttributeMessage;\npub use protocol::playlist4_external::ItemAttributeKind as PlaylistItemAttributeKind;\nuse protocol::playlist4_external::ItemAttributes as PlaylistItemAttributesMessage;\nuse protocol::playlist4_external::ItemAttributesPartialState as PlaylistPartialItemAttributesMessage;\npub use protocol::playlist4_external::ListAttributeKind as PlaylistAttributeKind;\nuse protocol::playlist4_external::ListAttributes as PlaylistAttributesMessage;\nuse protocol::playlist4_external::ListAttributesPartialState as PlaylistPartialAttributesMessage;\nuse protocol::playlist4_external::UpdateItemAttributes as PlaylistUpdateItemAttributesMessage;\nuse protocol::playlist4_external::UpdateListAttributes as PlaylistUpdateAttributesMessage;\n\n#[derive(Debug, Clone)]\npub struct PlaylistAttributes {\n    pub name: String,\n    pub description: String,\n    pub picture: Vec<u8>,\n    pub is_collaborative: bool,\n    pub pl3_version: String,\n    pub is_deleted_by_owner: bool,\n    pub client_id: String,\n    pub format: String,\n    pub format_attributes: PlaylistFormatAttribute,\n    pub picture_sizes: PictureSizes,\n}\n\n#[derive(Debug, Clone, Default)]\npub struct PlaylistAttributeKinds(pub Vec<PlaylistAttributeKind>);\n\nimpl_deref_wrapped!(PlaylistAttributeKinds, Vec<PlaylistAttributeKind>);\n\nimpl_from_repeated_copy!(PlaylistAttributeKind, PlaylistAttributeKinds);\n\n#[derive(Debug, Clone, Default)]\npub struct PlaylistFormatAttribute(pub HashMap<String, String>);\n\nimpl_deref_wrapped!(PlaylistFormatAttribute, HashMap<String, String>);\n\n#[derive(Debug, Clone)]\npub struct PlaylistItemAttributes {\n    pub added_by: String,\n    pub timestamp: Date,\n    pub seen_at: Date,\n    pub is_public: bool,\n    pub format_attributes: PlaylistFormatAttribute,\n    pub item_id: Vec<u8>,\n}\n\n#[derive(Debug, Clone, Default)]\npub struct PlaylistItemAttributeKinds(pub Vec<PlaylistItemAttributeKind>);\n\nimpl_deref_wrapped!(PlaylistItemAttributeKinds, Vec<PlaylistItemAttributeKind>);\n\nimpl_from_repeated_copy!(PlaylistItemAttributeKind, PlaylistItemAttributeKinds);\n\n#[derive(Debug, Clone)]\npub struct PlaylistPartialAttributes {\n    #[allow(dead_code)]\n    values: PlaylistAttributes,\n    #[allow(dead_code)]\n    no_value: PlaylistAttributeKinds,\n}\n\n#[derive(Debug, Clone)]\npub struct PlaylistPartialItemAttributes {\n    #[allow(dead_code)]\n    values: PlaylistItemAttributes,\n    #[allow(dead_code)]\n    no_value: PlaylistItemAttributeKinds,\n}\n\n#[derive(Debug, Clone)]\npub struct PlaylistUpdateAttributes {\n    pub new_attributes: PlaylistPartialAttributes,\n    pub old_attributes: PlaylistPartialAttributes,\n}\n\n#[derive(Debug, Clone)]\npub struct PlaylistUpdateItemAttributes {\n    pub index: i32,\n    pub new_attributes: PlaylistPartialItemAttributes,\n    pub old_attributes: PlaylistPartialItemAttributes,\n}\n\nimpl TryFrom<&PlaylistAttributesMessage> for PlaylistAttributes {\n    type Error = librespot_core::Error;\n    fn try_from(attributes: &PlaylistAttributesMessage) -> Result<Self, Self::Error> {\n        Ok(Self {\n            name: attributes.name().to_owned(),\n            description: attributes.description().to_owned(),\n            picture: attributes.picture().to_owned(),\n            is_collaborative: attributes.collaborative(),\n            pl3_version: attributes.pl3_version().to_owned(),\n            is_deleted_by_owner: attributes.deleted_by_owner(),\n            client_id: attributes.client_id().to_owned(),\n            format: attributes.format().to_owned(),\n            format_attributes: attributes.format_attributes.as_slice().into(),\n            picture_sizes: attributes.picture_size.as_slice().into(),\n        })\n    }\n}\n\nimpl From<&[PlaylistFormatAttributeMessage]> for PlaylistFormatAttribute {\n    fn from(attributes: &[PlaylistFormatAttributeMessage]) -> Self {\n        let format_attributes = attributes\n            .iter()\n            .map(|attribute| (attribute.key().to_owned(), attribute.value().to_owned()))\n            .collect();\n\n        PlaylistFormatAttribute(format_attributes)\n    }\n}\n\nimpl TryFrom<&PlaylistItemAttributesMessage> for PlaylistItemAttributes {\n    type Error = librespot_core::Error;\n    fn try_from(attributes: &PlaylistItemAttributesMessage) -> Result<Self, Self::Error> {\n        Ok(Self {\n            added_by: attributes.added_by().to_owned(),\n            timestamp: Date::from_timestamp_ms(attributes.timestamp())?,\n            seen_at: Date::from_timestamp_ms(attributes.seen_at())?,\n            is_public: attributes.public(),\n            format_attributes: attributes.format_attributes.as_slice().into(),\n            item_id: attributes.item_id().to_owned(),\n        })\n    }\n}\nimpl TryFrom<&PlaylistPartialAttributesMessage> for PlaylistPartialAttributes {\n    type Error = librespot_core::Error;\n    fn try_from(attributes: &PlaylistPartialAttributesMessage) -> Result<Self, Self::Error> {\n        Ok(Self {\n            values: attributes.values.get_or_default().try_into()?,\n            no_value: attributes\n                .no_value\n                .iter()\n                .map(protobuf::EnumOrUnknown::enum_value_or_default)\n                .collect::<Vec<PlaylistAttributeKind>>()\n                .as_slice()\n                .into(),\n        })\n    }\n}\n\nimpl TryFrom<&PlaylistPartialItemAttributesMessage> for PlaylistPartialItemAttributes {\n    type Error = librespot_core::Error;\n    fn try_from(attributes: &PlaylistPartialItemAttributesMessage) -> Result<Self, Self::Error> {\n        Ok(Self {\n            values: attributes.values.get_or_default().try_into()?,\n            no_value: attributes\n                .no_value\n                .iter()\n                .map(protobuf::EnumOrUnknown::enum_value_or_default)\n                .collect::<Vec<PlaylistItemAttributeKind>>()\n                .as_slice()\n                .into(),\n        })\n    }\n}\n\nimpl TryFrom<&PlaylistUpdateAttributesMessage> for PlaylistUpdateAttributes {\n    type Error = librespot_core::Error;\n    fn try_from(update: &PlaylistUpdateAttributesMessage) -> Result<Self, Self::Error> {\n        Ok(Self {\n            new_attributes: update.new_attributes.get_or_default().try_into()?,\n            old_attributes: update.old_attributes.get_or_default().try_into()?,\n        })\n    }\n}\n\nimpl TryFrom<&PlaylistUpdateItemAttributesMessage> for PlaylistUpdateItemAttributes {\n    type Error = librespot_core::Error;\n    fn try_from(update: &PlaylistUpdateItemAttributesMessage) -> Result<Self, Self::Error> {\n        Ok(Self {\n            index: update.index(),\n            new_attributes: update.new_attributes.get_or_default().try_into()?,\n            old_attributes: update.old_attributes.get_or_default().try_into()?,\n        })\n    }\n}\n"
  },
  {
    "path": "metadata/src/playlist/diff.rs",
    "content": "use std::fmt::Debug;\n\nuse super::operation::PlaylistOperations;\n\nuse librespot_core::SpotifyId;\n\nuse librespot_protocol as protocol;\nuse protocol::playlist4_external::Diff as DiffMessage;\n\n#[derive(Debug, Clone)]\npub struct PlaylistDiff {\n    pub from_revision: SpotifyId,\n    pub operations: PlaylistOperations,\n    pub to_revision: SpotifyId,\n}\n\nimpl TryFrom<&DiffMessage> for PlaylistDiff {\n    type Error = librespot_core::Error;\n    fn try_from(diff: &DiffMessage) -> Result<Self, Self::Error> {\n        Ok(Self {\n            from_revision: diff\n                .from_revision\n                .clone()\n                .unwrap_or_default()\n                .as_slice()\n                .try_into()?,\n            operations: diff.ops.as_slice().try_into()?,\n            to_revision: diff\n                .to_revision\n                .clone()\n                .unwrap_or_default()\n                .as_slice()\n                .try_into()?,\n        })\n    }\n}\n"
  },
  {
    "path": "metadata/src/playlist/item.rs",
    "content": "use std::{\n    fmt::Debug,\n    ops::{Deref, DerefMut},\n};\n\nuse crate::util::{impl_deref_wrapped, impl_try_from_repeated};\n\nuse super::{\n    attribute::{PlaylistAttributes, PlaylistItemAttributes},\n    permission::Capabilities,\n};\n\nuse librespot_core::{SpotifyUri, date::Date};\n\nuse librespot_protocol as protocol;\nuse protocol::playlist4_external::Item as PlaylistItemMessage;\nuse protocol::playlist4_external::ListItems as PlaylistItemsMessage;\nuse protocol::playlist4_external::MetaItem as PlaylistMetaItemMessage;\n\n#[derive(Debug, Clone)]\npub struct PlaylistItem {\n    pub id: SpotifyUri,\n    pub attributes: PlaylistItemAttributes,\n}\n\n#[derive(Debug, Clone, Default)]\npub struct PlaylistItems(pub Vec<PlaylistItem>);\n\nimpl_deref_wrapped!(PlaylistItems, Vec<PlaylistItem>);\n\n#[derive(Debug, Clone)]\npub struct PlaylistItemList {\n    pub position: i32,\n    pub is_truncated: bool,\n    pub items: PlaylistItems,\n    pub meta_items: PlaylistMetaItems,\n}\n\n#[derive(Debug, Clone)]\npub struct PlaylistMetaItem {\n    pub revision: SpotifyUri,\n    pub attributes: PlaylistAttributes,\n    pub length: i32,\n    pub timestamp: Date,\n    pub owner_username: String,\n    pub has_abuse_reporting: bool,\n    pub capabilities: Capabilities,\n}\n\n#[derive(Debug, Clone, Default)]\npub struct PlaylistMetaItems(pub Vec<PlaylistMetaItem>);\n\nimpl_deref_wrapped!(PlaylistMetaItems, Vec<PlaylistMetaItem>);\n\nimpl TryFrom<&PlaylistItemMessage> for PlaylistItem {\n    type Error = librespot_core::Error;\n    fn try_from(item: &PlaylistItemMessage) -> Result<Self, Self::Error> {\n        Ok(Self {\n            id: item.try_into()?,\n            attributes: item.attributes.get_or_default().try_into()?,\n        })\n    }\n}\n\nimpl_try_from_repeated!(PlaylistItemMessage, PlaylistItems);\n\nimpl TryFrom<&PlaylistItemsMessage> for PlaylistItemList {\n    type Error = librespot_core::Error;\n    fn try_from(list_items: &PlaylistItemsMessage) -> Result<Self, Self::Error> {\n        Ok(Self {\n            position: list_items.pos(),\n            is_truncated: list_items.truncated(),\n            items: list_items.items.as_slice().try_into()?,\n            meta_items: list_items.meta_items.as_slice().try_into()?,\n        })\n    }\n}\n\nimpl TryFrom<&PlaylistMetaItemMessage> for PlaylistMetaItem {\n    type Error = librespot_core::Error;\n    fn try_from(item: &PlaylistMetaItemMessage) -> Result<Self, Self::Error> {\n        Ok(Self {\n            revision: item.try_into()?,\n            attributes: item.attributes.get_or_default().try_into()?,\n            length: item.length(),\n            timestamp: Date::from_timestamp_ms(item.timestamp())?,\n            owner_username: item.owner_username().to_owned(),\n            has_abuse_reporting: item.abuse_reporting_enabled(),\n            capabilities: item.capabilities.get_or_default().into(),\n        })\n    }\n}\n\nimpl_try_from_repeated!(PlaylistMetaItemMessage, PlaylistMetaItems);\n"
  },
  {
    "path": "metadata/src/playlist/list.rs",
    "content": "use std::{\n    fmt::Debug,\n    ops::{Deref, DerefMut},\n};\n\nuse crate::{\n    Metadata,\n    request::RequestResult,\n    util::{impl_deref_wrapped, impl_from_repeated_copy, impl_try_from_repeated},\n};\n\nuse super::{\n    attribute::PlaylistAttributes, diff::PlaylistDiff, item::PlaylistItemList,\n    permission::Capabilities,\n};\n\nuse librespot_core::{Error, Session, SpotifyUri, date::Date, spotify_id::SpotifyId};\nuse librespot_protocol as protocol;\nuse protocol::playlist4_external::GeoblockBlockingType as Geoblock;\n\n#[derive(Debug, Clone, Default)]\npub struct Geoblocks(Vec<Geoblock>);\n\nimpl_deref_wrapped!(Geoblocks, Vec<Geoblock>);\n\n#[derive(Debug, Clone)]\npub struct Playlist {\n    pub id: SpotifyUri,\n    pub revision: Vec<u8>,\n    pub length: i32,\n    pub attributes: PlaylistAttributes,\n    pub contents: PlaylistItemList,\n    pub diff: Option<PlaylistDiff>,\n    pub sync_result: Option<PlaylistDiff>,\n    pub resulting_revisions: Playlists,\n    pub has_multiple_heads: bool,\n    pub is_up_to_date: bool,\n    pub nonces: Vec<i64>,\n    pub timestamp: Date,\n    pub has_abuse_reporting: bool,\n    pub capabilities: Capabilities,\n    pub geoblocks: Geoblocks,\n}\n\n#[derive(Debug, Clone, Default)]\npub struct Playlists(pub Vec<SpotifyId>);\n\nimpl_deref_wrapped!(Playlists, Vec<SpotifyId>);\n\n#[derive(Debug, Clone)]\npub struct SelectedListContent {\n    pub revision: Vec<u8>,\n    pub length: i32,\n    pub attributes: PlaylistAttributes,\n    pub contents: PlaylistItemList,\n    pub diff: Option<PlaylistDiff>,\n    pub sync_result: Option<PlaylistDiff>,\n    pub resulting_revisions: Playlists,\n    pub has_multiple_heads: bool,\n    pub is_up_to_date: bool,\n    pub nonces: Vec<i64>,\n    pub timestamp: Date,\n    pub owner_username: String,\n    pub has_abuse_reporting: bool,\n    pub capabilities: Capabilities,\n    pub geoblocks: Geoblocks,\n}\n\nimpl Playlist {\n    pub fn tracks(&self) -> impl ExactSizeIterator<Item = &SpotifyUri> {\n        let tracks = self.contents.items.iter().map(|item| &item.id);\n\n        let length = tracks.len();\n        let expected_length = self.length as usize;\n        if length != expected_length {\n            warn!(\"Got {length} tracks, but the list should contain {expected_length} tracks.\",);\n        }\n\n        tracks\n    }\n\n    pub fn name(&self) -> &str {\n        &self.attributes.name\n    }\n}\n\n#[async_trait]\nimpl Metadata for Playlist {\n    type Message = protocol::playlist4_external::SelectedListContent;\n\n    async fn request(session: &Session, playlist_uri: &SpotifyUri) -> RequestResult {\n        let SpotifyUri::Playlist {\n            id: playlist_id, ..\n        } = playlist_uri\n        else {\n            return Err(Error::invalid_argument(\"playlist_uri\"));\n        };\n\n        session.spclient().get_playlist(playlist_id).await\n    }\n\n    fn parse(msg: &Self::Message, uri: &SpotifyUri) -> Result<Self, Error> {\n        let SpotifyUri::Playlist {\n            id: playlist_id, ..\n        } = uri\n        else {\n            return Err(Error::invalid_argument(\"playlist_uri\"));\n        };\n\n        // the playlist proto doesn't contain the id so we decorate it\n        let playlist = SelectedListContent::try_from(msg)?;\n\n        let new_uri = SpotifyUri::Playlist {\n            id: *playlist_id,\n            user: Some(playlist.owner_username),\n        };\n\n        Ok(Self {\n            id: new_uri,\n            revision: playlist.revision,\n            length: playlist.length,\n            attributes: playlist.attributes,\n            contents: playlist.contents,\n            diff: playlist.diff,\n            sync_result: playlist.sync_result,\n            resulting_revisions: playlist.resulting_revisions,\n            has_multiple_heads: playlist.has_multiple_heads,\n            is_up_to_date: playlist.is_up_to_date,\n            nonces: playlist.nonces,\n            timestamp: playlist.timestamp,\n            has_abuse_reporting: playlist.has_abuse_reporting,\n            capabilities: playlist.capabilities,\n            geoblocks: playlist.geoblocks,\n        })\n    }\n}\n\nimpl TryFrom<&<Playlist as Metadata>::Message> for SelectedListContent {\n    type Error = librespot_core::Error;\n    fn try_from(playlist: &<Playlist as Metadata>::Message) -> Result<Self, Self::Error> {\n        let timestamp = playlist.timestamp();\n        let timestamp = if timestamp > 9295169800000 {\n            // timestamp is way out of range for milliseconds. Some seem to be in microseconds?\n            // Observed on playlists where:\n            //   format: \"artist-mix-reader\"\n            //   format_attributes {\n            //     key: \"mediaListConfig\"\n            //     value: \"spotify:medialistconfig:artist-seed-mix:default_v18\"\n            //   }\n            warn!(\"timestamp is very large; assuming it's in microseconds\");\n            timestamp / 1000\n        } else {\n            timestamp\n        };\n        let timestamp = Date::from_timestamp_ms(timestamp)?;\n\n        Ok(Self {\n            revision: playlist.revision().to_owned(),\n            length: playlist.length(),\n            attributes: playlist.attributes.get_or_default().try_into()?,\n            contents: playlist.contents.get_or_default().try_into()?,\n            diff: playlist.diff.as_ref().map(TryInto::try_into).transpose()?,\n            sync_result: playlist\n                .sync_result\n                .as_ref()\n                .map(TryInto::try_into)\n                .transpose()?,\n            resulting_revisions: Playlists(\n                playlist\n                    .resulting_revisions\n                    .iter()\n                    .map(std::convert::TryInto::try_into)\n                    .collect::<Result<Vec<SpotifyId>, Error>>()?,\n            ),\n            has_multiple_heads: playlist.multiple_heads(),\n            is_up_to_date: playlist.up_to_date(),\n            nonces: playlist.nonces.clone(),\n            timestamp,\n            owner_username: playlist.owner_username().to_owned(),\n            has_abuse_reporting: playlist.abuse_reporting_enabled(),\n            capabilities: playlist.capabilities.get_or_default().into(),\n            geoblocks: Geoblocks(\n                playlist\n                    .geoblock\n                    .iter()\n                    .map(protobuf::EnumOrUnknown::enum_value_or_default)\n                    .collect(),\n            ),\n        })\n    }\n}\n\nimpl_from_repeated_copy!(Geoblock, Geoblocks);\nimpl_try_from_repeated!(Vec<u8>, Playlists);\n"
  },
  {
    "path": "metadata/src/playlist/mod.rs",
    "content": "pub mod annotation;\npub mod attribute;\npub mod diff;\npub mod item;\npub mod list;\npub mod operation;\npub mod permission;\n\npub use annotation::PlaylistAnnotation;\npub use list::Playlist;\n"
  },
  {
    "path": "metadata/src/playlist/operation.rs",
    "content": "use std::{\n    fmt::Debug,\n    ops::{Deref, DerefMut},\n};\n\nuse crate::{\n    playlist::{\n        attribute::{PlaylistUpdateAttributes, PlaylistUpdateItemAttributes},\n        item::PlaylistItems,\n    },\n    util::{impl_deref_wrapped, impl_try_from_repeated},\n};\n\nuse librespot_protocol as protocol;\nuse protocol::playlist4_external::Add as PlaylistAddMessage;\nuse protocol::playlist4_external::Mov as PlaylistMoveMessage;\nuse protocol::playlist4_external::Op as PlaylistOperationMessage;\nuse protocol::playlist4_external::Rem as PlaylistRemoveMessage;\npub use protocol::playlist4_external::op::Kind as PlaylistOperationKind;\n\n#[derive(Debug, Clone)]\npub struct PlaylistOperation {\n    pub kind: PlaylistOperationKind,\n    pub add: PlaylistOperationAdd,\n    pub rem: PlaylistOperationRemove,\n    pub mov: PlaylistOperationMove,\n    pub update_item_attributes: PlaylistUpdateItemAttributes,\n    pub update_list_attributes: PlaylistUpdateAttributes,\n}\n\n#[derive(Debug, Clone, Default)]\npub struct PlaylistOperations(pub Vec<PlaylistOperation>);\n\nimpl_deref_wrapped!(PlaylistOperations, Vec<PlaylistOperation>);\n\n#[derive(Debug, Clone)]\npub struct PlaylistOperationAdd {\n    pub from_index: i32,\n    pub items: PlaylistItems,\n    pub add_last: bool,\n    pub add_first: bool,\n}\n\n#[derive(Debug, Clone)]\npub struct PlaylistOperationMove {\n    pub from_index: i32,\n    pub length: i32,\n    pub to_index: i32,\n}\n\n#[derive(Debug, Clone)]\npub struct PlaylistOperationRemove {\n    pub from_index: i32,\n    pub length: i32,\n    pub items: PlaylistItems,\n    pub has_items_as_key: bool,\n}\n\nimpl TryFrom<&PlaylistOperationMessage> for PlaylistOperation {\n    type Error = librespot_core::Error;\n    fn try_from(operation: &PlaylistOperationMessage) -> Result<Self, Self::Error> {\n        Ok(Self {\n            kind: operation.kind(),\n            add: operation.add.get_or_default().try_into()?,\n            rem: operation.rem.get_or_default().try_into()?,\n            mov: operation.mov.get_or_default().into(),\n            update_item_attributes: operation\n                .update_item_attributes\n                .get_or_default()\n                .try_into()?,\n            update_list_attributes: operation\n                .update_list_attributes\n                .get_or_default()\n                .try_into()?,\n        })\n    }\n}\n\nimpl_try_from_repeated!(PlaylistOperationMessage, PlaylistOperations);\n\nimpl TryFrom<&PlaylistAddMessage> for PlaylistOperationAdd {\n    type Error = librespot_core::Error;\n    fn try_from(add: &PlaylistAddMessage) -> Result<Self, Self::Error> {\n        Ok(Self {\n            from_index: add.from_index(),\n            items: add.items.as_slice().try_into()?,\n            add_last: add.add_last(),\n            add_first: add.add_first(),\n        })\n    }\n}\n\nimpl From<&PlaylistMoveMessage> for PlaylistOperationMove {\n    fn from(mov: &PlaylistMoveMessage) -> Self {\n        Self {\n            from_index: mov.from_index(),\n            length: mov.length(),\n            to_index: mov.to_index(),\n        }\n    }\n}\n\nimpl TryFrom<&PlaylistRemoveMessage> for PlaylistOperationRemove {\n    type Error = librespot_core::Error;\n    fn try_from(remove: &PlaylistRemoveMessage) -> Result<Self, Self::Error> {\n        Ok(Self {\n            from_index: remove.from_index(),\n            length: remove.length(),\n            items: remove.items.as_slice().try_into()?,\n            has_items_as_key: remove.items_as_key(),\n        })\n    }\n}\n"
  },
  {
    "path": "metadata/src/playlist/permission.rs",
    "content": "use std::{\n    fmt::Debug,\n    ops::{Deref, DerefMut},\n};\n\nuse crate::util::{impl_deref_wrapped, impl_from_repeated_copy};\n\nuse librespot_protocol as protocol;\nuse protocol::playlist_permission::Capabilities as CapabilitiesMessage;\nuse protocol::playlist_permission::PermissionLevel;\n\n#[derive(Debug, Clone)]\npub struct Capabilities {\n    pub can_view: bool,\n    pub can_administrate_permissions: bool,\n    pub grantable_levels: PermissionLevels,\n    pub can_edit_metadata: bool,\n    pub can_edit_items: bool,\n    pub can_cancel_membership: bool,\n}\n\n#[derive(Debug, Clone, Default)]\npub struct PermissionLevels(pub Vec<PermissionLevel>);\n\nimpl_deref_wrapped!(PermissionLevels, Vec<PermissionLevel>);\n\nimpl From<&CapabilitiesMessage> for Capabilities {\n    fn from(playlist: &CapabilitiesMessage) -> Self {\n        Self {\n            can_view: playlist.can_view(),\n            can_administrate_permissions: playlist.can_administrate_permissions(),\n            grantable_levels: PermissionLevels(\n                playlist\n                    .grantable_level\n                    .iter()\n                    .map(protobuf::EnumOrUnknown::enum_value_or_default)\n                    .collect(),\n            ),\n            can_edit_metadata: playlist.can_edit_metadata(),\n            can_edit_items: playlist.can_edit_items(),\n            can_cancel_membership: playlist.can_cancel_membership(),\n        }\n    }\n}\n\nimpl_from_repeated_copy!(PermissionLevel, PermissionLevels);\n"
  },
  {
    "path": "metadata/src/request.rs",
    "content": "use std::fmt::Write;\n\nuse crate::MetadataError;\n\nuse librespot_core::{Error, Session};\n\npub type RequestResult = Result<bytes::Bytes, Error>;\n\n#[async_trait]\npub trait MercuryRequest {\n    async fn request(session: &Session, uri: &str) -> RequestResult {\n        let mut metrics_uri = uri.to_owned();\n\n        let separator = match metrics_uri.find('?') {\n            Some(_) => \"&\",\n            None => \"?\",\n        };\n        let _ = write!(metrics_uri, \"{separator}country={}\", session.country());\n\n        if let Some(product) = session.get_user_attribute(\"type\") {\n            let _ = write!(metrics_uri, \"&product={product}\");\n        }\n\n        trace!(\"Requesting {metrics_uri}\");\n\n        let request = session.mercury().get(metrics_uri)?;\n        let response = request.await?;\n        match response.payload.first() {\n            Some(data) => {\n                let data = data.to_vec().into();\n                trace!(\"Received metadata: {data:?}\");\n                Ok(data)\n            }\n            None => Err(Error::unavailable(MetadataError::Empty)),\n        }\n    }\n}\n"
  },
  {
    "path": "metadata/src/restriction.rs",
    "content": "use std::{\n    fmt::Debug,\n    ops::{Deref, DerefMut},\n};\n\nuse crate::util::impl_deref_wrapped;\nuse crate::util::{impl_from_repeated, impl_from_repeated_copy};\n\nuse protocol::metadata::Restriction as RestrictionMessage;\n\nuse librespot_protocol as protocol;\npub use protocol::metadata::restriction::Catalogue as RestrictionCatalogue;\npub use protocol::metadata::restriction::Type as RestrictionType;\n\n#[derive(Debug, Clone)]\npub struct Restriction {\n    pub catalogues: RestrictionCatalogues,\n    pub restriction_type: RestrictionType,\n    pub catalogue_strs: Vec<String>,\n    pub countries_allowed: Option<Vec<String>>,\n    pub countries_forbidden: Option<Vec<String>>,\n}\n\n#[derive(Debug, Clone, Default)]\npub struct Restrictions(pub Vec<Restriction>);\n\nimpl_deref_wrapped!(Restrictions, Vec<Restriction>);\n\n#[derive(Debug, Clone)]\npub struct RestrictionCatalogues(pub Vec<RestrictionCatalogue>);\n\nimpl_deref_wrapped!(RestrictionCatalogues, Vec<RestrictionCatalogue>);\n\nimpl Restriction {\n    fn parse_country_codes(country_codes: &str) -> Vec<String> {\n        country_codes.chunks(2).map(Into::into).collect()\n    }\n}\n\nimpl From<&RestrictionMessage> for Restriction {\n    fn from(restriction: &RestrictionMessage) -> Self {\n        let countries_allowed = if restriction.has_countries_allowed() {\n            Some(Self::parse_country_codes(restriction.countries_allowed()))\n        } else {\n            None\n        };\n\n        let countries_forbidden = if restriction.has_countries_forbidden() {\n            Some(Self::parse_country_codes(restriction.countries_forbidden()))\n        } else {\n            None\n        };\n\n        Self {\n            catalogues: restriction\n                .catalogue\n                .iter()\n                .map(protobuf::EnumOrUnknown::enum_value_or_default)\n                .collect::<Vec<RestrictionCatalogue>>()\n                .as_slice()\n                .into(),\n            restriction_type: restriction\n                .type_\n                .unwrap_or_default()\n                .enum_value_or_default(),\n            catalogue_strs: restriction.catalogue_str.to_vec(),\n            countries_allowed,\n            countries_forbidden,\n        }\n    }\n}\n\nimpl_from_repeated!(RestrictionMessage, Restrictions);\nimpl_from_repeated_copy!(RestrictionCatalogue, RestrictionCatalogues);\n\nstruct StrChunks<'s>(&'s str, usize);\n\ntrait StrChunksExt {\n    fn chunks(&self, size: usize) -> StrChunks<'_>;\n}\n\nimpl StrChunksExt for str {\n    fn chunks(&self, size: usize) -> StrChunks<'_> {\n        StrChunks(self, size)\n    }\n}\n\nimpl<'s> Iterator for StrChunks<'s> {\n    type Item = &'s str;\n    fn next(&mut self) -> Option<&'s str> {\n        let &mut StrChunks(data, size) = self;\n        if data.is_empty() {\n            None\n        } else {\n            let ret = Some(&data[..size]);\n            self.0 = &data[size..];\n            ret\n        }\n    }\n}\n"
  },
  {
    "path": "metadata/src/sale_period.rs",
    "content": "use std::{\n    fmt::Debug,\n    ops::{Deref, DerefMut},\n};\n\nuse crate::{\n    restriction::Restrictions,\n    util::{impl_deref_wrapped, impl_try_from_repeated},\n};\n\nuse librespot_core::date::Date;\n\nuse librespot_protocol as protocol;\nuse protocol::metadata::SalePeriod as SalePeriodMessage;\n\n#[derive(Debug, Clone)]\npub struct SalePeriod {\n    pub restrictions: Restrictions,\n    pub start: Date,\n    pub end: Date,\n}\n\n#[derive(Debug, Clone, Default)]\npub struct SalePeriods(pub Vec<SalePeriod>);\n\nimpl_deref_wrapped!(SalePeriods, Vec<SalePeriod>);\n\nimpl TryFrom<&SalePeriodMessage> for SalePeriod {\n    type Error = librespot_core::Error;\n    fn try_from(sale_period: &SalePeriodMessage) -> Result<Self, Self::Error> {\n        Ok(Self {\n            restrictions: sale_period.restriction.as_slice().into(),\n            start: sale_period.start.get_or_default().try_into()?,\n            end: sale_period.end.get_or_default().try_into()?,\n        })\n    }\n}\n\nimpl_try_from_repeated!(SalePeriodMessage, SalePeriods);\n"
  },
  {
    "path": "metadata/src/show.rs",
    "content": "use std::fmt::Debug;\n\nuse crate::{\n    Metadata, RequestResult, availability::Availabilities, copyright::Copyrights,\n    episode::Episodes, image::Images, restriction::Restrictions,\n};\n\nuse librespot_core::{Error, Session, SpotifyUri};\n\nuse librespot_protocol as protocol;\npub use protocol::metadata::show::ConsumptionOrder as ShowConsumptionOrder;\npub use protocol::metadata::show::MediaType as ShowMediaType;\n\n#[derive(Debug, Clone)]\npub struct Show {\n    pub id: SpotifyUri,\n    pub name: String,\n    pub description: String,\n    pub publisher: String,\n    pub language: String,\n    pub is_explicit: bool,\n    pub covers: Images,\n    pub episodes: Episodes,\n    pub copyrights: Copyrights,\n    pub restrictions: Restrictions,\n    pub keywords: Vec<String>,\n    pub media_type: ShowMediaType,\n    pub consumption_order: ShowConsumptionOrder,\n    pub availability: Availabilities,\n    pub trailer_uri: Option<SpotifyUri>,\n    pub has_music_and_talk: bool,\n    pub is_audiobook: bool,\n}\n\n#[async_trait]\nimpl Metadata for Show {\n    type Message = protocol::metadata::Show;\n\n    async fn request(session: &Session, show_uri: &SpotifyUri) -> RequestResult {\n        let SpotifyUri::Show { .. } = show_uri else {\n            return Err(Error::invalid_argument(\"show_uri\"));\n        };\n\n        session.spclient().get_show_metadata(show_uri).await\n    }\n\n    fn parse(msg: &Self::Message, _: &SpotifyUri) -> Result<Self, Error> {\n        Self::try_from(msg)\n    }\n}\n\nimpl TryFrom<&<Self as Metadata>::Message> for Show {\n    type Error = librespot_core::Error;\n    fn try_from(show: &<Self as Metadata>::Message) -> Result<Self, Self::Error> {\n        Ok(Self {\n            id: show.try_into()?,\n            name: show.name().to_owned(),\n            description: show.description().to_owned(),\n            publisher: show.publisher().to_owned(),\n            language: show.language().to_owned(),\n            is_explicit: show.explicit(),\n            covers: show.cover_image.image.as_slice().into(),\n            episodes: show.episode.as_slice().try_into()?,\n            copyrights: show.copyright.as_slice().into(),\n            restrictions: show.restriction.as_slice().into(),\n            keywords: show.keyword.to_vec(),\n            media_type: show.media_type(),\n            consumption_order: show.consumption_order(),\n            availability: show.availability.as_slice().try_into()?,\n            trailer_uri: show\n                .trailer_uri\n                .as_deref()\n                .filter(|s| !s.is_empty())\n                .map(SpotifyUri::from_uri)\n                .transpose()?,\n            has_music_and_talk: show.music_and_talk(),\n            is_audiobook: show.is_audiobook(),\n        })\n    }\n}\n"
  },
  {
    "path": "metadata/src/track.rs",
    "content": "use std::{\n    fmt::Debug,\n    ops::{Deref, DerefMut},\n};\n\nuse uuid::Uuid;\n\nuse crate::{\n    Album, Metadata, RequestResult,\n    artist::{Artists, ArtistsWithRole},\n    audio::file::AudioFiles,\n    availability::Availabilities,\n    content_rating::ContentRatings,\n    external_id::ExternalIds,\n    restriction::Restrictions,\n    sale_period::SalePeriods,\n    util::{impl_deref_wrapped, impl_try_from_repeated},\n};\n\nuse librespot_core::{Error, Session, SpotifyUri, date::Date};\nuse librespot_protocol as protocol;\n\n#[derive(Debug, Clone)]\npub struct Track {\n    pub id: SpotifyUri,\n    pub name: String,\n    pub album: Album,\n    pub artists: Artists,\n    pub number: i32,\n    pub disc_number: i32,\n    pub duration: i32,\n    pub popularity: i32,\n    pub is_explicit: bool,\n    pub external_ids: ExternalIds,\n    pub restrictions: Restrictions,\n    pub files: AudioFiles,\n    pub alternatives: Tracks,\n    pub sale_periods: SalePeriods,\n    pub previews: AudioFiles,\n    pub tags: Vec<String>,\n    pub earliest_live_timestamp: Date,\n    pub has_lyrics: bool,\n    pub availability: Availabilities,\n    pub licensor: Uuid,\n    pub language_of_performance: Vec<String>,\n    pub content_ratings: ContentRatings,\n    pub original_title: String,\n    pub version_title: String,\n    pub artists_with_role: ArtistsWithRole,\n}\n\n#[derive(Debug, Clone, Default)]\npub struct Tracks(pub Vec<SpotifyUri>);\n\nimpl_deref_wrapped!(Tracks, Vec<SpotifyUri>);\n\n#[async_trait]\nimpl Metadata for Track {\n    type Message = protocol::metadata::Track;\n\n    async fn request(session: &Session, track_uri: &SpotifyUri) -> RequestResult {\n        let SpotifyUri::Track { .. } = track_uri else {\n            return Err(Error::invalid_argument(\"track_uri\"));\n        };\n\n        session.spclient().get_track_metadata(track_uri).await\n    }\n\n    fn parse(msg: &Self::Message, _: &SpotifyUri) -> Result<Self, Error> {\n        Self::try_from(msg)\n    }\n}\n\nimpl TryFrom<&<Self as Metadata>::Message> for Track {\n    type Error = librespot_core::Error;\n    fn try_from(track: &<Self as Metadata>::Message) -> Result<Self, Self::Error> {\n        Ok(Self {\n            id: track.try_into()?,\n            name: track.name().to_owned(),\n            album: track.album.get_or_default().try_into()?,\n            artists: track.artist.as_slice().try_into()?,\n            number: track.number(),\n            disc_number: track.disc_number(),\n            duration: track.duration(),\n            popularity: track.popularity(),\n            is_explicit: track.explicit(),\n            external_ids: track.external_id.as_slice().into(),\n            restrictions: track.restriction.as_slice().into(),\n            files: track.file.as_slice().into(),\n            alternatives: track.alternative.as_slice().try_into()?,\n            sale_periods: track.sale_period.as_slice().try_into()?,\n            previews: track.preview.as_slice().into(),\n            tags: track.tags.to_vec(),\n            earliest_live_timestamp: Date::from_timestamp_ms(track.earliest_live_timestamp())?,\n            has_lyrics: track.has_lyrics(),\n            availability: track.availability.as_slice().try_into()?,\n            licensor: Uuid::from_slice(track.licensor.uuid()).unwrap_or_else(|_| Uuid::nil()),\n            language_of_performance: track.language_of_performance.to_vec(),\n            content_ratings: track.content_rating.as_slice().into(),\n            original_title: track.original_title().to_owned(),\n            version_title: track.version_title().to_owned(),\n            artists_with_role: track.artist_with_role.as_slice().try_into()?,\n        })\n    }\n}\n\nimpl_try_from_repeated!(<Track as Metadata>::Message, Tracks);\n"
  },
  {
    "path": "metadata/src/util.rs",
    "content": "macro_rules! impl_from_repeated {\n    ($src:ty, $dst:ty) => {\n        impl From<&[$src]> for $dst {\n            fn from(src: &[$src]) -> Self {\n                let result = src.iter().map(From::from).collect();\n                Self(result)\n            }\n        }\n    };\n}\n\npub(crate) use impl_from_repeated;\n\nmacro_rules! impl_from_repeated_copy {\n    ($src:ty, $dst:ty) => {\n        impl From<&[$src]> for $dst {\n            fn from(src: &[$src]) -> Self {\n                let result = src.iter().copied().collect();\n                Self(result)\n            }\n        }\n    };\n}\n\npub(crate) use impl_from_repeated_copy;\n\nmacro_rules! impl_try_from_repeated {\n    ($src:ty, $dst:ty) => {\n        impl TryFrom<&[$src]> for $dst {\n            type Error = librespot_core::Error;\n            fn try_from(src: &[$src]) -> Result<Self, Self::Error> {\n                let result: Result<Vec<_>, _> = src.iter().map(TryFrom::try_from).collect();\n                Ok(Self(result?))\n            }\n        }\n    };\n}\n\npub(crate) use impl_try_from_repeated;\n\nmacro_rules! impl_deref_wrapped {\n    ($wrapper:ty, $inner:ty) => {\n        impl Deref for $wrapper {\n            type Target = $inner;\n            fn deref(&self) -> &Self::Target {\n                &self.0\n            }\n        }\n\n        impl DerefMut for $wrapper {\n            fn deref_mut(&mut self) -> &mut Self::Target {\n                &mut self.0\n            }\n        }\n    };\n}\n\npub(crate) use impl_deref_wrapped;\n"
  },
  {
    "path": "metadata/src/video.rs",
    "content": "use std::{\n    fmt::Debug,\n    ops::{Deref, DerefMut},\n};\n\nuse crate::util::{impl_deref_wrapped, impl_from_repeated};\n\nuse librespot_core::FileId;\n\nuse librespot_protocol as protocol;\nuse protocol::metadata::VideoFile as VideoFileMessage;\n\n#[derive(Debug, Clone, Default)]\npub struct VideoFiles(pub Vec<FileId>);\n\nimpl_deref_wrapped!(VideoFiles, Vec<FileId>);\n\nimpl_from_repeated!(VideoFileMessage, VideoFiles);\n"
  },
  {
    "path": "oauth/Cargo.toml",
    "content": "[package]\nname = \"librespot-oauth\"\nversion = \"0.8.0\"\nrust-version.workspace = true\nauthors = [\"Nick Steel <nick@nsteel.co.uk>\"]\nlicense.workspace = true\ndescription = \"OAuth authorization code flow with PKCE for obtaining a Spotify access token\"\nrepository.workspace = true\nedition.workspace = true\n\n[features]\n# Refer to the workspace Cargo.toml for the list of features\ndefault = [\"native-tls\"]\n\n# TLS backends (mutually exclusive - compile-time checks in src/lib.rs)\nnative-tls = [\"oauth2/native-tls\", \"reqwest/native-tls\"]\nrustls-tls-native-roots = [\n    \"__rustls\",\n    \"oauth2/rustls-tls\",\n    \"reqwest/rustls-tls-native-roots\",\n]\nrustls-tls-webpki-roots = [\n    \"__rustls\",\n    \"oauth2/rustls-tls\",\n    \"reqwest/rustls-tls-webpki-roots\",\n]\n\n# Internal features - these are not meant to be used by end users\n__rustls = []\n\n[dependencies]\nlog = \"0.4\"\noauth2 = { version = \"5.0\", default-features = false, features = [\n    \"reqwest\",\n    \"reqwest-blocking\",\n] }\nopen = \"5.3\"\nreqwest = { version = \"0.12\", default-features = false, features = [\n    \"system-proxy\",\n] }\nthiserror = \"2\"\nurl = \"2.5\"\n\n[dev-dependencies]\nenv_logger = { version = \"0.11\", default-features = false, features = [\n    \"color\",\n    \"humantime\",\n    \"auto-color\",\n] }\ntokio = { version = \"1\", features = [\"rt-multi-thread\", \"macros\"] }\n\n[lints]\nworkspace = true\n"
  },
  {
    "path": "oauth/examples/oauth_async.rs",
    "content": "use std::env;\n\nuse librespot_oauth::OAuthClientBuilder;\n\nconst SPOTIFY_CLIENT_ID: &str = \"65b708073fc0480ea92a077233ca87bd\";\nconst SPOTIFY_REDIRECT_URI: &str = \"http://127.0.0.1:8898/login\";\n\nconst RESPONSE: &str = r#\"\n<!doctype html>\n<html>\n    <body>\n        <h1>Return to your app!</h1>\n    </body>\n</html>\n\"#;\n\n#[tokio::main]\nasync fn main() {\n    let mut builder = env_logger::Builder::new();\n    builder.parse_filters(\"librespot=trace\");\n    builder.init();\n\n    let args: Vec<_> = env::args().collect();\n    let (client_id, redirect_uri, scopes) = if args.len() == 4 {\n        // You can use your own client ID, along with it's associated redirect URI.\n        (\n            args[1].as_str(),\n            args[2].as_str(),\n            args[3].split(',').collect::<Vec<&str>>(),\n        )\n    } else if args.len() == 1 {\n        (SPOTIFY_CLIENT_ID, SPOTIFY_REDIRECT_URI, vec![\"streaming\"])\n    } else {\n        eprintln!(\"Usage: {} [CLIENT_ID REDIRECT_URI SCOPES]\", args[0]);\n        return;\n    };\n\n    let client = match OAuthClientBuilder::new(client_id, redirect_uri, scopes)\n        .open_in_browser()\n        .with_custom_message(RESPONSE)\n        .build()\n    {\n        Ok(client) => client,\n        Err(err) => {\n            eprintln!(\"Unable to build an OAuth client: {err}\");\n            return;\n        }\n    };\n\n    let refresh_token = match client.get_access_token_async().await {\n        Ok(token) => {\n            println!(\"OAuth Token: {token:#?}\");\n            token.refresh_token\n        }\n        Err(err) => {\n            println!(\"Unable to get OAuth Token: {err}\");\n            return;\n        }\n    };\n\n    match client.refresh_token_async(&refresh_token).await {\n        Ok(token) => println!(\"New refreshed OAuth Token: {token:#?}\"),\n        Err(err) => println!(\"Unable to get refreshed OAuth Token: {err}\"),\n    }\n}\n"
  },
  {
    "path": "oauth/examples/oauth_sync.rs",
    "content": "use std::env;\n\nuse librespot_oauth::OAuthClientBuilder;\n\nconst SPOTIFY_CLIENT_ID: &str = \"65b708073fc0480ea92a077233ca87bd\";\nconst SPOTIFY_REDIRECT_URI: &str = \"http://127.0.0.1:8898/login\";\n\nconst RESPONSE: &str = r#\"\n<!doctype html>\n<html>\n    <body>\n        <h1>Return to your app!</h1>\n    </body>\n</html>\n\"#;\n\nfn main() {\n    let mut builder = env_logger::Builder::new();\n    builder.parse_filters(\"librespot=trace\");\n    builder.init();\n\n    let args: Vec<_> = env::args().collect();\n    let (client_id, redirect_uri, scopes) = if args.len() == 4 {\n        // You can use your own client ID, along with it's associated redirect URI.\n        (\n            args[1].as_str(),\n            args[2].as_str(),\n            args[3].split(',').collect::<Vec<&str>>(),\n        )\n    } else if args.len() == 1 {\n        (SPOTIFY_CLIENT_ID, SPOTIFY_REDIRECT_URI, vec![\"streaming\"])\n    } else {\n        eprintln!(\"Usage: {} [CLIENT_ID REDIRECT_URI SCOPES]\", args[0]);\n        return;\n    };\n\n    let client = match OAuthClientBuilder::new(client_id, redirect_uri, scopes)\n        .open_in_browser()\n        .with_custom_message(RESPONSE)\n        .build()\n    {\n        Ok(client) => client,\n        Err(err) => {\n            eprintln!(\"Unable to build an OAuth client: {err}\");\n            return;\n        }\n    };\n\n    let refresh_token = match client.get_access_token() {\n        Ok(token) => {\n            println!(\"OAuth Token: {token:#?}\");\n            token.refresh_token\n        }\n        Err(err) => {\n            println!(\"Unable to get OAuth Token: {err}\");\n            return;\n        }\n    };\n\n    match client.refresh_token(&refresh_token) {\n        Ok(token) => println!(\"New refreshed OAuth Token: {token:#?}\"),\n        Err(err) => println!(\"Unable to get refreshed OAuth Token: {err}\"),\n    }\n}\n"
  },
  {
    "path": "oauth/src/lib.rs",
    "content": "#![warn(missing_docs)]\n//! Provides a Spotify access token using the OAuth authorization code flow\n//! with PKCE.\n//!\n//! Assuming sufficient scopes, the returned access token may be used with Spotify's\n//! Web API, and/or to establish a new Session with [`librespot_core`].\n//!\n//! The authorization code flow is an interactive process which requires a web browser\n//! to complete. The resulting code must then be provided back from the browser to this\n//! library for exchange into an access token. Providing the code can be automatic via\n//! a spawned http server (mimicking Spotify's client), or manually via stdin. The latter\n//! is appropriate for headless systems.\n\nuse std::{\n    io::{self, BufRead, BufReader, Write},\n    net::{SocketAddr, TcpListener},\n    sync::mpsc,\n    time::{Duration, Instant},\n};\n\nuse oauth2::{\n    AuthUrl, AuthorizationCode, ClientId, CsrfToken, EmptyExtraTokenFields, EndpointNotSet,\n    EndpointSet, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, RefreshToken, Scope,\n    StandardTokenResponse, TokenResponse, TokenUrl, basic::BasicClient, basic::BasicTokenType,\n};\n\nuse log::{error, info, trace};\nuse thiserror::Error;\nuse url::Url;\n\n// TLS Feature Validation\n//\n// These compile-time checks are placed in the oauth crate rather than core for a specific reason:\n// oauth is at the bottom of the dependency tree (even librespot-core depends on librespot-oauth),\n// which means it gets compiled first. This ensures TLS feature conflicts are detected early in\n// the build process, providing immediate feedback to users rather than failing later during\n// core compilation.\n//\n// The dependency chain is: workspace -> core -> oauth\n// So oauth's feature validation runs before core's, catching configuration errors quickly.\n\n#[cfg(all(feature = \"native-tls\", feature = \"__rustls\"))]\ncompile_error!(\n    \"Feature \\\"native-tls\\\" is mutually exclusive with \\\"rustls-tls-native-roots\\\" and \\\"rustls-tls-webpki-roots\\\". Enable only one.\"\n);\n\n#[cfg(not(any(feature = \"native-tls\", feature = \"__rustls\")))]\ncompile_error!(\n    \"Either feature \\\"native-tls\\\" (default), \\\"rustls-tls-native-roots\\\" or \\\"rustls-tls-webpki-roots\\\" must be enabled for this crate.\"\n);\n\n/// Possible errors encountered during the OAuth authentication flow.\n#[derive(Debug, Error)]\npub enum OAuthError {\n    /// The redirect URI cannot be parsed as a valid URL.\n    #[error(\"Unable to parse redirect URI {uri} ({e})\")]\n    AuthCodeBadUri {\n        /// Auth URI.\n        uri: String,\n        /// Inner error code.\n        e: url::ParseError,\n    },\n\n    /// The authorization code parameter is missing in the redirect URI.\n    #[error(\"Auth code param not found in URI {uri}\")]\n    AuthCodeNotFound {\n        /// Auth URI.\n        uri: String,\n    },\n\n    /// Failed to read input from standard input when manually collecting auth code.\n    #[error(\"Failed to read redirect URI from stdin\")]\n    AuthCodeStdinRead,\n\n    /// Could not bind TCP listener to the specified socket address for OAuth callback.\n    #[error(\"Failed to bind server to {addr} ({e})\")]\n    AuthCodeListenerBind {\n        /// Callback address.\n        addr: SocketAddr,\n        /// Inner error code.\n        e: io::Error,\n    },\n\n    /// Listener terminated before receiving an OAuth callback connection.\n    #[error(\"Listener terminated without accepting a connection\")]\n    AuthCodeListenerTerminated,\n\n    /// Failed to read incoming HTTP request containing OAuth callback.\n    #[error(\"Failed to read redirect URI from HTTP request\")]\n    AuthCodeListenerRead,\n\n    /// Received malformed HTTP request for OAuth callback.\n    #[error(\"Failed to parse redirect URI from HTTP request\")]\n    AuthCodeListenerParse,\n\n    /// Could not send HTTP response after handling OAuth callback.\n    #[error(\"Failed to write HTTP response\")]\n    AuthCodeListenerWrite,\n\n    /// Invalid Spotify authorization endpoint URL.\n    #[error(\"Invalid Spotify OAuth URI\")]\n    InvalidSpotifyUri,\n\n    /// Redirect URI failed validation.\n    #[error(\"Invalid Redirect URI {uri} ({e})\")]\n    InvalidRedirectUri {\n        /// Auth URI.\n        uri: String,\n        /// Inner error code\n        e: url::ParseError,\n    },\n\n    /// Channel communication failure.\n    #[error(\"Failed to receive code\")]\n    Recv,\n\n    /// Token exchange failure with Spotify's authorization server.\n    #[error(\"Failed to exchange code for access token ({e})\")]\n    ExchangeCode {\n        /// Inner error description\n        e: String,\n    },\n}\n\n/// Represents an OAuth token used for accessing Spotify's Web API and sessions.\n#[derive(Debug, Clone)]\npub struct OAuthToken {\n    /// Bearer token used for authenticated Spotify API requests\n    pub access_token: String,\n    /// Long-lived token used to obtain new access tokens\n    pub refresh_token: String,\n    /// Instant when the access token becomes invalid\n    pub expires_at: Instant,\n    /// Type of token\n    pub token_type: String,\n    /// Permission scopes granted by this token\n    pub scopes: Vec<String>,\n}\n\n/// Return code query-string parameter from the redirect URI.\nfn get_code(redirect_url: &str) -> Result<AuthorizationCode, OAuthError> {\n    let url = Url::parse(redirect_url).map_err(|e| OAuthError::AuthCodeBadUri {\n        uri: redirect_url.to_string(),\n        e,\n    })?;\n    let code = url\n        .query_pairs()\n        .find(|(key, _)| key == \"code\")\n        .map(|(_, code)| AuthorizationCode::new(code.into_owned()))\n        .ok_or(OAuthError::AuthCodeNotFound {\n            uri: redirect_url.to_string(),\n        })?;\n\n    Ok(code)\n}\n\n/// Prompt for redirect URI on stdin and return auth code.\nfn get_authcode_stdin() -> Result<AuthorizationCode, OAuthError> {\n    println!(\"Provide redirect URL\");\n    let mut buffer = String::new();\n    let stdin = io::stdin();\n    stdin\n        .read_line(&mut buffer)\n        .map_err(|_| OAuthError::AuthCodeStdinRead)?;\n\n    get_code(buffer.trim())\n}\n\n/// Spawn HTTP server at provided socket address to accept OAuth callback and return auth code.\nfn get_authcode_listener(\n    socket_address: SocketAddr,\n    message: String,\n) -> Result<AuthorizationCode, OAuthError> {\n    let listener =\n        TcpListener::bind(socket_address).map_err(|e| OAuthError::AuthCodeListenerBind {\n            addr: socket_address,\n            e,\n        })?;\n    info!(\"OAuth server listening on {socket_address:?}\");\n\n    // The server will terminate itself after collecting the first code.\n    let mut stream = listener\n        .incoming()\n        .flatten()\n        .next()\n        .ok_or(OAuthError::AuthCodeListenerTerminated)?;\n    let mut reader = BufReader::new(&stream);\n    let mut request_line = String::new();\n    reader\n        .read_line(&mut request_line)\n        .map_err(|_| OAuthError::AuthCodeListenerRead)?;\n\n    let redirect_url = request_line\n        .split_whitespace()\n        .nth(1)\n        .ok_or(OAuthError::AuthCodeListenerParse)?;\n    let code = get_code(&(\"http://localhost\".to_string() + redirect_url));\n\n    let response = format!(\n        \"HTTP/1.1 200 OK\\r\\ncontent-length: {}\\r\\n\\r\\n{}\",\n        message.len(),\n        message\n    );\n    stream\n        .write_all(response.as_bytes())\n        .map_err(|_| OAuthError::AuthCodeListenerWrite)?;\n\n    code\n}\n\n// If the specified `redirect_uri` is HTTP and contains a port,\n// then the corresponding socket address is returned.\nfn get_socket_address(redirect_uri: &str) -> Option<SocketAddr> {\n    let url = match Url::parse(redirect_uri) {\n        Ok(u) if u.scheme() == \"http\" && u.port().is_some() => u,\n        _ => return None,\n    };\n    match url.socket_addrs(|| None) {\n        Ok(mut addrs) => addrs.pop(),\n        _ => None,\n    }\n}\n\n/// Struct that handle obtaining and refreshing access tokens.\npub struct OAuthClient {\n    scopes: Vec<String>,\n    redirect_uri: String,\n    should_open_url: bool,\n    message: String,\n    client: BasicClient<EndpointSet, EndpointNotSet, EndpointNotSet, EndpointNotSet, EndpointSet>,\n}\n\nimpl OAuthClient {\n    /// Generates and opens/shows the authorization URL to obtain an access token.\n    ///\n    /// Returns a verifier that must be included in the final request for validation.\n    fn set_auth_url(&self) -> PkceCodeVerifier {\n        let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();\n        // Generate the full authorization URL.\n        // Some of these scopes are unavailable for custom client IDs. Which?\n        let request_scopes: Vec<oauth2::Scope> =\n            self.scopes.iter().map(|s| Scope::new(s.into())).collect();\n        let (auth_url, _) = self\n            .client\n            .authorize_url(CsrfToken::new_random)\n            .add_scopes(request_scopes)\n            .set_pkce_challenge(pkce_challenge)\n            .url();\n\n        if self.should_open_url {\n            open::that_in_background(auth_url.as_str());\n        }\n        println!(\"Browse to: {auth_url}\");\n\n        pkce_verifier\n    }\n\n    fn build_token(\n        &self,\n        resp: StandardTokenResponse<EmptyExtraTokenFields, BasicTokenType>,\n    ) -> Result<OAuthToken, OAuthError> {\n        trace!(\"Obtained new access token: {resp:?}\");\n\n        let token_scopes: Vec<String> = match resp.scopes() {\n            Some(s) => s.iter().map(|s| s.to_string()).collect(),\n            _ => self.scopes.clone(),\n        };\n        let refresh_token = match resp.refresh_token() {\n            Some(t) => t.secret().to_string(),\n            _ => \"\".to_string(), // Spotify always provides a refresh token.\n        };\n        Ok(OAuthToken {\n            access_token: resp.access_token().secret().to_string(),\n            refresh_token,\n            expires_at: Instant::now()\n                + resp\n                    .expires_in()\n                    .unwrap_or_else(|| Duration::from_secs(3600)),\n            token_type: format!(\"{:?}\", resp.token_type()),\n            scopes: token_scopes,\n        })\n    }\n\n    /// Syncronously obtain a Spotify access token using the authorization code with PKCE OAuth flow.\n    pub fn get_access_token(&self) -> Result<OAuthToken, OAuthError> {\n        let pkce_verifier = self.set_auth_url();\n\n        let code = match get_socket_address(&self.redirect_uri) {\n            Some(addr) => get_authcode_listener(addr, self.message.clone()),\n            _ => get_authcode_stdin(),\n        }?;\n        trace!(\"Exchange {code:?} for access token\");\n\n        let (tx, rx) = mpsc::channel();\n        let client = self.client.clone();\n        std::thread::spawn(move || {\n            let http_client = reqwest::blocking::Client::new();\n            let resp = client\n                .exchange_code(code)\n                .set_pkce_verifier(pkce_verifier)\n                .request(&http_client);\n            if let Err(e) = tx.send(resp) {\n                error!(\"OAuth channel send error: {e}\");\n            }\n        });\n        let channel_response = rx.recv().map_err(|_| OAuthError::Recv)?;\n        let token_response =\n            channel_response.map_err(|e| OAuthError::ExchangeCode { e: e.to_string() })?;\n\n        self.build_token(token_response)\n    }\n\n    /// Synchronously obtain a new valid OAuth token from `refresh_token`\n    pub fn refresh_token(&self, refresh_token: &str) -> Result<OAuthToken, OAuthError> {\n        let refresh_token = RefreshToken::new(refresh_token.to_string());\n        let http_client = reqwest::blocking::Client::new();\n        let resp = self\n            .client\n            .exchange_refresh_token(&refresh_token)\n            .request(&http_client);\n\n        let resp = resp.map_err(|e| OAuthError::ExchangeCode { e: e.to_string() })?;\n        self.build_token(resp)\n    }\n\n    /// Asyncronously obtain a Spotify access token using the authorization code with PKCE OAuth flow.\n    pub async fn get_access_token_async(&self) -> Result<OAuthToken, OAuthError> {\n        let pkce_verifier = self.set_auth_url();\n\n        let code = match get_socket_address(&self.redirect_uri) {\n            Some(addr) => get_authcode_listener(addr, self.message.clone()),\n            _ => get_authcode_stdin(),\n        }?;\n        trace!(\"Exchange {code:?} for access token\");\n\n        let http_client = reqwest::Client::new();\n        let resp = self\n            .client\n            .exchange_code(code)\n            .set_pkce_verifier(pkce_verifier)\n            .request_async(&http_client)\n            .await;\n\n        let resp = resp.map_err(|e| OAuthError::ExchangeCode { e: e.to_string() })?;\n        self.build_token(resp)\n    }\n\n    /// Asynchronously obtain a new valid OAuth token from `refresh_token`\n    pub async fn refresh_token_async(&self, refresh_token: &str) -> Result<OAuthToken, OAuthError> {\n        let refresh_token = RefreshToken::new(refresh_token.to_string());\n        let http_client = reqwest::Client::new();\n        let resp = self\n            .client\n            .exchange_refresh_token(&refresh_token)\n            .request_async(&http_client)\n            .await;\n\n        let resp = resp.map_err(|e| OAuthError::ExchangeCode { e: e.to_string() })?;\n        self.build_token(resp)\n    }\n}\n\n/// Builder struct through which structures of type OAuthClient are instantiated.\npub struct OAuthClientBuilder {\n    client_id: String,\n    redirect_uri: String,\n    scopes: Vec<String>,\n    should_open_url: bool,\n    message: String,\n}\n\nimpl OAuthClientBuilder {\n    /// Create a new OAuthClientBuilder with provided params and default config.\n    ///\n    /// `redirect_uri` must match to the registered Uris of `client_id`\n    pub fn new(client_id: &str, redirect_uri: &str, scopes: Vec<&str>) -> Self {\n        Self {\n            client_id: client_id.to_string(),\n            redirect_uri: redirect_uri.to_string(),\n            scopes: scopes.into_iter().map(Into::into).collect(),\n            should_open_url: false,\n            message: String::from(\"Go back to your terminal :)\"),\n        }\n    }\n\n    /// When this function is added to the building process pipeline, the auth url will be\n    /// opened with the default web browser. Otherwise, it will be printed to standard output.\n    pub fn open_in_browser(mut self) -> Self {\n        self.should_open_url = true;\n        self\n    }\n\n    /// When this function is added to the building process pipeline, the body of the response to\n    /// the callback request will be `message`. This is useful to load custom HTMLs to that &str.\n    pub fn with_custom_message(mut self, message: &str) -> Self {\n        self.message = message.to_string();\n        self\n    }\n\n    /// End of the building process pipeline. If Ok, a OAuthClient instance will be returned.\n    pub fn build(self) -> Result<OAuthClient, OAuthError> {\n        let auth_url = AuthUrl::new(\"https://accounts.spotify.com/authorize\".to_string())\n            .map_err(|_| OAuthError::InvalidSpotifyUri)?;\n        let token_url = TokenUrl::new(\"https://accounts.spotify.com/api/token\".to_string())\n            .map_err(|_| OAuthError::InvalidSpotifyUri)?;\n        let redirect_url = RedirectUrl::new(self.redirect_uri.clone()).map_err(|e| {\n            OAuthError::InvalidRedirectUri {\n                uri: self.redirect_uri.clone(),\n                e,\n            }\n        })?;\n\n        let client = BasicClient::new(ClientId::new(self.client_id.to_string()))\n            .set_auth_uri(auth_url)\n            .set_token_uri(token_url)\n            .set_redirect_uri(redirect_url);\n\n        Ok(OAuthClient {\n            scopes: self.scopes,\n            should_open_url: self.should_open_url,\n            message: self.message,\n            redirect_uri: self.redirect_uri,\n            client,\n        })\n    }\n}\n\n/// Obtain a Spotify access token using the authorization code with PKCE OAuth flow.\n/// The `redirect_uri` must match what is registered to the client ID.\n#[deprecated(\n    since = \"0.7.0\",\n    note = \"please use builder pattern with `OAuthClientBuilder` instead\"\n)]\n/// Obtain a Spotify access token using the authorization code with PKCE OAuth flow.\n/// The redirect_uri must match what is registered to the client ID.\npub fn get_access_token(\n    client_id: &str,\n    redirect_uri: &str,\n    scopes: Vec<&str>,\n) -> Result<OAuthToken, OAuthError> {\n    let auth_url = AuthUrl::new(\"https://accounts.spotify.com/authorize\".to_string())\n        .map_err(|_| OAuthError::InvalidSpotifyUri)?;\n    let token_url = TokenUrl::new(\"https://accounts.spotify.com/api/token\".to_string())\n        .map_err(|_| OAuthError::InvalidSpotifyUri)?;\n    let redirect_url =\n        RedirectUrl::new(redirect_uri.to_string()).map_err(|e| OAuthError::InvalidRedirectUri {\n            uri: redirect_uri.to_string(),\n            e,\n        })?;\n    let client = BasicClient::new(ClientId::new(client_id.to_string()))\n        .set_auth_uri(auth_url)\n        .set_token_uri(token_url)\n        .set_redirect_uri(redirect_url);\n\n    let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();\n\n    // Generate the full authorization URL.\n    // Some of these scopes are unavailable for custom client IDs. Which?\n    let request_scopes: Vec<oauth2::Scope> = scopes\n        .clone()\n        .into_iter()\n        .map(|s| Scope::new(s.into()))\n        .collect();\n    let (auth_url, _) = client\n        .authorize_url(CsrfToken::new_random)\n        .add_scopes(request_scopes)\n        .set_pkce_challenge(pkce_challenge)\n        .url();\n\n    println!(\"Browse to: {auth_url}\");\n\n    let code = match get_socket_address(redirect_uri) {\n        Some(addr) => get_authcode_listener(addr, String::from(\"Go back to your terminal :)\")),\n        _ => get_authcode_stdin(),\n    }?;\n    trace!(\"Exchange {code:?} for access token\");\n\n    // Do this sync in another thread because I am too stupid to make the async version work.\n    let (tx, rx) = mpsc::channel();\n    std::thread::spawn(move || {\n        let http_client = reqwest::blocking::Client::new();\n        let resp = client\n            .exchange_code(code)\n            .set_pkce_verifier(pkce_verifier)\n            .request(&http_client);\n        if let Err(e) = tx.send(resp) {\n            error!(\"OAuth channel send error: {e}\");\n        }\n    });\n    let token_response = rx.recv().map_err(|_| OAuthError::Recv)?;\n    let token = token_response.map_err(|e| OAuthError::ExchangeCode { e: e.to_string() })?;\n    trace!(\"Obtained new access token: {token:?}\");\n\n    let token_scopes: Vec<String> = match token.scopes() {\n        Some(s) => s.iter().map(|s| s.to_string()).collect(),\n        _ => scopes.into_iter().map(Into::into).collect(),\n    };\n    let refresh_token = match token.refresh_token() {\n        Some(t) => t.secret().to_string(),\n        _ => \"\".to_string(), // Spotify always provides a refresh token.\n    };\n    Ok(OAuthToken {\n        access_token: token.access_token().secret().to_string(),\n        refresh_token,\n        expires_at: Instant::now()\n            + token\n                .expires_in()\n                .unwrap_or_else(|| Duration::from_secs(3600)),\n        token_type: format!(\"{:?}\", token.token_type()).to_string(), // Urgh!?\n        scopes: token_scopes,\n    })\n}\n\n#[cfg(test)]\nmod test {\n    use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};\n\n    use super::*;\n\n    #[test]\n    fn get_socket_address_none() {\n        // No port\n        assert_eq!(get_socket_address(\"http://127.0.0.1/foo\"), None);\n        assert_eq!(get_socket_address(\"http://127.0.0.1:/foo\"), None);\n        assert_eq!(get_socket_address(\"http://[::1]/foo\"), None);\n        // Not http\n        assert_eq!(get_socket_address(\"https://127.0.0.1/foo\"), None);\n    }\n\n    #[test]\n    fn get_socket_address_some() {\n        let localhost_v4 = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 1234);\n        let localhost_v6 = SocketAddr::new(IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 0, 0, 0, 1)), 8888);\n        let addr_v4 = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8)), 1234);\n        let addr_v6 = SocketAddr::new(\n            IpAddr::V6(Ipv6Addr::new(0x2001, 0x4860, 0x4860, 0, 0, 0, 0, 0x8888)),\n            8888,\n        );\n\n        // Loopback addresses\n        assert_eq!(\n            get_socket_address(\"http://127.0.0.1:1234/foo\"),\n            Some(localhost_v4)\n        );\n        assert_eq!(\n            get_socket_address(\"http://[0:0:0:0:0:0:0:1]:8888/foo\"),\n            Some(localhost_v6)\n        );\n        assert_eq!(\n            get_socket_address(\"http://[::1]:8888/foo\"),\n            Some(localhost_v6)\n        );\n\n        // Non-loopback addresses\n        assert_eq!(get_socket_address(\"http://8.8.8.8:1234/foo\"), Some(addr_v4));\n        assert_eq!(\n            get_socket_address(\"http://[2001:4860:4860::8888]:8888/foo\"),\n            Some(addr_v6)\n        );\n    }\n}\n"
  },
  {
    "path": "playback/Cargo.toml",
    "content": "[package]\nname = \"librespot-playback\"\nversion = \"0.8.0\"\nrust-version.workspace = true\nauthors = [\"Sasha Hilton <sashahilton00@gmail.com>\"]\nlicense.workspace = true\ndescription = \"The audio playback logic for librespot\"\nrepository.workspace = true\nedition.workspace = true\n\n[features]\n# Refer to the workspace Cargo.toml for the list of features\ndefault = [\"rodio-backend\", \"native-tls\"]\n\n# Audio backends\nalsa-backend = [\"dep:alsa\"]\ngstreamer-backend = [\n    \"dep:gstreamer\",\n    \"dep:gstreamer-app\",\n    \"dep:gstreamer-audio\",\n]\njackaudio-backend = [\"dep:jack\"]\nportaudio-backend = [\"dep:portaudio-rs\"]\npulseaudio-backend = [\"dep:libpulse-binding\", \"dep:libpulse-simple-binding\"]\nrodio-backend = [\"dep:cpal\", \"dep:rodio\"]\nrodiojack-backend = [\"dep:rodio\", \"cpal/jack\"]\nsdl-backend = [\"dep:sdl2\"]\n\n# Audio processing features\npassthrough-decoder = [\"dep:ogg\"]\n\n# TLS backend propagation\nnative-tls = [\n    \"librespot-core/native-tls\",\n    \"librespot-audio/native-tls\",\n    \"librespot-metadata/native-tls\",\n]\nrustls-tls-native-roots = [\n    \"librespot-core/rustls-tls-native-roots\",\n    \"librespot-audio/rustls-tls-native-roots\",\n    \"librespot-metadata/rustls-tls-native-roots\",\n]\nrustls-tls-webpki-roots = [\n    \"librespot-core/rustls-tls-webpki-roots\",\n    \"librespot-audio/rustls-tls-webpki-roots\",\n    \"librespot-metadata/rustls-tls-webpki-roots\",\n]\n\n[dependencies]\nlibrespot-audio = { version = \"0.8.0\", path = \"../audio\", default-features = false }\nlibrespot-core = { version = \"0.8.0\", path = \"../core\", default-features = false }\nlibrespot-metadata = { version = \"0.8.0\", path = \"../metadata\", default-features = false }\n\nfutures-util = { version = \"0.3\", default-features = false, features = [\"std\"] }\nlog = \"0.4\"\nportable-atomic = \"1\"\nshell-words = \"1.1\"\nthiserror = \"2\"\ntokio = { version = \"1\", features = [\"rt-multi-thread\", \"sync\"] }\nzerocopy = { version = \"0.8\", features = [\"derive\"] }\n\n# Backends\nalsa = { version = \"0.10\", optional = true }\njack = { version = \"0.13\", optional = true }\nportaudio-rs = { version = \"0.3\", optional = true }\nsdl2 = { version = \"0.38\", optional = true }\n\n# GStreamer dependencies\ngstreamer = { version = \"0.24\", optional = true }\ngstreamer-app = { version = \"0.24\", optional = true }\ngstreamer-audio = { version = \"0.24\", optional = true }\n\n# PulseAudio dependencies\nlibpulse-binding = { version = \"2\", optional = true, default-features = false }\nlibpulse-simple-binding = { version = \"2\", optional = true, default-features = false }\n\n# Rodio dependencies\ncpal = { version = \"0.16\", optional = true }\nrodio = { version = \"0.21\", optional = true, default-features = false, features = [\n    \"playback\",\n] }\n\n# Container and audio decoder\nsymphonia = { version = \"0.5\", default-features = false, features = [\n    \"mp3\",\n    \"ogg\",\n    \"vorbis\",\n    \"flac\"\n] }\n\n# Legacy Ogg container decoder for the passthrough decoder\nogg = { version = \"0.9\", optional = true }\n\n# Dithering\nrand = { version = \"0.9\", default-features = false, features = [\"small_rng\"] }\nrand_distr = \"0.5\"\n\n# Local file handling\nform_urlencoded = \"1.2.2\"\n\n[lints]\nworkspace = true\n"
  },
  {
    "path": "playback/src/audio_backend/alsa.rs",
    "content": "use super::{Open, Sink, SinkAsBytes, SinkError, SinkResult};\nuse crate::config::AudioFormat;\nuse crate::convert::Converter;\nuse crate::decoder::AudioPacket;\nuse crate::{NUM_CHANNELS, SAMPLE_RATE};\nuse alsa::device_name::HintIter;\nuse alsa::pcm::{Access, Format, Frames, HwParams, PCM};\nuse alsa::{Direction, ValueOr};\nuse std::process::exit;\nuse thiserror::Error;\n\nconst MAX_BUFFER: Frames = (SAMPLE_RATE / 2) as Frames;\nconst MIN_BUFFER: Frames = (SAMPLE_RATE / 10) as Frames;\nconst ZERO_FRAMES: Frames = 0;\n\nconst MAX_PERIOD_DIVISOR: Frames = 4;\nconst MIN_PERIOD_DIVISOR: Frames = 10;\n\n#[derive(Debug, Error)]\nenum AlsaError {\n    #[error(\"<AlsaSink> Device {device} Unsupported Format {alsa_format:?} ({format:?}), {e}\")]\n    UnsupportedFormat {\n        device: String,\n        alsa_format: Format,\n        format: AudioFormat,\n        e: alsa::Error,\n    },\n\n    #[error(\"<AlsaSink> Device {device} Unsupported Channel Count {channel_count}, {e}\")]\n    UnsupportedChannelCount {\n        device: String,\n        channel_count: u8,\n        e: alsa::Error,\n    },\n\n    #[error(\"<AlsaSink> Device {device} Unsupported Sample Rate {samplerate}, {e}\")]\n    UnsupportedSampleRate {\n        device: String,\n        samplerate: u32,\n        e: alsa::Error,\n    },\n\n    #[error(\"<AlsaSink> Device {device} Unsupported Access Type RWInterleaved, {e}\")]\n    UnsupportedAccessType { device: String, e: alsa::Error },\n\n    #[error(\"<AlsaSink> Device {device} May be Invalid, Busy, or Already in Use, {e}\")]\n    PcmSetUp { device: String, e: alsa::Error },\n\n    #[error(\"<AlsaSink> Failed to Drain PCM Buffer, {0}\")]\n    DrainFailure(alsa::Error),\n\n    #[error(\"<AlsaSink> {0}\")]\n    OnWrite(alsa::Error),\n\n    #[error(\"<AlsaSink> Hardware, {0}\")]\n    HwParams(alsa::Error),\n\n    #[error(\"<AlsaSink> Software, {0}\")]\n    SwParams(alsa::Error),\n\n    #[error(\"<AlsaSink> PCM, {0}\")]\n    Pcm(alsa::Error),\n\n    #[error(\"<AlsaSink> Could Not Parse Output Name(s) and/or Description(s), {0}\")]\n    Parsing(alsa::Error),\n\n    #[error(\"<AlsaSink>\")]\n    NotConnected,\n}\n\nimpl From<AlsaError> for SinkError {\n    fn from(e: AlsaError) -> SinkError {\n        use AlsaError::*;\n        let es = e.to_string();\n        match e {\n            DrainFailure(_) | OnWrite(_) => SinkError::OnWrite(es),\n            PcmSetUp { .. } => SinkError::ConnectionRefused(es),\n            NotConnected => SinkError::NotConnected(es),\n            _ => SinkError::InvalidParams(es),\n        }\n    }\n}\n\nimpl From<AudioFormat> for Format {\n    fn from(f: AudioFormat) -> Format {\n        use AudioFormat::*;\n        match f {\n            F64 => Format::float64(),\n            F32 => Format::float(),\n            S32 => Format::s32(),\n            S24 => Format::s24(),\n            S24_3 => Format::s24_3(),\n            S16 => Format::s16(),\n        }\n    }\n}\n\npub struct AlsaSink {\n    pcm: Option<PCM>,\n    format: AudioFormat,\n    device: String,\n    period_buffer: Vec<u8>,\n}\n\nfn list_compatible_devices() -> SinkResult<()> {\n    let i = HintIter::new_str(None, \"pcm\").map_err(AlsaError::Parsing)?;\n\n    println!(\"\\n\\n\\tCompatible alsa device(s):\\n\");\n    println!(\"\\t------------------------------------------------------\\n\");\n\n    for a in i {\n        if let Some(Direction::Playback) = a.direction {\n            if let Some(name) = a.name {\n                if let Ok(pcm) = PCM::new(&name, Direction::Playback, false) {\n                    if let Ok(hwp) = HwParams::any(&pcm) {\n                        // Only show devices that support\n                        // 2 ch 44.1 Interleaved.\n\n                        if hwp.set_access(Access::RWInterleaved).is_ok()\n                            && hwp.set_rate(SAMPLE_RATE, ValueOr::Nearest).is_ok()\n                            && hwp.set_channels(NUM_CHANNELS as u32).is_ok()\n                        {\n                            let mut supported_formats = vec![];\n\n                            for f in &[\n                                AudioFormat::S16,\n                                AudioFormat::S24,\n                                AudioFormat::S24_3,\n                                AudioFormat::S32,\n                                AudioFormat::F32,\n                                AudioFormat::F64,\n                            ] {\n                                if hwp.test_format(Format::from(*f)).is_ok() {\n                                    supported_formats.push(format!(\"{f:?}\"));\n                                }\n                            }\n\n                            if !supported_formats.is_empty() {\n                                println!(\"\\tDevice:\\n\\n\\t\\t{name}\\n\");\n\n                                println!(\n                                    \"\\tDescription:\\n\\n\\t\\t{}\\n\",\n                                    a.desc.unwrap_or_default().replace('\\n', \"\\n\\t\\t\")\n                                );\n\n                                println!(\n                                    \"\\tSupported Format(s):\\n\\n\\t\\t{}\\n\",\n                                    supported_formats.join(\" \")\n                                );\n\n                                println!(\n                                    \"\\t------------------------------------------------------\\n\"\n                                );\n                            }\n                        }\n                    };\n                }\n            }\n        }\n    }\n\n    Ok(())\n}\n\nfn open_device(dev_name: &str, format: AudioFormat) -> SinkResult<(PCM, usize)> {\n    let pcm = PCM::new(dev_name, Direction::Playback, false).map_err(|e| AlsaError::PcmSetUp {\n        device: dev_name.to_string(),\n        e,\n    })?;\n\n    let bytes_per_period = {\n        let hwp = HwParams::any(&pcm).map_err(AlsaError::HwParams)?;\n\n        hwp.set_access(Access::RWInterleaved)\n            .map_err(|e| AlsaError::UnsupportedAccessType {\n                device: dev_name.to_string(),\n                e,\n            })?;\n\n        let alsa_format = Format::from(format);\n\n        hwp.set_format(alsa_format)\n            .map_err(|e| AlsaError::UnsupportedFormat {\n                device: dev_name.to_string(),\n                alsa_format,\n                format,\n                e,\n            })?;\n\n        hwp.set_rate(SAMPLE_RATE, ValueOr::Nearest).map_err(|e| {\n            AlsaError::UnsupportedSampleRate {\n                device: dev_name.to_string(),\n                samplerate: SAMPLE_RATE,\n                e,\n            }\n        })?;\n\n        hwp.set_channels(NUM_CHANNELS as u32)\n            .map_err(|e| AlsaError::UnsupportedChannelCount {\n                device: dev_name.to_string(),\n                channel_count: NUM_CHANNELS,\n                e,\n            })?;\n\n        // Clone the hwp while it's in\n        // a good working state so that\n        // in the event of an error setting\n        // the buffer and period sizes\n        // we can use the good working clone\n        // instead of the hwp that's in an\n        // error state.\n        let hwp_clone = hwp.clone();\n\n        // At a sampling rate of 44100:\n        // The largest buffer is 22050 Frames (500ms) with 5512 Frame periods (125ms).\n        // The smallest buffer is 4410 Frames (100ms) with 441 Frame periods (10ms).\n        // Actual values may vary.\n        //\n        // Larger buffer and period sizes are preferred as extremely small values\n        // will cause high CPU useage.\n        //\n        // If no buffer or period size is in those ranges or an error happens\n        // trying to set the buffer or period size use the device's defaults\n        // which may not be ideal but are *hopefully* serviceable.\n\n        let buffer_size = {\n            let max = match hwp.get_buffer_size_max() {\n                Err(e) => {\n                    trace!(\"Error getting the device's max Buffer size: {e}\");\n                    ZERO_FRAMES\n                }\n                Ok(s) => s,\n            };\n\n            let min = match hwp.get_buffer_size_min() {\n                Err(e) => {\n                    trace!(\"Error getting the device's min Buffer size: {e}\");\n                    ZERO_FRAMES\n                }\n                Ok(s) => s,\n            };\n\n            let buffer_size = if min < max {\n                match (MIN_BUFFER..=MAX_BUFFER)\n                    .rev()\n                    .find(|f| (min..=max).contains(f))\n                {\n                    Some(size) => {\n                        trace!(\"Desired Frames per Buffer: {size:?}\");\n\n                        match hwp.set_buffer_size_near(size) {\n                            Err(e) => {\n                                trace!(\"Error setting the device's Buffer size: {e}\");\n                                ZERO_FRAMES\n                            }\n                            Ok(s) => s,\n                        }\n                    }\n                    None => {\n                        trace!(\"No Desired Buffer size in range reported by the device.\");\n                        ZERO_FRAMES\n                    }\n                }\n            } else {\n                trace!(\n                    \"The device's min reported Buffer size was greater than or equal to its max reported Buffer size.\"\n                );\n                ZERO_FRAMES\n            };\n\n            if buffer_size == ZERO_FRAMES {\n                trace!(\"Desired Buffer Frame range: {MIN_BUFFER:?} - {MAX_BUFFER:?}\",);\n\n                trace!(\"Actual Buffer Frame range as reported by the device: {min:?} - {max:?}\",);\n            }\n\n            buffer_size\n        };\n\n        let period_size = {\n            if buffer_size == ZERO_FRAMES {\n                ZERO_FRAMES\n            } else {\n                let max = match hwp.get_period_size_max() {\n                    Err(e) => {\n                        trace!(\"Error getting the device's max Period size: {e}\");\n                        ZERO_FRAMES\n                    }\n                    Ok(s) => s,\n                };\n\n                let min = match hwp.get_period_size_min() {\n                    Err(e) => {\n                        trace!(\"Error getting the device's min Period size: {e}\");\n                        ZERO_FRAMES\n                    }\n                    Ok(s) => s,\n                };\n\n                let max_period = buffer_size / MAX_PERIOD_DIVISOR;\n                let min_period = buffer_size / MIN_PERIOD_DIVISOR;\n\n                let period_size = if min < max && min_period < max_period {\n                    match (min_period..=max_period)\n                        .rev()\n                        .find(|f| (min..=max).contains(f))\n                    {\n                        Some(size) => {\n                            trace!(\"Desired Frames per Period: {size:?}\");\n\n                            match hwp.set_period_size_near(size, ValueOr::Nearest) {\n                                Err(e) => {\n                                    trace!(\"Error setting the device's Period size: {e}\");\n                                    ZERO_FRAMES\n                                }\n                                Ok(s) => s,\n                            }\n                        }\n                        None => {\n                            trace!(\"No Desired Period size in range reported by the device.\");\n                            ZERO_FRAMES\n                        }\n                    }\n                } else {\n                    trace!(\n                        \"The device's min reported Period size was greater than or equal to its max reported Period size,\"\n                    );\n                    trace!(\n                        \"or the desired min Period size was greater than or equal to the desired max Period size.\"\n                    );\n                    ZERO_FRAMES\n                };\n\n                if period_size == ZERO_FRAMES {\n                    trace!(\"Buffer size: {buffer_size:?}\");\n\n                    trace!(\n                        \"Desired Period Frame range: {min_period:?} (Buffer size / {MIN_PERIOD_DIVISOR:?}) - {max_period:?} (Buffer size / {MAX_PERIOD_DIVISOR:?})\",\n                    );\n\n                    trace!(\n                        \"Actual Period Frame range as reported by the device: {min:?} - {max:?}\",\n                    );\n                }\n\n                period_size\n            }\n        };\n\n        if buffer_size == ZERO_FRAMES || period_size == ZERO_FRAMES {\n            trace!(\n                \"Failed to set Buffer and/or Period size, falling back to the device's defaults.\"\n            );\n\n            trace!(\"You may experience higher than normal CPU usage and/or audio issues.\");\n\n            pcm.hw_params(&hwp_clone).map_err(AlsaError::Pcm)?;\n        } else {\n            pcm.hw_params(&hwp).map_err(AlsaError::Pcm)?;\n        }\n\n        let hwp = pcm.hw_params_current().map_err(AlsaError::Pcm)?;\n\n        // Don't assume we got what we wanted. Ask to make sure.\n        let frames_per_period = hwp.get_period_size().map_err(AlsaError::HwParams)?;\n\n        let frames_per_buffer = hwp.get_buffer_size().map_err(AlsaError::HwParams)?;\n\n        let swp = pcm.sw_params_current().map_err(AlsaError::Pcm)?;\n\n        swp.set_start_threshold(frames_per_buffer - frames_per_period)\n            .map_err(AlsaError::SwParams)?;\n\n        pcm.sw_params(&swp).map_err(AlsaError::Pcm)?;\n\n        trace!(\"Actual Frames per Buffer: {frames_per_buffer:?}\");\n        trace!(\"Actual Frames per Period: {frames_per_period:?}\");\n\n        // Let ALSA do the math for us.\n        pcm.frames_to_bytes(frames_per_period) as usize\n    };\n\n    trace!(\"Period Buffer size in bytes: {bytes_per_period:?}\");\n\n    Ok((pcm, bytes_per_period))\n}\n\nimpl Open for AlsaSink {\n    fn open(device: Option<String>, format: AudioFormat) -> Self {\n        let name = match device.as_deref() {\n            Some(\"?\") => match list_compatible_devices() {\n                Ok(_) => {\n                    exit(0);\n                }\n                Err(e) => {\n                    error!(\"{e}\");\n                    exit(1);\n                }\n            },\n            Some(device) => device,\n            None => \"default\",\n        }\n        .to_string();\n\n        info!(\"Using AlsaSink with format: {format:?}\");\n\n        Self {\n            pcm: None,\n            format,\n            device: name,\n            period_buffer: vec![],\n        }\n    }\n}\n\nimpl Sink for AlsaSink {\n    fn start(&mut self) -> SinkResult<()> {\n        if self.pcm.is_none() {\n            let (pcm, bytes_per_period) = open_device(&self.device, self.format)?;\n            self.pcm = Some(pcm);\n\n            if self.period_buffer.capacity() != bytes_per_period {\n                self.period_buffer = Vec::with_capacity(bytes_per_period);\n            }\n\n            // Should always match the \"Period Buffer size in bytes: \" trace! message.\n            trace!(\n                \"Period Buffer capacity: {:?}\",\n                self.period_buffer.capacity()\n            );\n        }\n\n        Ok(())\n    }\n\n    fn stop(&mut self) -> SinkResult<()> {\n        if self.pcm.is_some() {\n            // Zero fill the remainder of the period buffer and\n            // write any leftover data before draining the actual PCM buffer.\n            self.period_buffer.resize(self.period_buffer.capacity(), 0);\n            self.write_buf()?;\n\n            let pcm = self.pcm.take().ok_or(AlsaError::NotConnected)?;\n\n            pcm.drain().map_err(AlsaError::DrainFailure)?;\n        }\n\n        Ok(())\n    }\n\n    sink_as_bytes!();\n}\n\nimpl SinkAsBytes for AlsaSink {\n    #[inline]\n    fn write_bytes(&mut self, data: &[u8]) -> SinkResult<()> {\n        let mut start_index = 0;\n        let data_len = data.len();\n        let capacity = self.period_buffer.capacity();\n\n        loop {\n            let data_left = data_len - start_index;\n            let space_left = capacity - self.period_buffer.len();\n            let data_to_buffer = data_left.min(space_left);\n            let end_index = start_index + data_to_buffer;\n\n            self.period_buffer\n                .extend_from_slice(&data[start_index..end_index]);\n\n            if self.period_buffer.len() == capacity {\n                self.write_buf()?;\n            }\n\n            if end_index == data_len {\n                break Ok(());\n            }\n\n            start_index = end_index;\n        }\n    }\n}\n\nimpl AlsaSink {\n    pub const NAME: &'static str = \"alsa\";\n\n    fn write_buf(&mut self) -> SinkResult<()> {\n        if self.pcm.is_some() {\n            let write_result = {\n                let pcm = self.pcm.as_mut().ok_or(AlsaError::NotConnected)?;\n\n                match pcm.io_bytes().writei(&self.period_buffer) {\n                    Ok(_) => Ok(()),\n                    Err(e) => {\n                        // Capture and log the original error as a warning, and then try to recover.\n                        // If recovery fails then forward that error back to player.\n                        warn!(\"Error writing from AlsaSink buffer to PCM, trying to recover, {e}\");\n\n                        pcm.try_recover(e, false).map_err(AlsaError::OnWrite)\n                    }\n                }\n            };\n\n            if let Err(e) = write_result {\n                self.pcm = None;\n                return Err(e.into());\n            }\n        }\n\n        self.period_buffer.clear();\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "playback/src/audio_backend/gstreamer.rs",
    "content": "use std::sync::{Arc, Mutex};\n\nuse gstreamer::{\n    State,\n    event::{FlushStart, FlushStop},\n    prelude::*,\n};\n\nuse gstreamer as gst;\nuse gstreamer_app as gst_app;\nuse gstreamer_audio as gst_audio;\n\nconst GSTREAMER_ASYNC_ERROR_POISON_MSG: &str = \"gstreamer async error mutex should not be poisoned\";\n\nuse super::{Open, Sink, SinkAsBytes, SinkError, SinkResult};\n\nuse crate::{\n    NUM_CHANNELS, SAMPLE_RATE, config::AudioFormat, convert::Converter, decoder::AudioPacket,\n};\n\npub struct GstreamerSink {\n    appsrc: gst_app::AppSrc,\n    bufferpool: gst::BufferPool,\n    pipeline: gst::Pipeline,\n    format: AudioFormat,\n    async_error: Arc<Mutex<Option<String>>>,\n}\n\nimpl Open for GstreamerSink {\n    fn open(device: Option<String>, format: AudioFormat) -> Self {\n        info!(\"Using GStreamer sink with format: {format:?}\");\n        gst::init().expect(\"failed to init GStreamer!\");\n\n        let gst_format = match format {\n            AudioFormat::F64 => gst_audio::AUDIO_FORMAT_F64,\n            AudioFormat::F32 => gst_audio::AUDIO_FORMAT_F32,\n            AudioFormat::S32 => gst_audio::AUDIO_FORMAT_S32,\n            AudioFormat::S24 => gst_audio::AUDIO_FORMAT_S2432,\n            AudioFormat::S24_3 => gst_audio::AUDIO_FORMAT_S24,\n            AudioFormat::S16 => gst_audio::AUDIO_FORMAT_S16,\n        };\n\n        let gst_info = gst_audio::AudioInfo::builder(gst_format, SAMPLE_RATE, NUM_CHANNELS as u32)\n            .build()\n            .expect(\"Failed to create GStreamer audio format\");\n        let gst_caps = gst_info.to_caps().expect(\"Failed to create GStreamer caps\");\n\n        let sample_size = format.size();\n        let gst_bytes = NUM_CHANNELS as usize * 2048 * sample_size;\n\n        let pipeline = gst::Pipeline::new();\n        let appsrc = gst::ElementFactory::make(\"appsrc\")\n            .build()\n            .expect(\"Failed to create GStreamer appsrc element\")\n            .downcast::<gst_app::AppSrc>()\n            .expect(\"couldn't cast AppSrc element at runtime!\");\n        appsrc.set_caps(Some(&gst_caps));\n        appsrc.set_max_bytes(gst_bytes as u64);\n        appsrc.set_block(true);\n\n        let sink = match device {\n            None => {\n                // no need to dither twice; use librespot dithering instead\n                gst::parse::bin_from_description(\n                    \"audioconvert dithering=none ! audioresample ! autoaudiosink\",\n                    true,\n                )\n                .expect(\"Failed to create default GStreamer sink\")\n            }\n            Some(ref x) => gst::parse::bin_from_description(x, true)\n                .expect(\"Failed to create custom GStreamer sink\"),\n        };\n        pipeline\n            .add(&appsrc)\n            .expect(\"Failed to add GStreamer appsrc to pipeline\");\n        pipeline\n            .add(&sink)\n            .expect(\"Failed to add GStreamer sink to pipeline\");\n        appsrc\n            .link(&sink)\n            .expect(\"Failed to link GStreamer source to sink\");\n\n        let bus = pipeline.bus().expect(\"couldn't get bus from pipeline\");\n\n        let bufferpool = gst::BufferPool::new();\n\n        let mut conf = bufferpool.config();\n        conf.set_params(Some(&gst_caps), gst_bytes as u32, 0, 0);\n        bufferpool\n            .set_config(conf)\n            .expect(\"couldn't configure the buffer pool\");\n\n        let async_error = Arc::new(Mutex::new(None));\n        let async_error_clone = async_error.clone();\n\n        bus.set_sync_handler(move |_bus, msg| {\n            match msg.view() {\n                gst::MessageView::Eos(_) => {\n                    println!(\"gst signaled end of stream\");\n\n                    let mut async_error_storage = async_error_clone\n                        .lock()\n                        .expect(GSTREAMER_ASYNC_ERROR_POISON_MSG);\n                    *async_error_storage = Some(String::from(\"gst signaled end of stream\"));\n                }\n                gst::MessageView::Error(err) => {\n                    println!(\n                        \"Error from {:?}: {} ({:?})\",\n                        err.src().map(|s| s.path_string()),\n                        err.error(),\n                        err.debug()\n                    );\n\n                    let mut async_error_storage = async_error_clone\n                        .lock()\n                        .expect(GSTREAMER_ASYNC_ERROR_POISON_MSG);\n                    *async_error_storage = Some(format!(\n                        \"Error from {:?}: {} ({:?})\",\n                        err.src().map(|s| s.path_string()),\n                        err.error(),\n                        err.debug()\n                    ));\n                }\n                _ => (),\n            }\n\n            gst::BusSyncReply::Drop\n        });\n\n        pipeline\n            .set_state(State::Ready)\n            .expect(\"unable to set the pipeline to the `Ready` state\");\n\n        Self {\n            appsrc,\n            bufferpool,\n            pipeline,\n            format,\n            async_error,\n        }\n    }\n}\n\nimpl Sink for GstreamerSink {\n    fn start(&mut self) -> SinkResult<()> {\n        *self\n            .async_error\n            .lock()\n            .expect(GSTREAMER_ASYNC_ERROR_POISON_MSG) = None;\n        self.appsrc.send_event(FlushStop::new(true));\n        self.bufferpool\n            .set_active(true)\n            .map_err(|e| SinkError::StateChange(e.to_string()))?;\n        self.pipeline\n            .set_state(State::Playing)\n            .map_err(|e| SinkError::StateChange(e.to_string()))?;\n        Ok(())\n    }\n\n    fn stop(&mut self) -> SinkResult<()> {\n        *self\n            .async_error\n            .lock()\n            .expect(GSTREAMER_ASYNC_ERROR_POISON_MSG) = None;\n        self.appsrc.send_event(FlushStart::new());\n        self.pipeline\n            .set_state(State::Paused)\n            .map_err(|e| SinkError::StateChange(e.to_string()))?;\n        self.bufferpool\n            .set_active(false)\n            .map_err(|e| SinkError::StateChange(e.to_string()))?;\n        Ok(())\n    }\n\n    sink_as_bytes!();\n}\n\nimpl Drop for GstreamerSink {\n    fn drop(&mut self) {\n        let _ = self.pipeline.set_state(State::Null);\n    }\n}\n\nimpl SinkAsBytes for GstreamerSink {\n    #[inline]\n    fn write_bytes(&mut self, data: &[u8]) -> SinkResult<()> {\n        if let Some(async_error) = &*self\n            .async_error\n            .lock()\n            .expect(GSTREAMER_ASYNC_ERROR_POISON_MSG)\n        {\n            return Err(SinkError::OnWrite(async_error.to_string()));\n        }\n\n        let mut buffer = self\n            .bufferpool\n            .acquire_buffer(None)\n            .map_err(|e| SinkError::OnWrite(e.to_string()))?;\n\n        let mutbuf = buffer.make_mut();\n        mutbuf.set_size(data.len());\n        mutbuf\n            .copy_from_slice(0, data)\n            .map_err(|e| SinkError::OnWrite(e.to_string()))?;\n\n        self.appsrc\n            .push_buffer(buffer)\n            .map_err(|e| SinkError::OnWrite(e.to_string()))?;\n\n        Ok(())\n    }\n}\n\nimpl GstreamerSink {\n    pub const NAME: &'static str = \"gstreamer\";\n}\n"
  },
  {
    "path": "playback/src/audio_backend/jackaudio.rs",
    "content": "use super::{Open, Sink, SinkError, SinkResult};\nuse crate::NUM_CHANNELS;\nuse crate::config::AudioFormat;\nuse crate::convert::Converter;\nuse crate::decoder::AudioPacket;\nuse jack::{\n    AsyncClient, AudioOut, Client, ClientOptions, Control, Port, ProcessHandler, ProcessScope,\n};\nuse std::sync::mpsc::{Receiver, SyncSender, sync_channel};\n\npub struct JackSink {\n    send: SyncSender<f32>,\n    // We have to keep hold of this object, or the Sink can't play...\n    #[allow(dead_code)]\n    active_client: AsyncClient<(), JackData>,\n}\n\npub struct JackData {\n    rec: Receiver<f32>,\n    port_l: Port<AudioOut>,\n    port_r: Port<AudioOut>,\n}\n\nimpl ProcessHandler for JackData {\n    fn process(&mut self, _: &Client, ps: &ProcessScope) -> Control {\n        // get output port buffers\n        let buf_r: &mut [f32] = self.port_r.as_mut_slice(ps);\n        let buf_l: &mut [f32] = self.port_l.as_mut_slice(ps);\n        // get queue iterator\n        let mut queue_iter = self.rec.try_iter();\n\n        for i in 0..buf_r.len() {\n            buf_r[i] = queue_iter.next().unwrap_or(0.0);\n            buf_l[i] = queue_iter.next().unwrap_or(0.0);\n        }\n        Control::Continue\n    }\n}\n\nimpl Open for JackSink {\n    fn open(client_name: Option<String>, format: AudioFormat) -> Self {\n        if format != AudioFormat::F32 {\n            warn!(\"JACK currently does not support {format:?} output\");\n        }\n        info!(\"Using JACK sink with format {:?}\", AudioFormat::F32);\n\n        let client_name = client_name.unwrap_or_else(|| \"librespot\".to_string());\n        let (client, _status) =\n            Client::new(&client_name[..], ClientOptions::NO_START_SERVER).unwrap();\n        let ch_r = client.register_port(\"out_0\", AudioOut::default()).unwrap();\n        let ch_l = client.register_port(\"out_1\", AudioOut::default()).unwrap();\n        // buffer for samples from librespot (~10ms)\n        let (tx, rx) = sync_channel::<f32>(NUM_CHANNELS as usize * 1024 * AudioFormat::F32.size());\n        let jack_data = JackData {\n            rec: rx,\n            port_l: ch_l,\n            port_r: ch_r,\n        };\n        let active_client = AsyncClient::new(client, (), jack_data).unwrap();\n\n        Self {\n            send: tx,\n            active_client,\n        }\n    }\n}\n\nimpl Sink for JackSink {\n    fn write(&mut self, packet: AudioPacket, converter: &mut Converter) -> SinkResult<()> {\n        let samples = packet\n            .samples()\n            .map_err(|e| SinkError::OnWrite(e.to_string()))?;\n\n        let samples_f32: &[f32] = &converter.f64_to_f32(samples);\n        for sample in samples_f32.iter() {\n            let res = self.send.send(*sample);\n            if res.is_err() {\n                error!(\"cannot write to channel\");\n            }\n        }\n        Ok(())\n    }\n}\n\nimpl JackSink {\n    pub const NAME: &'static str = \"jackaudio\";\n}\n"
  },
  {
    "path": "playback/src/audio_backend/mod.rs",
    "content": "use crate::config::AudioFormat;\nuse crate::convert::Converter;\nuse crate::decoder::AudioPacket;\nuse thiserror::Error;\n\n#[derive(Debug, Error)]\npub enum SinkError {\n    #[error(\"Audio Sink Error Not Connected: {0}\")]\n    NotConnected(String),\n    #[error(\"Audio Sink Error Connection Refused: {0}\")]\n    ConnectionRefused(String),\n    #[error(\"Audio Sink Error On Write: {0}\")]\n    OnWrite(String),\n    #[error(\"Audio Sink Error Invalid Parameters: {0}\")]\n    InvalidParams(String),\n    #[error(\"Audio Sink Error Changing State: {0}\")]\n    StateChange(String),\n}\n\npub type SinkResult<T> = Result<T, SinkError>;\n\npub trait Open {\n    fn open(_: Option<String>, format: AudioFormat) -> Self;\n}\n\npub trait Sink {\n    fn start(&mut self) -> SinkResult<()> {\n        Ok(())\n    }\n    fn stop(&mut self) -> SinkResult<()> {\n        Ok(())\n    }\n    fn write(&mut self, packet: AudioPacket, converter: &mut Converter) -> SinkResult<()>;\n}\n\npub type SinkBuilder = fn(Option<String>, AudioFormat) -> Box<dyn Sink>;\n\npub trait SinkAsBytes {\n    fn write_bytes(&mut self, data: &[u8]) -> SinkResult<()>;\n}\n\nfn mk_sink<S: Sink + Open + 'static>(device: Option<String>, format: AudioFormat) -> Box<dyn Sink> {\n    Box::new(S::open(device, format))\n}\n\n// reuse code for various backends\nmacro_rules! sink_as_bytes {\n    () => {\n        #[inline]\n        fn write(&mut self, packet: AudioPacket, converter: &mut Converter) -> SinkResult<()> {\n            use crate::convert::i24;\n            use zerocopy::IntoBytes;\n            match packet {\n                AudioPacket::Samples(samples) => match self.format {\n                    AudioFormat::F64 => self.write_bytes(samples.as_bytes()),\n                    AudioFormat::F32 => {\n                        let samples_f32: &[f32] = &converter.f64_to_f32(&samples);\n                        self.write_bytes(samples_f32.as_bytes())\n                    }\n                    AudioFormat::S32 => {\n                        let samples_s32: &[i32] = &converter.f64_to_s32(&samples);\n                        self.write_bytes(samples_s32.as_bytes())\n                    }\n                    AudioFormat::S24 => {\n                        let samples_s24: &[i32] = &converter.f64_to_s24(&samples);\n                        self.write_bytes(samples_s24.as_bytes())\n                    }\n                    AudioFormat::S24_3 => {\n                        let samples_s24_3: &[i24] = &converter.f64_to_s24_3(&samples);\n                        self.write_bytes(samples_s24_3.as_bytes())\n                    }\n                    AudioFormat::S16 => {\n                        let samples_s16: &[i16] = &converter.f64_to_s16(&samples);\n                        self.write_bytes(samples_s16.as_bytes())\n                    }\n                },\n                AudioPacket::Raw(samples) => self.write_bytes(&samples),\n            }\n        }\n    };\n}\n\n#[cfg(feature = \"alsa-backend\")]\nmod alsa;\n#[cfg(feature = \"alsa-backend\")]\nuse self::alsa::AlsaSink;\n\n#[cfg(feature = \"portaudio-backend\")]\nmod portaudio;\n#[cfg(feature = \"portaudio-backend\")]\nuse self::portaudio::PortAudioSink;\n\n#[cfg(feature = \"pulseaudio-backend\")]\nmod pulseaudio;\n#[cfg(feature = \"pulseaudio-backend\")]\nuse self::pulseaudio::PulseAudioSink;\n\n#[cfg(feature = \"jackaudio-backend\")]\nmod jackaudio;\n#[cfg(feature = \"jackaudio-backend\")]\nuse self::jackaudio::JackSink;\n\n#[cfg(feature = \"gstreamer-backend\")]\nmod gstreamer;\n#[cfg(feature = \"gstreamer-backend\")]\nuse self::gstreamer::GstreamerSink;\n\n#[cfg(any(feature = \"rodio-backend\", feature = \"rodiojack-backend\"))]\nmod rodio;\n#[cfg(feature = \"rodio-backend\")]\nuse self::rodio::RodioSink;\n\n#[cfg(feature = \"sdl-backend\")]\nmod sdl;\n#[cfg(feature = \"sdl-backend\")]\nuse self::sdl::SdlSink;\n\nmod pipe;\nuse self::pipe::StdoutSink;\n\nmod subprocess;\nuse self::subprocess::SubprocessSink;\n\npub const BACKENDS: &[(&str, SinkBuilder)] = &[\n    #[cfg(feature = \"rodio-backend\")]\n    (RodioSink::NAME, rodio::mk_rodio), // default goes first\n    #[cfg(feature = \"alsa-backend\")]\n    (AlsaSink::NAME, mk_sink::<AlsaSink>),\n    #[cfg(feature = \"portaudio-backend\")]\n    (PortAudioSink::NAME, mk_sink::<PortAudioSink<'_>>),\n    #[cfg(feature = \"pulseaudio-backend\")]\n    (PulseAudioSink::NAME, mk_sink::<PulseAudioSink>),\n    #[cfg(feature = \"jackaudio-backend\")]\n    (JackSink::NAME, mk_sink::<JackSink>),\n    #[cfg(feature = \"gstreamer-backend\")]\n    (GstreamerSink::NAME, mk_sink::<GstreamerSink>),\n    #[cfg(feature = \"rodiojack-backend\")]\n    (\"rodiojack\", rodio::mk_rodiojack),\n    #[cfg(feature = \"sdl-backend\")]\n    (SdlSink::NAME, mk_sink::<SdlSink>),\n    (StdoutSink::NAME, mk_sink::<StdoutSink>),\n    (SubprocessSink::NAME, mk_sink::<SubprocessSink>),\n];\n\npub fn find(name: Option<String>) -> Option<SinkBuilder> {\n    if let Some(name) = name {\n        BACKENDS\n            .iter()\n            .find(|backend| name == backend.0)\n            .map(|backend| backend.1)\n    } else {\n        BACKENDS.first().map(|backend| backend.1)\n    }\n}\n"
  },
  {
    "path": "playback/src/audio_backend/pipe.rs",
    "content": "use super::{Open, Sink, SinkAsBytes, SinkError, SinkResult};\nuse crate::config::AudioFormat;\nuse crate::convert::Converter;\nuse crate::decoder::AudioPacket;\n\nuse std::fs::OpenOptions;\nuse std::io::{self, Write};\nuse std::process::exit;\nuse thiserror::Error;\n\n#[derive(Debug, Error)]\nenum StdoutError {\n    #[error(\"<StdoutSink> {0}\")]\n    OnWrite(std::io::Error),\n\n    #[error(\"<StdoutSink> File Path {file} Can Not be Opened and/or Created, {e}\")]\n    OpenFailure { file: String, e: std::io::Error },\n\n    #[error(\"<StdoutSink> Failed to Flush the Output Stream, {0}\")]\n    FlushFailure(std::io::Error),\n\n    #[error(\"<StdoutSink> The Output Stream is None\")]\n    NoOutput,\n}\n\nimpl From<StdoutError> for SinkError {\n    fn from(e: StdoutError) -> SinkError {\n        use StdoutError::*;\n        let es = e.to_string();\n        match e {\n            FlushFailure(_) | OnWrite(_) => SinkError::OnWrite(es),\n            OpenFailure { .. } => SinkError::ConnectionRefused(es),\n            NoOutput => SinkError::NotConnected(es),\n        }\n    }\n}\n\npub struct StdoutSink {\n    output: Option<Box<dyn Write>>,\n    file: Option<String>,\n    format: AudioFormat,\n}\n\nimpl Open for StdoutSink {\n    fn open(file: Option<String>, format: AudioFormat) -> Self {\n        if let Some(\"?\") = file.as_deref() {\n            println!(\n                \"\\nUsage:\\n\\nOutput to stdout:\\n\\n\\t--backend pipe\\n\\nOutput to file:\\n\\n\\t--backend pipe --device {{filename}}\\n\"\n            );\n            exit(0);\n        }\n\n        info!(\"Using StdoutSink (pipe) with format: {format:?}\");\n\n        Self {\n            output: None,\n            file,\n            format,\n        }\n    }\n}\n\nimpl Sink for StdoutSink {\n    fn start(&mut self) -> SinkResult<()> {\n        self.output.get_or_insert({\n            match self.file.as_deref() {\n                Some(file) => Box::new(\n                    OpenOptions::new()\n                        .write(true)\n                        .create(true)\n                        .truncate(true)\n                        .open(file)\n                        .map_err(|e| StdoutError::OpenFailure {\n                            file: file.to_string(),\n                            e,\n                        })?,\n                ),\n                None => Box::new(io::stdout()),\n            }\n        });\n\n        Ok(())\n    }\n\n    fn stop(&mut self) -> SinkResult<()> {\n        self.output\n            .take()\n            .ok_or(StdoutError::NoOutput)?\n            .flush()\n            .map_err(StdoutError::FlushFailure)?;\n\n        Ok(())\n    }\n\n    sink_as_bytes!();\n}\n\nimpl SinkAsBytes for StdoutSink {\n    #[inline]\n    fn write_bytes(&mut self, data: &[u8]) -> SinkResult<()> {\n        self.output\n            .as_deref_mut()\n            .ok_or(StdoutError::NoOutput)?\n            .write_all(data)\n            .map_err(StdoutError::OnWrite)?;\n\n        Ok(())\n    }\n}\n\nimpl StdoutSink {\n    pub const NAME: &'static str = \"pipe\";\n}\n"
  },
  {
    "path": "playback/src/audio_backend/portaudio.rs",
    "content": "use super::{Open, Sink, SinkError, SinkResult};\nuse crate::config::AudioFormat;\nuse crate::convert::Converter;\nuse crate::decoder::AudioPacket;\nuse crate::{NUM_CHANNELS, SAMPLE_RATE};\nuse portaudio_rs::device::{DeviceIndex, DeviceInfo, get_default_output_index};\nuse portaudio_rs::stream::*;\nuse std::process::exit;\nuse std::time::Duration;\n\npub enum PortAudioSink<'a> {\n    F32(\n        Option<portaudio_rs::stream::Stream<'a, f32, f32>>,\n        StreamParameters<f32>,\n    ),\n    S32(\n        Option<portaudio_rs::stream::Stream<'a, i32, i32>>,\n        StreamParameters<i32>,\n    ),\n    S16(\n        Option<portaudio_rs::stream::Stream<'a, i16, i16>>,\n        StreamParameters<i16>,\n    ),\n}\n\nfn output_devices() -> Box<dyn Iterator<Item = (DeviceIndex, DeviceInfo)>> {\n    let count = portaudio_rs::device::get_count().unwrap();\n    let devices = (0..count)\n        .filter_map(|idx| portaudio_rs::device::get_info(idx).map(|info| (idx, info)))\n        .filter(|(_, info)| info.max_output_channels > 0);\n\n    Box::new(devices)\n}\n\nfn list_outputs() {\n    let default = get_default_output_index();\n\n    for (idx, info) in output_devices() {\n        if Some(idx) == default {\n            println!(\"- {} (default)\", info.name);\n        } else {\n            println!(\"- {}\", info.name)\n        }\n    }\n}\n\nfn find_output(device: &str) -> Option<DeviceIndex> {\n    output_devices()\n        .find(|(_, info)| info.name == device)\n        .map(|(idx, _)| idx)\n}\n\nimpl<'a> Open for PortAudioSink<'a> {\n    fn open(device: Option<String>, format: AudioFormat) -> PortAudioSink<'a> {\n        info!(\"Using PortAudio sink with format: {format:?}\");\n\n        portaudio_rs::initialize().unwrap();\n\n        let device_idx = match device.as_deref() {\n            Some(\"?\") => {\n                list_outputs();\n                exit(0)\n            }\n            Some(device) => find_output(device),\n            None => get_default_output_index(),\n        }\n        .expect(\"could not find device\");\n\n        let info = portaudio_rs::device::get_info(device_idx);\n        let latency = match info {\n            Some(info) => info.default_high_output_latency,\n            None => Duration::new(0, 0),\n        };\n\n        macro_rules! open_sink {\n            ($sink: expr, $type: ty) => {{\n                let params = StreamParameters {\n                    device: device_idx,\n                    channel_count: NUM_CHANNELS as u32,\n                    suggested_latency: latency,\n                    data: 0.0 as $type,\n                };\n                $sink(None, params)\n            }};\n        }\n        match format {\n            AudioFormat::F32 => open_sink!(Self::F32, f32),\n            AudioFormat::S32 => open_sink!(Self::S32, i32),\n            AudioFormat::S16 => open_sink!(Self::S16, i16),\n            _ => {\n                unimplemented!(\"PortAudio currently does not support {format:?} output\")\n            }\n        }\n    }\n}\n\nimpl Sink for PortAudioSink<'_> {\n    fn start(&mut self) -> SinkResult<()> {\n        macro_rules! start_sink {\n            (ref mut $stream: ident, ref $parameters: ident) => {{\n                if $stream.is_none() {\n                    *$stream = Some(\n                        Stream::open(\n                            None,\n                            Some(*$parameters),\n                            SAMPLE_RATE as f64,\n                            FRAMES_PER_BUFFER_UNSPECIFIED,\n                            StreamFlags::DITHER_OFF, // no need to dither twice; use librespot dithering instead\n                            None,\n                        )\n                        .unwrap(),\n                    );\n                }\n                $stream.as_mut().unwrap().start().unwrap()\n            }};\n        }\n\n        match self {\n            Self::F32(stream, parameters) => start_sink!(ref mut stream, ref parameters),\n            Self::S32(stream, parameters) => start_sink!(ref mut stream, ref parameters),\n            Self::S16(stream, parameters) => start_sink!(ref mut stream, ref parameters),\n        };\n\n        Ok(())\n    }\n\n    fn stop(&mut self) -> SinkResult<()> {\n        macro_rules! stop_sink {\n            (ref mut $stream: ident) => {{\n                $stream.as_mut().unwrap().stop().unwrap();\n                *$stream = None;\n            }};\n        }\n        match self {\n            Self::F32(stream, _) => stop_sink!(ref mut stream),\n            Self::S32(stream, _) => stop_sink!(ref mut stream),\n            Self::S16(stream, _) => stop_sink!(ref mut stream),\n        };\n\n        Ok(())\n    }\n\n    fn write(&mut self, packet: AudioPacket, converter: &mut Converter) -> SinkResult<()> {\n        macro_rules! write_sink {\n            (ref mut $stream: expr, $samples: expr) => {\n                $stream.as_mut().unwrap().write($samples)\n            };\n        }\n\n        let samples = packet\n            .samples()\n            .map_err(|e| SinkError::OnWrite(e.to_string()))?;\n\n        let result = match self {\n            Self::F32(stream, _parameters) => {\n                let samples_f32: &[f32] = &converter.f64_to_f32(samples);\n                write_sink!(ref mut stream, samples_f32)\n            }\n            Self::S32(stream, _parameters) => {\n                let samples_s32: &[i32] = &converter.f64_to_s32(samples);\n                write_sink!(ref mut stream, samples_s32)\n            }\n            Self::S16(stream, _parameters) => {\n                let samples_s16: &[i16] = &converter.f64_to_s16(samples);\n                write_sink!(ref mut stream, samples_s16)\n            }\n        };\n        match result {\n            Ok(_) => (),\n            Err(portaudio_rs::PaError::OutputUnderflowed) => error!(\"PortAudio write underflow\"),\n            Err(e) => panic!(\"PortAudio error {e}\"),\n        };\n\n        Ok(())\n    }\n}\n\nimpl Drop for PortAudioSink<'_> {\n    fn drop(&mut self) {\n        portaudio_rs::terminate().unwrap();\n    }\n}\n\nimpl PortAudioSink<'_> {\n    pub const NAME: &'static str = \"portaudio\";\n}\n"
  },
  {
    "path": "playback/src/audio_backend/pulseaudio.rs",
    "content": "use super::{Open, Sink, SinkAsBytes, SinkError, SinkResult};\nuse crate::config::AudioFormat;\nuse crate::convert::Converter;\nuse crate::decoder::AudioPacket;\nuse crate::{NUM_CHANNELS, SAMPLE_RATE};\nuse libpulse_binding::{self as pulse, error::PAErr, stream::Direction};\nuse libpulse_simple_binding::Simple;\nuse std::env;\nuse thiserror::Error;\n\n#[derive(Debug, Error)]\nenum PulseError {\n    #[error(\n        \"<PulseAudioSink> Unsupported Pulseaudio Sample Spec, Format {pulse_format:?} ({format:?}), Channels {channels}, Rate {rate}\"\n    )]\n    InvalidSampleSpec {\n        pulse_format: pulse::sample::Format,\n        format: AudioFormat,\n        channels: u8,\n        rate: u32,\n    },\n\n    #[error(\"<PulseAudioSink> {0}\")]\n    ConnectionRefused(PAErr),\n\n    #[error(\"<PulseAudioSink> Failed to Drain Pulseaudio Buffer, {0}\")]\n    DrainFailure(PAErr),\n\n    #[error(\"<PulseAudioSink>\")]\n    NotConnected,\n\n    #[error(\"<PulseAudioSink> {0}\")]\n    OnWrite(PAErr),\n}\n\nimpl From<PulseError> for SinkError {\n    fn from(e: PulseError) -> SinkError {\n        use PulseError::*;\n        let es = e.to_string();\n        match e {\n            DrainFailure(_) | OnWrite(_) => SinkError::OnWrite(es),\n            ConnectionRefused(_) => SinkError::ConnectionRefused(es),\n            NotConnected => SinkError::NotConnected(es),\n            InvalidSampleSpec { .. } => SinkError::InvalidParams(es),\n        }\n    }\n}\n\npub struct PulseAudioSink {\n    sink: Option<Simple>,\n    device: Option<String>,\n    app_name: String,\n    stream_desc: String,\n    format: AudioFormat,\n}\n\nimpl Open for PulseAudioSink {\n    fn open(device: Option<String>, format: AudioFormat) -> Self {\n        let app_name = env::var(\"PULSE_PROP_application.name\").unwrap_or_default();\n        let stream_desc = env::var(\"PULSE_PROP_stream.description\").unwrap_or_default();\n\n        let mut actual_format = format;\n\n        if actual_format == AudioFormat::F64 {\n            warn!(\"PulseAudio currently does not support F64 output\");\n            actual_format = AudioFormat::F32;\n        }\n\n        info!(\"Using PulseAudioSink with format: {actual_format:?}\");\n\n        Self {\n            sink: None,\n            device,\n            app_name,\n            stream_desc,\n            format: actual_format,\n        }\n    }\n}\n\nimpl Sink for PulseAudioSink {\n    fn start(&mut self) -> SinkResult<()> {\n        if self.sink.is_none() {\n            // PulseAudio calls S24 and S24_3 different from the rest of the world\n            let pulse_format = match self.format {\n                AudioFormat::F32 => pulse::sample::Format::FLOAT32NE,\n                AudioFormat::S32 => pulse::sample::Format::S32NE,\n                AudioFormat::S24 => pulse::sample::Format::S24_32NE,\n                AudioFormat::S24_3 => pulse::sample::Format::S24NE,\n                AudioFormat::S16 => pulse::sample::Format::S16NE,\n                _ => unreachable!(),\n            };\n\n            let sample_spec = pulse::sample::Spec {\n                format: pulse_format,\n                channels: NUM_CHANNELS,\n                rate: SAMPLE_RATE,\n            };\n\n            if !sample_spec.is_valid() {\n                let pulse_error = PulseError::InvalidSampleSpec {\n                    pulse_format,\n                    format: self.format,\n                    channels: NUM_CHANNELS,\n                    rate: SAMPLE_RATE,\n                };\n\n                return Err(SinkError::from(pulse_error));\n            }\n\n            let sink = Simple::new(\n                None,                   // Use the default server.\n                &self.app_name,         // Our application's name.\n                Direction::Playback,    // Direction.\n                self.device.as_deref(), // Our device (sink) name.\n                &self.stream_desc,      // Description of our stream.\n                &sample_spec,           // Our sample format.\n                None,                   // Use default channel map.\n                None,                   // Use default buffering attributes.\n            )\n            .map_err(PulseError::ConnectionRefused)?;\n\n            self.sink = Some(sink);\n        }\n\n        Ok(())\n    }\n\n    fn stop(&mut self) -> SinkResult<()> {\n        let sink = self.sink.take().ok_or(PulseError::NotConnected)?;\n\n        sink.drain().map_err(PulseError::DrainFailure)?;\n        Ok(())\n    }\n\n    sink_as_bytes!();\n}\n\nimpl SinkAsBytes for PulseAudioSink {\n    #[inline]\n    fn write_bytes(&mut self, data: &[u8]) -> SinkResult<()> {\n        let sink = self.sink.as_mut().ok_or(PulseError::NotConnected)?;\n\n        sink.write(data).map_err(PulseError::OnWrite)?;\n\n        Ok(())\n    }\n}\n\nimpl PulseAudioSink {\n    pub const NAME: &'static str = \"pulseaudio\";\n}\n"
  },
  {
    "path": "playback/src/audio_backend/rodio.rs",
    "content": "use std::process::exit;\nuse std::thread;\nuse std::time::Duration;\n\nuse cpal::traits::{DeviceTrait, HostTrait};\nuse thiserror::Error;\n\nuse super::{Sink, SinkError, SinkResult};\nuse crate::config::AudioFormat;\nuse crate::convert::Converter;\nuse crate::decoder::AudioPacket;\nuse crate::{NUM_CHANNELS, SAMPLE_RATE};\n\n#[cfg(all(\n    feature = \"rodiojack-backend\",\n    not(any(target_os = \"linux\", target_os = \"dragonfly\", target_os = \"freebsd\"))\n))]\ncompile_error!(\"Rodio JACK backend is currently only supported on linux.\");\n\n#[cfg(feature = \"rodio-backend\")]\npub fn mk_rodio(device: Option<String>, format: AudioFormat) -> Box<dyn Sink> {\n    Box::new(open(cpal::default_host(), device, format))\n}\n\n#[cfg(feature = \"rodiojack-backend\")]\npub fn mk_rodiojack(device: Option<String>, format: AudioFormat) -> Box<dyn Sink> {\n    Box::new(open(\n        cpal::host_from_id(cpal::HostId::Jack).unwrap(),\n        device,\n        format,\n    ))\n}\n\n#[derive(Debug, Error)]\npub enum RodioError {\n    #[error(\"<RodioSink> No Device Available\")]\n    NoDeviceAvailable,\n    #[error(\"<RodioSink> device \\\"{0}\\\" is Not Available\")]\n    DeviceNotAvailable(String),\n    #[error(\"<RodioSink> Play Error: {0}\")]\n    PlayError(#[from] rodio::PlayError),\n    #[error(\"<RodioSink> Stream Error: {0}\")]\n    StreamError(#[from] rodio::StreamError),\n    #[error(\"<RodioSink> Cannot Get Audio Devices: {0}\")]\n    DevicesError(#[from] cpal::DevicesError),\n    #[error(\"<RodioSink> {0}\")]\n    Samples(String),\n}\n\nimpl From<RodioError> for SinkError {\n    fn from(e: RodioError) -> SinkError {\n        use RodioError::*;\n        let es = e.to_string();\n        match e {\n            StreamError(_) | PlayError(_) | Samples(_) => SinkError::OnWrite(es),\n            NoDeviceAvailable | DeviceNotAvailable(_) => SinkError::ConnectionRefused(es),\n            DevicesError(_) => SinkError::InvalidParams(es),\n        }\n    }\n}\n\nimpl From<cpal::DefaultStreamConfigError> for RodioError {\n    fn from(_: cpal::DefaultStreamConfigError) -> RodioError {\n        RodioError::NoDeviceAvailable\n    }\n}\n\nimpl From<cpal::SupportedStreamConfigsError> for RodioError {\n    fn from(_: cpal::SupportedStreamConfigsError) -> RodioError {\n        RodioError::NoDeviceAvailable\n    }\n}\n\npub struct RodioSink {\n    rodio_sink: rodio::Sink,\n    _stream: rodio::OutputStream,\n}\n\nfn list_formats(device: &cpal::Device) {\n    match device.default_output_config() {\n        Ok(cfg) => {\n            debug!(\"  Default config:\");\n            debug!(\"    {cfg:?}\");\n        }\n        Err(e) => {\n            // Use loglevel debug, since even the output is only debug\n            debug!(\"Error getting default rodio::Sink config: {e}\");\n        }\n    };\n\n    match device.supported_output_configs() {\n        Ok(mut cfgs) => {\n            if let Some(first) = cfgs.next() {\n                debug!(\"  Available configs:\");\n                debug!(\"    {first:?}\");\n            } else {\n                return;\n            }\n\n            for cfg in cfgs {\n                debug!(\"    {cfg:?}\");\n            }\n        }\n        Err(e) => {\n            debug!(\"Error getting supported rodio::Sink configs: {e}\");\n        }\n    }\n}\n\nfn list_outputs(host: &cpal::Host) -> Result<(), cpal::DevicesError> {\n    let mut default_device_name = None;\n\n    if let Some(default_device) = host.default_output_device() {\n        default_device_name = default_device.name().ok();\n        println!(\n            \"Default Audio Device:\\n  {}\",\n            default_device_name.as_deref().unwrap_or(\"[unknown name]\")\n        );\n\n        list_formats(&default_device);\n\n        println!(\"Other Available Audio Devices:\");\n    } else {\n        warn!(\"No default device was found\");\n    }\n\n    for device in host.output_devices()? {\n        match device.name() {\n            Ok(name) if Some(&name) == default_device_name.as_ref() => (),\n            Ok(name) => {\n                println!(\"  {name}\");\n                list_formats(&device);\n            }\n            Err(e) => {\n                warn!(\"Cannot get device name: {e}\");\n                println!(\"   [unknown name]\");\n                list_formats(&device);\n            }\n        }\n    }\n\n    Ok(())\n}\n\nfn create_sink(\n    host: &cpal::Host,\n    device: Option<String>,\n    format: AudioFormat,\n) -> Result<(rodio::Sink, rodio::OutputStream), RodioError> {\n    let cpal_device = match device.as_deref() {\n        Some(\"?\") => match list_outputs(host) {\n            Ok(()) => exit(0),\n            Err(e) => {\n                error!(\"{e}\");\n                exit(1);\n            }\n        },\n        Some(device_name) => {\n            // Ignore devices for which getting name fails, or format doesn't match\n            host.output_devices()?\n                .find(|d| d.name().ok().is_some_and(|name| name == device_name)) // Ignore devices for which getting name fails\n                .ok_or_else(|| RodioError::DeviceNotAvailable(device_name.to_string()))?\n        }\n        None => host\n            .default_output_device()\n            .ok_or(RodioError::NoDeviceAvailable)?,\n    };\n\n    let name = cpal_device.name().ok();\n    info!(\n        \"Using audio device: {}\",\n        name.as_deref().unwrap_or(\"[unknown name]\")\n    );\n\n    // First try native stereo 44.1 kHz playback, then fall back to the device default sample rate\n    // (some devices only support 48 kHz and Rodio will resample linearly), then fall back to\n    // whatever the default device config is (like mono).\n    let default_config = cpal_device.default_output_config()?;\n    let config = cpal_device\n        .supported_output_configs()?\n        .find(|c| c.channels() == NUM_CHANNELS as cpal::ChannelCount)\n        .and_then(|c| {\n            c.try_with_sample_rate(cpal::SampleRate(SAMPLE_RATE))\n                .or_else(|| c.try_with_sample_rate(default_config.sample_rate()))\n        })\n        .unwrap_or(default_config);\n\n    let sample_format = match format {\n        AudioFormat::F64 => cpal::SampleFormat::F64,\n        AudioFormat::F32 => cpal::SampleFormat::F32,\n        AudioFormat::S32 => cpal::SampleFormat::I32,\n        AudioFormat::S24 | AudioFormat::S24_3 => cpal::SampleFormat::I24,\n        AudioFormat::S16 => cpal::SampleFormat::I16,\n    };\n\n    let mut stream = match rodio::OutputStreamBuilder::default()\n        .with_device(cpal_device.clone())\n        .with_config(&config.config())\n        .with_sample_format(sample_format)\n        .open_stream()\n    {\n        Ok(exact_stream) => exact_stream,\n        Err(e) => {\n            warn!(\"unable to create Rodio output, falling back to default: {e}\");\n            rodio::OutputStreamBuilder::from_device(cpal_device)?.open_stream_or_fallback()?\n        }\n    };\n\n    // disable logging on stream drop\n    stream.log_on_drop(false);\n\n    let sink = rodio::Sink::connect_new(stream.mixer());\n    Ok((sink, stream))\n}\n\npub fn open(host: cpal::Host, device: Option<String>, format: AudioFormat) -> RodioSink {\n    info!(\n        \"Using Rodio sink with format {format:?} and cpal host: {}\",\n        host.id().name()\n    );\n\n    let (sink, stream) = create_sink(&host, device, format).unwrap();\n\n    debug!(\"Rodio sink was created\");\n    RodioSink {\n        rodio_sink: sink,\n        _stream: stream,\n    }\n}\n\nimpl Sink for RodioSink {\n    fn start(&mut self) -> SinkResult<()> {\n        self.rodio_sink.play();\n        Ok(())\n    }\n\n    fn stop(&mut self) -> SinkResult<()> {\n        self.rodio_sink.sleep_until_end();\n        self.rodio_sink.pause();\n        Ok(())\n    }\n\n    fn write(&mut self, packet: AudioPacket, converter: &mut Converter) -> SinkResult<()> {\n        let samples = packet\n            .samples()\n            .map_err(|e| RodioError::Samples(e.to_string()))?;\n        let samples_f32: &[f32] = &converter.f64_to_f32(samples);\n        let source = rodio::buffer::SamplesBuffer::new(\n            NUM_CHANNELS as cpal::ChannelCount,\n            SAMPLE_RATE,\n            samples_f32,\n        );\n        self.rodio_sink.append(source);\n\n        // Chunk sizes seem to be about 256 to 3000 ish items long.\n        // Assuming they're on average 1628 then a half second buffer is:\n        // 44100 elements --> about 27 chunks\n        while self.rodio_sink.len() > 26 {\n            // sleep and wait for rodio to drain a bit\n            thread::sleep(Duration::from_millis(10));\n        }\n        Ok(())\n    }\n}\n\nimpl RodioSink {\n    #[allow(dead_code)]\n    pub const NAME: &'static str = \"rodio\";\n}\n"
  },
  {
    "path": "playback/src/audio_backend/sdl.rs",
    "content": "use super::{Open, Sink, SinkError, SinkResult};\nuse crate::config::AudioFormat;\nuse crate::convert::Converter;\nuse crate::decoder::AudioPacket;\nuse crate::{NUM_CHANNELS, SAMPLE_RATE};\nuse sdl2::audio::{AudioQueue, AudioSpecDesired};\nuse std::thread;\nuse std::time::Duration;\n\npub enum SdlSink {\n    F32(AudioQueue<f32>),\n    S32(AudioQueue<i32>),\n    S16(AudioQueue<i16>),\n}\n\nimpl Open for SdlSink {\n    fn open(device: Option<String>, format: AudioFormat) -> Self {\n        info!(\"Using SDL sink with format: {:?}\", format);\n\n        if device.is_some() {\n            warn!(\"SDL sink does not support specifying a device name\");\n        }\n\n        let ctx = sdl2::init().expect(\"could not initialize SDL\");\n        let audio = ctx\n            .audio()\n            .expect(\"could not initialize SDL audio subsystem\");\n\n        let desired_spec = AudioSpecDesired {\n            freq: Some(SAMPLE_RATE as i32),\n            channels: Some(NUM_CHANNELS),\n            samples: None,\n        };\n\n        macro_rules! open_sink {\n            ($sink: expr, $type: ty) => {{\n                let queue: AudioQueue<$type> = audio\n                    .open_queue(None, &desired_spec)\n                    .expect(\"could not open SDL audio device\");\n                $sink(queue)\n            }};\n        }\n        match format {\n            AudioFormat::F32 => open_sink!(Self::F32, f32),\n            AudioFormat::S32 => open_sink!(Self::S32, i32),\n            AudioFormat::S16 => open_sink!(Self::S16, i16),\n            _ => {\n                unimplemented!(\"SDL currently does not support {format:?} output\")\n            }\n        }\n    }\n}\n\nimpl Sink for SdlSink {\n    fn start(&mut self) -> SinkResult<()> {\n        macro_rules! start_sink {\n            ($queue: expr) => {{\n                $queue.clear();\n                $queue.resume();\n            }};\n        }\n        match self {\n            Self::F32(queue) => start_sink!(queue),\n            Self::S32(queue) => start_sink!(queue),\n            Self::S16(queue) => start_sink!(queue),\n        };\n        Ok(())\n    }\n\n    fn stop(&mut self) -> SinkResult<()> {\n        macro_rules! stop_sink {\n            ($queue: expr) => {{\n                $queue.pause();\n                $queue.clear();\n            }};\n        }\n        match self {\n            Self::F32(queue) => stop_sink!(queue),\n            Self::S32(queue) => stop_sink!(queue),\n            Self::S16(queue) => stop_sink!(queue),\n        };\n        Ok(())\n    }\n\n    fn write(&mut self, packet: AudioPacket, converter: &mut Converter) -> SinkResult<()> {\n        macro_rules! drain_sink {\n            ($queue: expr, $size: expr) => {{\n                // sleep and wait for sdl thread to drain the queue a bit\n                while $queue.size() > (NUM_CHANNELS as u32 * $size as u32 * SAMPLE_RATE) {\n                    thread::sleep(Duration::from_millis(10));\n                }\n            }};\n        }\n\n        let samples = packet\n            .samples()\n            .map_err(|e| SinkError::OnWrite(e.to_string()))?;\n        let result = match self {\n            Self::F32(queue) => {\n                let samples_f32: &[f32] = &converter.f64_to_f32(samples);\n                drain_sink!(queue, AudioFormat::F32.size());\n                queue.queue_audio(samples_f32)\n            }\n            Self::S32(queue) => {\n                let samples_s32: &[i32] = &converter.f64_to_s32(samples);\n                drain_sink!(queue, AudioFormat::S32.size());\n                queue.queue_audio(samples_s32)\n            }\n            Self::S16(queue) => {\n                let samples_s16: &[i16] = &converter.f64_to_s16(samples);\n                drain_sink!(queue, AudioFormat::S16.size());\n                queue.queue_audio(samples_s16)\n            }\n        };\n        result.map_err(SinkError::OnWrite)\n    }\n}\n\nimpl SdlSink {\n    pub const NAME: &'static str = \"sdl\";\n}\n"
  },
  {
    "path": "playback/src/audio_backend/subprocess.rs",
    "content": "use super::{Open, Sink, SinkAsBytes, SinkError, SinkResult};\nuse crate::config::AudioFormat;\nuse crate::convert::Converter;\nuse crate::decoder::AudioPacket;\nuse shell_words::split;\n\nuse std::io::{ErrorKind, Write};\nuse std::process::{Child, Command, Stdio, exit};\nuse thiserror::Error;\n\n#[derive(Debug, Error)]\nenum SubprocessError {\n    #[error(\"<SubprocessSink> {0}\")]\n    OnWrite(std::io::Error),\n\n    #[error(\"<SubprocessSink> Command {command} Can Not be Executed, {e}\")]\n    SpawnFailure { command: String, e: std::io::Error },\n\n    #[error(\"<SubprocessSink> Failed to Parse Command args for {command}, {e}\")]\n    InvalidArgs {\n        command: String,\n        e: shell_words::ParseError,\n    },\n\n    #[error(\"<SubprocessSink> Failed to Flush the Subprocess, {0}\")]\n    FlushFailure(std::io::Error),\n\n    #[error(\"<SubprocessSink> Failed to Kill the Subprocess, {0}\")]\n    KillFailure(std::io::Error),\n\n    #[error(\"<SubprocessSink> Failed to Wait for the Subprocess to Exit, {0}\")]\n    WaitFailure(std::io::Error),\n\n    #[error(\"<SubprocessSink> The Subprocess is no longer able to accept Bytes\")]\n    WriteZero,\n\n    #[error(\"<SubprocessSink> Missing Required Shell Command\")]\n    MissingCommand,\n\n    #[error(\"<SubprocessSink> The Subprocess is None\")]\n    NoChild,\n\n    #[error(\"<SubprocessSink> The Subprocess's stdin is None\")]\n    NoStdin,\n}\n\nimpl From<SubprocessError> for SinkError {\n    fn from(e: SubprocessError) -> SinkError {\n        use SubprocessError::*;\n        let es = e.to_string();\n        match e {\n            FlushFailure(_) | KillFailure(_) | WaitFailure(_) | OnWrite(_) | WriteZero => {\n                SinkError::OnWrite(es)\n            }\n            SpawnFailure { .. } => SinkError::ConnectionRefused(es),\n            MissingCommand | InvalidArgs { .. } => SinkError::InvalidParams(es),\n            NoChild | NoStdin => SinkError::NotConnected(es),\n        }\n    }\n}\n\npub struct SubprocessSink {\n    shell_command: Option<String>,\n    child: Option<Child>,\n    format: AudioFormat,\n}\n\nimpl Open for SubprocessSink {\n    fn open(shell_command: Option<String>, format: AudioFormat) -> Self {\n        if let Some(\"?\") = shell_command.as_deref() {\n            println!(\n                \"\\nUsage:\\n\\nOutput to a Subprocess:\\n\\n\\t--backend subprocess --device {{shell_command}}\\n\"\n            );\n            exit(0);\n        }\n\n        info!(\"Using SubprocessSink with format: {format:?}\");\n\n        Self {\n            shell_command,\n            child: None,\n            format,\n        }\n    }\n}\n\nimpl Sink for SubprocessSink {\n    fn start(&mut self) -> SinkResult<()> {\n        self.child.get_or_insert({\n            match self.shell_command.as_deref() {\n                Some(command) => {\n                    let args = split(command).map_err(|e| SubprocessError::InvalidArgs {\n                        command: command.to_string(),\n                        e,\n                    })?;\n\n                    Command::new(&args[0])\n                        .args(&args[1..])\n                        .stdin(Stdio::piped())\n                        .spawn()\n                        .map_err(|e| SubprocessError::SpawnFailure {\n                            command: command.to_string(),\n                            e,\n                        })?\n                }\n                None => return Err(SubprocessError::MissingCommand.into()),\n            }\n        });\n\n        Ok(())\n    }\n\n    fn stop(&mut self) -> SinkResult<()> {\n        let child = &mut self.child.take().ok_or(SubprocessError::NoChild)?;\n\n        match child.try_wait() {\n            // The process has already exited\n            // nothing to do.\n            Ok(Some(_)) => Ok(()),\n            Ok(_) => {\n                // The process Must DIE!!!\n                child\n                    .stdin\n                    .take()\n                    .ok_or(SubprocessError::NoStdin)?\n                    .flush()\n                    .map_err(SubprocessError::FlushFailure)?;\n\n                child.kill().map_err(SubprocessError::KillFailure)?;\n                child.wait().map_err(SubprocessError::WaitFailure)?;\n\n                Ok(())\n            }\n            Err(e) => Err(SubprocessError::WaitFailure(e).into()),\n        }\n    }\n\n    sink_as_bytes!();\n}\n\nimpl SinkAsBytes for SubprocessSink {\n    #[inline]\n    fn write_bytes(&mut self, data: &[u8]) -> SinkResult<()> {\n        // We get one attempted restart per write.\n        // We don't want to get stuck in a restart loop.\n        let mut restarted = false;\n        let mut start_index = 0;\n        let data_len = data.len();\n        let mut end_index = data_len;\n\n        loop {\n            match self\n                .child\n                .as_ref()\n                .ok_or(SubprocessError::NoChild)?\n                .stdin\n                .as_ref()\n                .ok_or(SubprocessError::NoStdin)?\n                .write(&data[start_index..end_index])\n            {\n                Ok(0) => {\n                    // Potentially fatal.\n                    // As per the docs a return value of 0\n                    // means we shouldn't try to write to the\n                    // process anymore so let's try a restart\n                    // if we haven't already.\n                    self.try_restart(SubprocessError::WriteZero, &mut restarted)?;\n\n                    continue;\n                }\n                Ok(bytes_written) => {\n                    // What we want, a successful write.\n                    start_index = data_len.min(start_index + bytes_written);\n                    end_index = data_len.min(start_index + bytes_written);\n\n                    if end_index == data_len {\n                        break Ok(());\n                    }\n                }\n                // Non-fatal, retry the write.\n                Err(ref e) if e.kind() == ErrorKind::Interrupted => continue,\n                Err(e) => {\n                    // Very possibly fatal,\n                    // but let's try a restart anyway if we haven't already.\n                    self.try_restart(SubprocessError::OnWrite(e), &mut restarted)?;\n\n                    continue;\n                }\n            }\n        }\n    }\n}\n\nimpl SubprocessSink {\n    pub const NAME: &'static str = \"subprocess\";\n\n    fn try_restart(&mut self, e: SubprocessError, restarted: &mut bool) -> SinkResult<()> {\n        // If the restart fails throw the original error back.\n        if !*restarted && self.stop().is_ok() && self.start().is_ok() {\n            *restarted = true;\n\n            Ok(())\n        } else {\n            Err(e.into())\n        }\n    }\n}\n"
  },
  {
    "path": "playback/src/config.rs",
    "content": "use std::{mem, path::PathBuf, str::FromStr, time::Duration};\n\npub use crate::dither::{DithererBuilder, TriangularDitherer, mk_ditherer};\nuse crate::{convert::i24, player::duration_to_coefficient};\n\n#[derive(Clone, Copy, Debug, Hash, PartialOrd, Ord, PartialEq, Eq, Default)]\npub enum Bitrate {\n    Bitrate96,\n    #[default]\n    Bitrate160,\n    Bitrate320,\n}\n\nimpl FromStr for Bitrate {\n    type Err = ();\n    fn from_str(s: &str) -> Result<Self, Self::Err> {\n        match s {\n            \"96\" => Ok(Self::Bitrate96),\n            \"160\" => Ok(Self::Bitrate160),\n            \"320\" => Ok(Self::Bitrate320),\n            _ => Err(()),\n        }\n    }\n}\n\n#[derive(Clone, Copy, Debug, Hash, PartialOrd, Ord, PartialEq, Eq, Default)]\npub enum AudioFormat {\n    F64,\n    F32,\n    S32,\n    S24,\n    S24_3,\n    #[default]\n    S16,\n}\n\nimpl FromStr for AudioFormat {\n    type Err = ();\n    fn from_str(s: &str) -> Result<Self, Self::Err> {\n        match s.to_uppercase().as_ref() {\n            \"F64\" => Ok(Self::F64),\n            \"F32\" => Ok(Self::F32),\n            \"S32\" => Ok(Self::S32),\n            \"S24\" => Ok(Self::S24),\n            \"S24_3\" => Ok(Self::S24_3),\n            \"S16\" => Ok(Self::S16),\n            _ => Err(()),\n        }\n    }\n}\n\nimpl AudioFormat {\n    // not used by all backends\n    #[allow(dead_code)]\n    pub fn size(&self) -> usize {\n        match self {\n            Self::F64 => mem::size_of::<f64>(),\n            Self::F32 => mem::size_of::<f32>(),\n            Self::S24_3 => mem::size_of::<i24>(),\n            Self::S16 => mem::size_of::<i16>(),\n            _ => mem::size_of::<i32>(), // S32 and S24 are both stored in i32\n        }\n    }\n}\n\n#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]\npub enum NormalisationType {\n    Album,\n    Track,\n    #[default]\n    Auto,\n}\n\nimpl FromStr for NormalisationType {\n    type Err = ();\n    fn from_str(s: &str) -> Result<Self, Self::Err> {\n        match s.to_lowercase().as_ref() {\n            \"album\" => Ok(Self::Album),\n            \"track\" => Ok(Self::Track),\n            \"auto\" => Ok(Self::Auto),\n            _ => Err(()),\n        }\n    }\n}\n\n#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]\npub enum NormalisationMethod {\n    Basic,\n    #[default]\n    Dynamic,\n}\n\nimpl FromStr for NormalisationMethod {\n    type Err = ();\n    fn from_str(s: &str) -> Result<Self, Self::Err> {\n        match s.to_lowercase().as_ref() {\n            \"basic\" => Ok(Self::Basic),\n            \"dynamic\" => Ok(Self::Dynamic),\n            _ => Err(()),\n        }\n    }\n}\n\n#[derive(Clone)]\npub struct PlayerConfig {\n    pub bitrate: Bitrate,\n    pub gapless: bool,\n    pub passthrough: bool,\n\n    pub normalisation: bool,\n    pub normalisation_type: NormalisationType,\n    pub normalisation_method: NormalisationMethod,\n    pub normalisation_pregain_db: f64,\n    pub normalisation_threshold_dbfs: f64,\n    pub normalisation_attack_cf: f64,\n    pub normalisation_release_cf: f64,\n    pub normalisation_knee_db: f64,\n\n    pub local_file_directories: Vec<PathBuf>,\n\n    // pass function pointers so they can be lazily instantiated *after* spawning a thread\n    // (thereby circumventing Send bounds that they might not satisfy)\n    pub ditherer: Option<DithererBuilder>,\n    /// Setting this will enable periodically sending events during playback informing about the playback position\n    /// To consume the PlayerEvent::PositionChanged event, listen to events via `Player::get_player_event_channel()``\n    pub position_update_interval: Option<Duration>,\n}\n\nimpl Default for PlayerConfig {\n    fn default() -> Self {\n        Self {\n            bitrate: Bitrate::default(),\n            gapless: true,\n            normalisation: false,\n            normalisation_type: NormalisationType::default(),\n            normalisation_method: NormalisationMethod::default(),\n            normalisation_pregain_db: 0.0,\n            normalisation_threshold_dbfs: -2.0,\n            normalisation_attack_cf: duration_to_coefficient(Duration::from_millis(5)),\n            normalisation_release_cf: duration_to_coefficient(Duration::from_millis(100)),\n            normalisation_knee_db: 5.0,\n            passthrough: false,\n            ditherer: Some(mk_ditherer::<TriangularDitherer>),\n            position_update_interval: None,\n            local_file_directories: Vec::new(),\n        }\n    }\n}\n\n// fields are intended for volume control range in dB\n#[derive(Clone, Copy, Debug)]\npub enum VolumeCtrl {\n    Cubic(f64),\n    Fixed,\n    Linear,\n    Log(f64),\n}\n\nimpl FromStr for VolumeCtrl {\n    type Err = ();\n    fn from_str(s: &str) -> Result<Self, Self::Err> {\n        Self::from_str_with_range(s, Self::DEFAULT_DB_RANGE)\n    }\n}\n\nimpl Default for VolumeCtrl {\n    fn default() -> VolumeCtrl {\n        VolumeCtrl::Log(Self::DEFAULT_DB_RANGE)\n    }\n}\n\nimpl VolumeCtrl {\n    pub const MAX_VOLUME: u16 = u16::MAX;\n\n    // Taken from: https://www.dr-lex.be/info-stuff/volumecontrols.html\n    pub const DEFAULT_DB_RANGE: f64 = 60.0;\n\n    pub fn from_str_with_range(s: &str, db_range: f64) -> Result<Self, <Self as FromStr>::Err> {\n        use self::VolumeCtrl::*;\n        match s.to_lowercase().as_ref() {\n            \"cubic\" => Ok(Cubic(db_range)),\n            \"fixed\" => Ok(Fixed),\n            \"linear\" => Ok(Linear),\n            \"log\" => Ok(Log(db_range)),\n            _ => Err(()),\n        }\n    }\n}\n"
  },
  {
    "path": "playback/src/convert.rs",
    "content": "use crate::dither::{Ditherer, DithererBuilder};\nuse zerocopy::{Immutable, IntoBytes};\n\n#[derive(Immutable, IntoBytes, Copy, Clone, Debug)]\n#[allow(non_camel_case_types)]\n#[repr(transparent)]\npub struct i24([u8; 3]);\nimpl i24 {\n    fn from_s24(sample: i32) -> Self {\n        // trim the padding in the most significant byte\n        #[allow(unused_variables)]\n        let [a, b, c, d] = sample.to_ne_bytes();\n        #[cfg(target_endian = \"little\")]\n        return Self([a, b, c]);\n        #[cfg(target_endian = \"big\")]\n        return Self([b, c, d]);\n    }\n}\n\npub struct Converter {\n    ditherer: Option<Box<dyn Ditherer>>,\n}\n\nimpl Converter {\n    pub fn new(dither_config: Option<DithererBuilder>) -> Self {\n        match dither_config {\n            Some(ditherer_builder) => {\n                let ditherer = (ditherer_builder)();\n                info!(\"Converting with ditherer: {}\", ditherer.name());\n                Self {\n                    ditherer: Some(ditherer),\n                }\n            }\n            None => Self { ditherer: None },\n        }\n    }\n\n    /// Base bit positions for PCM format scaling. These represent the position\n    /// of the most significant bit in each format's full-scale representation.\n    /// For signed integers in two's complement, full scale is 2^(bits-1).\n    const SHIFT_S16: u8 = 15; // 16-bit: 2^15 = 32768\n    const SHIFT_S24: u8 = 23; // 24-bit: 2^23 = 8388608  \n    const SHIFT_S32: u8 = 31; // 32-bit: 2^31 = 2147483648\n\n    /// Additional bit shifts needed to scale from 16-bit to higher bit depths.\n    /// These are the differences between the base shift amounts above.\n    const SHIFT_16_TO_24: u8 = Self::SHIFT_S24 - Self::SHIFT_S16; // 23 - 15 = 8\n    const SHIFT_16_TO_32: u8 = Self::SHIFT_S32 - Self::SHIFT_S16; // 31 - 15 = 16\n\n    /// Pre-calculated scale factor for 24-bit clamping bounds\n    const SCALE_S24: f64 = (1_u64 << Self::SHIFT_S24) as f64;\n\n    /// Scale audio samples with optimal dithering strategy for Spotify's 16-bit source material.\n    ///\n    /// Since Spotify audio is always 16-bit depth, this function:\n    /// 1. When dithering: applies noise at 16-bit level, preserves fractional precision,\n    ///    then scales to target format and rounds once at the end\n    /// 2. When not dithering: scales directly from normalized float to target format\n    ///\n    /// The `shift` parameter specifies how many extra bits to shift beyond\n    /// the base 16-bit scaling (0 for 16-bit, 8 for 24-bit, 16 for 32-bit).\n    #[inline]\n    pub fn scale(&mut self, sample: f64, shift: u8) -> f64 {\n        match self.ditherer.as_mut() {\n            Some(d) => {\n                // With dithering: Apply noise at 16-bit level to address original quantization,\n                // then scale up to target format while preserving sub-LSB information\n                let dithered_16bit = sample * (1_u64 << Self::SHIFT_S16) as f64 + d.noise();\n                let scaled = dithered_16bit * (1_u64 << shift) as f64;\n                scaled.round()\n            }\n            None => {\n                // No dithering: Scale directly from normalized float to target format\n                // using a single bit shift operation (base 16-bit shift + additional shift)\n                let total_shift = Self::SHIFT_S16 + shift;\n                (sample * (1_u64 << total_shift) as f64).round()\n            }\n        }\n    }\n\n    /// Clamping scale specifically for 24-bit output to prevent MSB overflow.\n    /// Only used for S24 formats where samples are packed in 32-bit words.\n    /// Ensures the most significant byte is zero to prevent overflow during dithering.\n    #[inline]\n    pub fn clamping_scale_s24(&mut self, sample: f64) -> f64 {\n        let int_value = self.scale(sample, Self::SHIFT_16_TO_24);\n\n        // In two's complement, there are more negative than positive values.\n        let min = -Self::SCALE_S24;\n        let max = Self::SCALE_S24 - 1.0;\n\n        int_value.clamp(min, max)\n    }\n\n    #[inline]\n    pub fn f64_to_f32(&mut self, samples: &[f64]) -> Vec<f32> {\n        samples.iter().map(|sample| *sample as f32).collect()\n    }\n\n    #[inline]\n    pub fn f64_to_s32(&mut self, samples: &[f64]) -> Vec<i32> {\n        samples\n            .iter()\n            .map(|sample| self.scale(*sample, Self::SHIFT_16_TO_32) as i32)\n            .collect()\n    }\n\n    /// S24 is 24-bit PCM packed in an upper 32-bit word\n    #[inline]\n    pub fn f64_to_s24(&mut self, samples: &[f64]) -> Vec<i32> {\n        samples\n            .iter()\n            .map(|sample| self.clamping_scale_s24(*sample) as i32)\n            .collect()\n    }\n\n    /// S24_3 is 24-bit PCM in a 3-byte array\n    #[inline]\n    pub fn f64_to_s24_3(&mut self, samples: &[f64]) -> Vec<i24> {\n        samples\n            .iter()\n            .map(|sample| i24::from_s24(self.clamping_scale_s24(*sample) as i32))\n            .collect()\n    }\n\n    #[inline]\n    pub fn f64_to_s16(&mut self, samples: &[f64]) -> Vec<i16> {\n        samples\n            .iter()\n            .map(|sample| self.scale(*sample, 0) as i16)\n            .collect()\n    }\n}\n"
  },
  {
    "path": "playback/src/decoder/mod.rs",
    "content": "use std::ops::Deref;\n\nuse thiserror::Error;\n\n#[cfg(feature = \"passthrough-decoder\")]\nmod passthrough_decoder;\n#[cfg(feature = \"passthrough-decoder\")]\npub use passthrough_decoder::PassthroughDecoder;\n\nmod symphonia_decoder;\npub use symphonia_decoder::SymphoniaDecoder;\n\n#[derive(Error, Debug)]\npub enum DecoderError {\n    #[error(\"Passthrough Decoder Error: {0}\")]\n    PassthroughDecoder(String),\n    #[error(\"Symphonia Decoder Error: {0}\")]\n    SymphoniaDecoder(String),\n}\n\npub type DecoderResult<T> = Result<T, DecoderError>;\n\n#[derive(Error, Debug)]\npub enum AudioPacketError {\n    #[error(\"Decoder Raw Error: Can't return Raw on Samples\")]\n    Raw,\n    #[error(\"Decoder Samples Error: Can't return Samples on Raw\")]\n    Samples,\n}\n\npub type AudioPacketResult<T> = Result<T, AudioPacketError>;\n\npub enum AudioPacket {\n    Samples(Vec<f64>),\n    Raw(Vec<u8>),\n}\n\nimpl AudioPacket {\n    #[inline]\n    pub fn samples(&self) -> AudioPacketResult<&[f64]> {\n        match self {\n            AudioPacket::Samples(s) => Ok(s),\n            AudioPacket::Raw(_) => Err(AudioPacketError::Raw),\n        }\n    }\n\n    #[inline]\n    pub fn raw(&self) -> AudioPacketResult<&[u8]> {\n        match self {\n            AudioPacket::Raw(d) => Ok(d),\n            AudioPacket::Samples(_) => Err(AudioPacketError::Samples),\n        }\n    }\n\n    #[inline]\n    pub fn is_empty(&self) -> bool {\n        match self {\n            AudioPacket::Samples(s) => s.is_empty(),\n            AudioPacket::Raw(d) => d.is_empty(),\n        }\n    }\n}\n\n#[derive(Debug, Clone)]\npub struct AudioPacketPosition {\n    pub position_ms: u32,\n    pub skipped: bool,\n}\n\nimpl Deref for AudioPacketPosition {\n    type Target = u32;\n    fn deref(&self) -> &Self::Target {\n        &self.position_ms\n    }\n}\n\npub trait AudioDecoder {\n    fn seek(&mut self, position_ms: u32) -> Result<u32, DecoderError>;\n    fn next_packet(&mut self) -> DecoderResult<Option<(AudioPacketPosition, AudioPacket)>>;\n}\n\nimpl From<DecoderError> for librespot_core::error::Error {\n    fn from(err: DecoderError) -> Self {\n        librespot_core::error::Error::aborted(err)\n    }\n}\n\nimpl From<symphonia::core::errors::Error> for DecoderError {\n    fn from(err: symphonia::core::errors::Error) -> Self {\n        Self::SymphoniaDecoder(err.to_string())\n    }\n}\n"
  },
  {
    "path": "playback/src/decoder/passthrough_decoder.rs",
    "content": "// Passthrough decoder for librespot\nuse std::{\n    io::{Read, Seek},\n    time::{SystemTime, UNIX_EPOCH},\n};\n\n// TODO: move this to the Symphonia Ogg demuxer\nuse ogg::{OggReadError, Packet, PacketReader, PacketWriteEndInfo, PacketWriter};\n\nuse super::{AudioDecoder, AudioPacket, AudioPacketPosition, DecoderError, DecoderResult};\n\nuse crate::{\n    MS_PER_PAGE, PAGES_PER_MS,\n    metadata::audio::{AudioFileFormat, AudioFiles},\n};\n\nfn get_header<T>(code: u8, rdr: &mut PacketReader<T>) -> DecoderResult<Vec<u8>>\nwhere\n    T: Read + Seek,\n{\n    let pck: Packet = rdr\n        .read_packet_expected()\n        .map_err(|e| DecoderError::PassthroughDecoder(e.to_string()))?;\n\n    let pkt_type = pck.data[0];\n    debug!(\"Vorbis header type {}\", &pkt_type);\n\n    if pkt_type != code {\n        return Err(DecoderError::PassthroughDecoder(\"Invalid Data\".into()));\n    }\n\n    Ok(pck.data)\n}\n\npub struct PassthroughDecoder<R: Read + Seek> {\n    rdr: PacketReader<R>,\n    wtr: PacketWriter<'static, Vec<u8>>,\n    eos: bool,\n    bos: bool,\n    ofsgp_page: u64,\n    stream_serial: u32,\n    ident: Vec<u8>,\n    comment: Vec<u8>,\n    setup: Vec<u8>,\n}\n\nimpl<R: Read + Seek> PassthroughDecoder<R> {\n    /// Constructs a new Decoder from a given implementation of `Read + Seek`.\n    pub fn new(rdr: R, format: AudioFileFormat) -> DecoderResult<Self> {\n        if !AudioFiles::is_ogg_vorbis(format) {\n            return Err(DecoderError::PassthroughDecoder(format!(\n                \"Passthrough decoder is not implemented for format {format:?}\"\n            )));\n        }\n\n        let mut rdr = PacketReader::new(rdr);\n        let since_epoch = SystemTime::now()\n            .duration_since(UNIX_EPOCH)\n            .map_err(|e| DecoderError::PassthroughDecoder(e.to_string()))?;\n        let stream_serial = since_epoch.as_millis() as u32;\n\n        info!(\"Starting passthrough track with serial {stream_serial}\");\n\n        // search for ident, comment, setup\n        let ident = get_header(1, &mut rdr)?;\n        let comment = get_header(3, &mut rdr)?;\n        let setup = get_header(5, &mut rdr)?;\n\n        // remove un-needed packets\n        rdr.delete_unread_packets();\n\n        Ok(PassthroughDecoder {\n            rdr,\n            wtr: PacketWriter::new(Vec::new()),\n            ofsgp_page: 0,\n            stream_serial,\n            ident,\n            comment,\n            setup,\n            eos: false,\n            bos: false,\n        })\n    }\n\n    fn position_pcm_to_ms(position_pcm: u64) -> u32 {\n        (position_pcm as f64 * MS_PER_PAGE) as u32\n    }\n}\n\nimpl<R: Read + Seek> AudioDecoder for PassthroughDecoder<R> {\n    fn seek(&mut self, position_ms: u32) -> Result<u32, DecoderError> {\n        let absgp = (position_ms as f64 * PAGES_PER_MS) as u64;\n\n        // add an eos to previous stream if missing\n        if self.bos && !self.eos {\n            match self.rdr.read_packet() {\n                Ok(Some(pck)) => {\n                    let absgp_page = pck.absgp_page() - self.ofsgp_page;\n                    self.wtr\n                        .write_packet(\n                            pck.data,\n                            self.stream_serial,\n                            PacketWriteEndInfo::EndStream,\n                            absgp_page,\n                        )\n                        .map_err(|e| DecoderError::PassthroughDecoder(e.to_string()))?;\n                }\n                _ => warn! {\"Cannot write EoS after seeking\"},\n            };\n        }\n\n        self.eos = false;\n        self.bos = false;\n        self.ofsgp_page = 0;\n        self.stream_serial += 1;\n\n        match self.rdr.seek_absgp(None, absgp) {\n            Ok(_) => {\n                // need to set some offset for next_page()\n                let pck = self\n                    .rdr\n                    .read_packet()\n                    .map_err(|e| DecoderError::PassthroughDecoder(e.to_string()))?;\n                match pck {\n                    Some(pck) => {\n                        let new_page = pck.absgp_page();\n                        self.ofsgp_page = new_page;\n                        debug!(\"Seek to offset page {}\", new_page);\n                        let new_position_ms = Self::position_pcm_to_ms(new_page);\n                        Ok(new_position_ms)\n                    }\n                    None => Err(DecoderError::PassthroughDecoder(\"Packet is None\".into())),\n                }\n            }\n            Err(e) => Err(DecoderError::PassthroughDecoder(e.to_string())),\n        }\n    }\n\n    fn next_packet(&mut self) -> DecoderResult<Option<(AudioPacketPosition, AudioPacket)>> {\n        // write headers if we are (re)starting\n        if !self.bos {\n            self.wtr\n                .write_packet(\n                    self.ident.clone(),\n                    self.stream_serial,\n                    PacketWriteEndInfo::EndPage,\n                    0,\n                )\n                .map_err(|e| DecoderError::PassthroughDecoder(e.to_string()))?;\n            self.wtr\n                .write_packet(\n                    self.comment.clone(),\n                    self.stream_serial,\n                    PacketWriteEndInfo::NormalPacket,\n                    0,\n                )\n                .map_err(|e| DecoderError::PassthroughDecoder(e.to_string()))?;\n            self.wtr\n                .write_packet(\n                    self.setup.clone(),\n                    self.stream_serial,\n                    PacketWriteEndInfo::EndPage,\n                    0,\n                )\n                .map_err(|e| DecoderError::PassthroughDecoder(e.to_string()))?;\n            self.bos = true;\n            debug!(\"Wrote Ogg headers\");\n        }\n\n        loop {\n            let pck = match self.rdr.read_packet() {\n                Ok(Some(pck)) => pck,\n                Ok(None) | Err(OggReadError::NoCapturePatternFound) => {\n                    info!(\"end of streaming\");\n                    return Ok(None);\n                }\n                Err(e) => return Err(DecoderError::PassthroughDecoder(e.to_string())),\n            };\n\n            let pckgp_page = pck.absgp_page();\n\n            // skip till we have audio and a calculable granule position\n            if pckgp_page == 0 || pckgp_page == self.ofsgp_page {\n                continue;\n            }\n\n            // set packet type\n            let inf = if pck.last_in_stream() {\n                self.eos = true;\n                PacketWriteEndInfo::EndStream\n            } else if pck.last_in_page() {\n                PacketWriteEndInfo::EndPage\n            } else {\n                PacketWriteEndInfo::NormalPacket\n            };\n\n            self.wtr\n                .write_packet(\n                    pck.data,\n                    self.stream_serial,\n                    inf,\n                    pckgp_page - self.ofsgp_page,\n                )\n                .map_err(|e| DecoderError::PassthroughDecoder(e.to_string()))?;\n\n            let data = self.wtr.inner_mut();\n\n            if !data.is_empty() {\n                let position_ms = Self::position_pcm_to_ms(pckgp_page);\n                let packet_position = AudioPacketPosition {\n                    position_ms,\n                    skipped: false,\n                };\n\n                let ogg_data = AudioPacket::Raw(std::mem::take(data));\n\n                return Ok(Some((packet_position, ogg_data)));\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "playback/src/decoder/symphonia_decoder.rs",
    "content": "use std::{io, time::Duration};\n\nuse symphonia::core::{\n    audio::SampleBuffer,\n    codecs::{Decoder, DecoderOptions},\n    errors::Error,\n    formats::{FormatOptions, SeekMode, SeekTo},\n    io::{MediaSource, MediaSourceStream, MediaSourceStreamOptions},\n    meta::{MetadataOptions, StandardTagKey, Value},\n    probe::{Hint, ProbeResult},\n};\n\nuse super::{AudioDecoder, AudioPacket, AudioPacketPosition, DecoderError, DecoderResult};\n\nuse crate::{NUM_CHANNELS, PAGES_PER_MS, SAMPLE_RATE, player::NormalisationData, symphonia_util};\n\npub struct SymphoniaDecoder {\n    probe_result: ProbeResult,\n    decoder: Box<dyn Decoder>,\n    sample_buffer: Option<SampleBuffer<f64>>,\n}\n\n#[derive(Default)]\npub(crate) struct LocalFileMetadata {\n    pub name: Option<String>,\n    pub language: Option<String>,\n    pub album: Option<String>,\n    pub artists: Option<String>,\n    pub album_artists: Option<String>,\n    pub number: Option<u32>,\n    pub disc_number: Option<u32>,\n}\n\nimpl SymphoniaDecoder {\n    pub fn new<R>(input: R, hint: Hint) -> DecoderResult<Self>\n    where\n        R: MediaSource + 'static,\n    {\n        let mss_opts = MediaSourceStreamOptions {\n            buffer_len: librespot_audio::AudioFetchParams::get().minimum_download_size,\n        };\n        let mss = MediaSourceStream::new(Box::new(input), mss_opts);\n\n        let format_opts = FormatOptions {\n            enable_gapless: true,\n            ..Default::default()\n        };\n        let metadata_opts: MetadataOptions = Default::default();\n\n        let probe_result =\n            symphonia::default::get_probe().format(&hint, mss, &format_opts, &metadata_opts)?;\n\n        let format = &probe_result.format;\n\n        let track = format.default_track().ok_or_else(|| {\n            DecoderError::SymphoniaDecoder(\"Could not retrieve default track\".into())\n        })?;\n\n        let decoder_opts: DecoderOptions = Default::default();\n\n        let decoder = symphonia::default::get_codecs().make(&track.codec_params, &decoder_opts)?;\n\n        let rate = decoder.codec_params().sample_rate.ok_or_else(|| {\n            DecoderError::SymphoniaDecoder(\"Could not retrieve sample rate\".into())\n        })?;\n\n        // TODO: The official client supports local files with sample rates other than 44,100 kHz.\n        // To play these accurately, we need to either resample the input audio, or introduce a way\n        // to change the player's current sample rate (likely by closing and re-opening the sink\n        // with new parameters).\n        if rate != SAMPLE_RATE {\n            return Err(DecoderError::SymphoniaDecoder(format!(\n                \"Unsupported sample rate: {rate}\"\n            )));\n        }\n\n        let channels = decoder.codec_params().channels.ok_or_else(|| {\n            DecoderError::SymphoniaDecoder(\"Could not retrieve channel configuration\".into())\n        })?;\n        if channels.count() != NUM_CHANNELS as usize {\n            return Err(DecoderError::SymphoniaDecoder(format!(\n                \"Unsupported number of channels: {channels}\"\n            )));\n        }\n\n        Ok(Self {\n            probe_result,\n            decoder,\n            // We set the sample buffer when decoding the first full packet,\n            // whose duration is also the ideal sample buffer size.\n            sample_buffer: None,\n        })\n    }\n\n    pub fn normalisation_data(&mut self) -> Option<NormalisationData> {\n        let metadata = symphonia_util::get_latest_metadata(&mut self.probe_result)?;\n        let tags = metadata.current()?.tags();\n\n        if tags.is_empty() {\n            None\n        } else {\n            let mut data = NormalisationData::default();\n\n            for tag in tags {\n                if let Value::Float(value) = tag.value {\n                    match tag.std_key {\n                        Some(StandardTagKey::ReplayGainAlbumGain) => data.album_gain_db = value,\n                        Some(StandardTagKey::ReplayGainAlbumPeak) => data.album_peak = value,\n                        Some(StandardTagKey::ReplayGainTrackGain) => data.track_gain_db = value,\n                        Some(StandardTagKey::ReplayGainTrackPeak) => data.track_peak = value,\n                        _ => (),\n                    }\n                }\n            }\n\n            Some(data)\n        }\n    }\n\n    pub(crate) fn local_file_metadata(&mut self) -> Option<LocalFileMetadata> {\n        let metadata = symphonia_util::get_latest_metadata(&mut self.probe_result)?;\n        let tags = metadata.current()?.tags();\n        let mut metadata = LocalFileMetadata::default();\n\n        for tag in tags {\n            if let Value::String(value) = &tag.value {\n                match tag.std_key {\n                    // We could possibly use mem::take here to avoid cloning, but that risks leaving\n                    // the audio item metadata in a bad state.\n                    Some(StandardTagKey::TrackTitle) => metadata.name = Some(value.clone()),\n                    Some(StandardTagKey::Language) => metadata.language = Some(value.clone()),\n                    Some(StandardTagKey::Artist) => metadata.artists = Some(value.clone()),\n                    Some(StandardTagKey::AlbumArtist) => {\n                        metadata.album_artists = Some(value.clone())\n                    }\n                    Some(StandardTagKey::Album) => metadata.album = Some(value.clone()),\n                    Some(StandardTagKey::TrackNumber) => {\n                        metadata.number = match value.parse::<u32>() {\n                            Ok(value) => Some(value),\n                            Err(e) => {\n                                warn!(\n                                    \"Failed to parse local file's track number tag '{value}': {e}\"\n                                );\n                                None\n                            }\n                        }\n                    }\n                    Some(StandardTagKey::DiscNumber) => {\n                        metadata.disc_number = match value.parse::<u32>() {\n                            Ok(value) => Some(value),\n                            Err(e) => {\n                                warn!(\n                                    \"Failed to parse local file's disc number tag '{value}': {e}\"\n                                );\n                                None\n                            }\n                        }\n                    }\n                    _ => (),\n                }\n            } else if let Value::UnsignedInt(value) = &tag.value {\n                match tag.std_key {\n                    Some(StandardTagKey::TrackNumber) => metadata.number = Some(*value as u32),\n                    Some(StandardTagKey::DiscNumber) => metadata.disc_number = Some(*value as u32),\n                    _ => (),\n                }\n            } else if let Value::SignedInt(value) = &tag.value {\n                match tag.std_key {\n                    Some(StandardTagKey::TrackNumber) => metadata.number = Some(*value as u32),\n                    Some(StandardTagKey::DiscNumber) => metadata.disc_number = Some(*value as u32),\n                    _ => (),\n                }\n            }\n        }\n\n        Some(metadata)\n    }\n\n    #[inline]\n    fn ts_to_ms(&self, ts: u64) -> u32 {\n        match self.decoder.codec_params().time_base {\n            Some(time_base) => {\n                let time = Duration::from(time_base.calc_time(ts));\n                time.as_millis() as u32\n            }\n            // Fallback in the unexpected case that the format has no base time set.\n            None => (ts as f64 * PAGES_PER_MS) as u32,\n        }\n    }\n}\n\nimpl AudioDecoder for SymphoniaDecoder {\n    fn seek(&mut self, position_ms: u32) -> Result<u32, DecoderError> {\n        // \"Saturate\" the position_ms to the duration of the track if it exceeds it.\n        let mut target = Duration::from_millis(position_ms.into());\n        let codec_params = self.decoder.codec_params();\n        if let (Some(time_base), Some(n_frames)) = (codec_params.time_base, codec_params.n_frames) {\n            let duration = Duration::from(time_base.calc_time(n_frames));\n            if target > duration {\n                target = duration;\n            }\n        }\n\n        // `track_id: None` implies the default track ID (of the container, not of Spotify).\n        let seeked_to_ts = self.probe_result.format.seek(\n            SeekMode::Accurate,\n            SeekTo::Time {\n                time: target.into(),\n                track_id: None,\n            },\n        )?;\n\n        // Seeking is a `FormatReader` operation, so the decoder cannot reliably\n        // know when a seek took place. Reset it to avoid audio glitches.\n        self.decoder.reset();\n\n        Ok(self.ts_to_ms(seeked_to_ts.actual_ts))\n    }\n\n    fn next_packet(&mut self) -> DecoderResult<Option<(AudioPacketPosition, AudioPacket)>> {\n        let mut skipped = false;\n\n        loop {\n            let packet = match self.probe_result.format.next_packet() {\n                Ok(packet) => packet,\n                Err(Error::IoError(err)) => {\n                    if err.kind() == io::ErrorKind::UnexpectedEof {\n                        return Ok(None);\n                    } else {\n                        return Err(DecoderError::SymphoniaDecoder(err.to_string()));\n                    }\n                }\n                Err(err) => {\n                    return Err(err.into());\n                }\n            };\n\n            let position_ms = self.ts_to_ms(packet.ts());\n            let packet_position = AudioPacketPosition {\n                position_ms,\n                skipped,\n            };\n\n            match self.decoder.decode(&packet) {\n                Ok(decoded) => {\n                    let sample_buffer = match self.sample_buffer.as_mut() {\n                        Some(buffer) => buffer,\n                        None => {\n                            let spec = *decoded.spec();\n                            let duration = decoded.capacity() as u64;\n                            self.sample_buffer.insert(SampleBuffer::new(duration, spec))\n                        }\n                    };\n\n                    sample_buffer.copy_interleaved_ref(decoded);\n                    let samples = AudioPacket::Samples(sample_buffer.samples().to_vec());\n\n                    return Ok(Some((packet_position, samples)));\n                }\n                Err(Error::DecodeError(_)) => {\n                    // The packet failed to decode due to corrupted or invalid data, get a new\n                    // packet and try again.\n                    warn!(\"Skipping malformed audio packet at {position_ms} ms\");\n                    skipped = true;\n                    continue;\n                }\n                Err(err) => return Err(err.into()),\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "playback/src/dither.rs",
    "content": "use rand::SeedableRng;\nuse rand::rngs::SmallRng;\nuse rand_distr::{Distribution, Normal, Triangular, Uniform};\nuse std::fmt;\n\nuse crate::NUM_CHANNELS;\n\n// Dithering lowers digital-to-analog conversion (\"requantization\") error,\n// linearizing output, lowering distortion and replacing it with a constant,\n// fixed noise level, which is more pleasant to the ear than the distortion.\n//\n// Guidance:\n//\n//  * On S24, S24_3 and S24, the default is to use triangular dithering.\n//    Depending on personal preference you may use Gaussian dithering instead;\n//    it's not as good objectively, but it may be preferred subjectively if\n//    you are looking for a more \"analog\" sound akin to tape hiss.\n//\n//  * Advanced users who know that they have a DAC without noise shaping have\n//    a third option: high-passed dithering, which is like triangular dithering\n//    except that it moves dithering noise up in frequency where it is less\n//    audible. Note: 99% of DACs are of delta-sigma design with noise shaping,\n//    so unless you have a multibit / R2R DAC, or otherwise know what you are\n//    doing, this is not for you.\n//\n//  * Don't dither or shape noise on S32 or F32. On F32 it's not supported\n//    anyway (there are no integer conversions and so no rounding errors) and\n//    on S32 the noise level is so far down that it is simply inaudible even\n//    after volume normalisation and control.\n//\npub trait Ditherer {\n    fn new() -> Self\n    where\n        Self: Sized;\n    fn name(&self) -> &'static str;\n    fn noise(&mut self) -> f64;\n}\n\nimpl fmt::Display for dyn Ditherer {\n    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n        write!(f, \"{}\", self.name())\n    }\n}\n\nfn create_rng() -> SmallRng {\n    SmallRng::from_os_rng()\n}\n\npub struct TriangularDitherer {\n    cached_rng: SmallRng,\n    distribution: Triangular<f64>,\n}\n\nimpl Ditherer for TriangularDitherer {\n    fn new() -> Self {\n        Self {\n            cached_rng: create_rng(),\n            // 2 LSB peak-to-peak needed to linearize the response:\n            distribution: Triangular::new(-1.0, 1.0, 0.0).unwrap(),\n        }\n    }\n\n    fn name(&self) -> &'static str {\n        Self::NAME\n    }\n\n    #[inline]\n    fn noise(&mut self) -> f64 {\n        self.distribution.sample(&mut self.cached_rng)\n    }\n}\n\nimpl TriangularDitherer {\n    pub const NAME: &'static str = \"tpdf\";\n}\n\npub struct GaussianDitherer {\n    cached_rng: SmallRng,\n    distribution: Normal<f64>,\n}\n\nimpl Ditherer for GaussianDitherer {\n    fn new() -> Self {\n        Self {\n            cached_rng: create_rng(),\n            // For Gaussian to achieve equivalent decorrelation to triangular dithering, it needs\n            // 3-4 dB higher amplitude than TPDF's optimal 0.408 LSB. If optimizing:\n            // - minimum correlation: σ ≈ 0.58\n            // - perceptual equivalence: σ ≈ 0.65\n            // - worst-case performance: σ ≈ 0.70\n            //\n            // σ = 0.6 LSB is a reasonable compromise that balances mathematical theory with\n            // empirical performance across various signal types.\n            distribution: Normal::new(0.0, 0.6).unwrap(),\n        }\n    }\n\n    fn name(&self) -> &'static str {\n        Self::NAME\n    }\n\n    #[inline]\n    fn noise(&mut self) -> f64 {\n        self.distribution.sample(&mut self.cached_rng)\n    }\n}\n\nimpl GaussianDitherer {\n    pub const NAME: &'static str = \"gpdf\";\n}\n\npub struct HighPassDitherer {\n    active_channel: usize,\n    previous_noises: [f64; NUM_CHANNELS as usize],\n    cached_rng: SmallRng,\n    distribution: Uniform<f64>,\n}\n\nimpl Ditherer for HighPassDitherer {\n    fn new() -> Self {\n        Self {\n            active_channel: 0,\n            previous_noises: [0.0; NUM_CHANNELS as usize],\n            cached_rng: create_rng(),\n            // 1 LSB +/- 1 LSB (previous) = 2 LSB\n            distribution: Uniform::new_inclusive(-0.5, 0.5)\n                .expect(\"Failed to create uniform distribution\"),\n        }\n    }\n\n    fn name(&self) -> &'static str {\n        Self::NAME\n    }\n\n    #[inline]\n    fn noise(&mut self) -> f64 {\n        let new_noise = self.distribution.sample(&mut self.cached_rng);\n        let high_passed_noise = new_noise - self.previous_noises[self.active_channel];\n        self.previous_noises[self.active_channel] = new_noise;\n        self.active_channel ^= 1;\n        high_passed_noise\n    }\n}\n\nimpl HighPassDitherer {\n    pub const NAME: &'static str = \"tpdf_hp\";\n}\n\npub fn mk_ditherer<D: Ditherer + 'static>() -> Box<dyn Ditherer> {\n    Box::new(D::new())\n}\n\npub type DithererBuilder = fn() -> Box<dyn Ditherer>;\n\npub fn find_ditherer(name: Option<String>) -> Option<DithererBuilder> {\n    match name.as_deref() {\n        Some(TriangularDitherer::NAME) => Some(mk_ditherer::<TriangularDitherer>),\n        Some(GaussianDitherer::NAME) => Some(mk_ditherer::<GaussianDitherer>),\n        Some(HighPassDitherer::NAME) => Some(mk_ditherer::<HighPassDitherer>),\n        _ => None,\n    }\n}\n"
  },
  {
    "path": "playback/src/lib.rs",
    "content": "#[macro_use]\nextern crate log;\n\nuse librespot_audio as audio;\nuse librespot_core as core;\nuse librespot_metadata as metadata;\n\npub mod audio_backend;\npub mod config;\npub mod convert;\npub mod decoder;\npub mod dither;\nmod local_file;\npub mod mixer;\npub mod player;\nmod symphonia_util;\n\npub const SAMPLE_RATE: u32 = 44100;\npub const NUM_CHANNELS: u8 = 2;\npub const SAMPLES_PER_SECOND: u32 = SAMPLE_RATE * NUM_CHANNELS as u32;\npub const PAGES_PER_MS: f64 = SAMPLE_RATE as f64 / 1000.0;\npub const MS_PER_PAGE: f64 = 1000.0 / SAMPLE_RATE as f64;\n"
  },
  {
    "path": "playback/src/local_file.rs",
    "content": "use crate::symphonia_util;\nuse librespot_core::{Error, SpotifyUri};\nuse std::{\n    collections::HashMap,\n    fs,\n    fs::File,\n    io,\n    path::{Path, PathBuf},\n    time::Duration,\n};\nuse symphonia::core::{\n    formats::FormatOptions,\n    io::MediaSourceStream,\n    meta::{MetadataOptions, StandardTagKey, Tag},\n    probe::{Hint, ProbeResult},\n};\n\n// \"Spotify supports .mp3, .mp4, and .m4p files. It doesn’t support .mp4 files that contain video,\n// or the iTunes lossless format (M4A).\"\n// https://community.spotify.com/t5/FAQs/Local-Files/ta-p/5186118\n//\n// There are some indications online that FLAC is supported, so check for this as well.\nconst SUPPORTED_FILE_EXTENSIONS: &[&str; 4] = &[\"mp3\", \"mp4\", \"m4p\", \"flac\"];\n\n#[derive(Default)]\npub struct LocalFileLookup(HashMap<SpotifyUri, PathBuf>);\n\nimpl LocalFileLookup {\n    pub fn get(&self, uri: &SpotifyUri) -> Option<&Path> {\n        self.0.get(uri).map(PathBuf::as_path)\n    }\n}\n\npub fn create_local_file_lookup(directories: &[PathBuf]) -> LocalFileLookup {\n    let mut lookup = LocalFileLookup(HashMap::new());\n\n    for path in directories {\n        if !path.is_dir() {\n            warn!(\n                \"Ignoring local file source {}: not a directory\",\n                path.display()\n            );\n            continue;\n        }\n\n        if let Err(e) = visit_dir(path, &mut lookup) {\n            warn!(\n                \"Failed to load entries from local file source {}: {}\",\n                path.display(),\n                e\n            );\n        }\n    }\n\n    lookup\n}\n\nfn visit_dir(dir: &Path, accumulator: &mut LocalFileLookup) -> io::Result<()> {\n    for entry in fs::read_dir(dir)? {\n        let path = entry?.path();\n        if path.is_dir() {\n            visit_dir(&path, accumulator)?;\n        } else {\n            let Some(file_extension) = path.extension().and_then(|e| e.to_str()) else {\n                continue;\n            };\n\n            let lowercase_extension = file_extension.to_lowercase();\n\n            if SUPPORTED_FILE_EXTENSIONS.contains(&lowercase_extension.as_str()) {\n                let uri = match get_uri_from_file(path.as_path(), file_extension) {\n                    Ok(uri) => uri,\n                    Err(e) => {\n                        warn!(\n                            \"Failed to determine URI of local file {}: {}\",\n                            path.display(),\n                            e\n                        );\n                        continue;\n                    }\n                };\n\n                accumulator.0.insert(uri, path);\n            }\n        }\n    }\n\n    Ok(())\n}\n\nfn get_uri_from_file(audio_path: &Path, file_extension: &str) -> Result<SpotifyUri, Error> {\n    let src = File::open(audio_path)?;\n    let mss = MediaSourceStream::new(Box::new(src), Default::default());\n\n    let mut hint = Hint::new();\n    hint.with_extension(file_extension);\n\n    let meta_opts: MetadataOptions = Default::default();\n    let fmt_opts: FormatOptions = Default::default();\n\n    let mut probed = symphonia::default::get_probe()\n        .format(&hint, mss, &fmt_opts, &meta_opts)\n        .map_err(|_| Error::internal(\"Failed to probe file\"))?;\n\n    let mut artist: Option<String> = None;\n    let mut album_title: Option<String> = None;\n    let mut track_title: Option<String> = None;\n\n    fn get_tags(probed: &mut ProbeResult) -> Option<Vec<Tag>> {\n        let metadata = symphonia_util::get_latest_metadata(probed)?;\n        let metadata_rev = metadata.current()?;\n        Some(metadata_rev.tags().to_vec())\n    }\n\n    for tag in get_tags(&mut probed).ok_or(Error::internal(\"Failed to probe audio tags\"))? {\n        if let Some(std_key) = tag.std_key {\n            match std_key {\n                StandardTagKey::Album => {\n                    album_title.replace(tag.value.to_string());\n                }\n                StandardTagKey::Artist => {\n                    artist.replace(tag.value.to_string());\n                }\n                StandardTagKey::TrackTitle => {\n                    track_title.replace(tag.value.to_string());\n                }\n                _ => {\n                    continue;\n                }\n            }\n        }\n    }\n\n    let first_track = probed\n        .format\n        .default_track()\n        .ok_or(Error::internal(\"Failed to find an audio track\"))?;\n\n    let time_base = first_track\n        .codec_params\n        .time_base\n        .ok_or(Error::internal(\"Failed to calculate track duration\"))?;\n\n    let num_frames = first_track\n        .codec_params\n        .n_frames\n        .ok_or(Error::internal(\"Failed to calculate track duration\"))?;\n\n    let time = time_base.calc_time(num_frames);\n\n    fn format_uri_part(input: Option<String>) -> String {\n        input\n            .map(|s| {\n                let bytes = s.into_bytes();\n                let encoded = form_urlencoded::byte_serialize(bytes.as_slice());\n                encoded.collect::<String>()\n            })\n            .unwrap_or(\"\".to_owned())\n    }\n\n    Ok(SpotifyUri::Local {\n        artist: format_uri_part(artist),\n        album_title: format_uri_part(album_title),\n        track_title: format_uri_part(track_title),\n        duration: Duration::from_secs(time.seconds),\n    })\n}\n"
  },
  {
    "path": "playback/src/mixer/alsamixer.rs",
    "content": "use crate::player::{db_to_ratio, ratio_to_db};\n\nuse super::mappings::{LogMapping, MappedCtrl, VolumeMapping};\nuse super::{Mixer, MixerConfig, VolumeCtrl};\n\nuse alsa::Error as AlsaError;\nuse alsa::ctl::{ElemId, ElemIface};\nuse alsa::mixer::{MilliBel, SelemChannelId, SelemId};\nuse alsa::{Ctl, Round};\n\nuse librespot_core::Error;\nuse std::ffi::{CString, NulError};\nuse thiserror::Error;\n\n#[derive(Clone)]\n#[allow(dead_code)]\npub struct AlsaMixer {\n    config: MixerConfig,\n    min: i64,\n    max: i64,\n    range: i64,\n    min_db: f64,\n    max_db: f64,\n    db_range: f64,\n    has_switch: bool,\n    is_softvol: bool,\n    use_linear_in_db: bool,\n}\n\n// min_db cannot be depended on to be mute. Also note that contrary to\n// its name copied verbatim from Alsa, this is in millibel scale.\nconst SND_CTL_TLV_DB_GAIN_MUTE: MilliBel = MilliBel(-9999999);\nconst ZERO_DB: MilliBel = MilliBel(0);\n\n#[derive(Debug, Error)]\nenum AlsaMixerError {\n    #[error(\"Could not open Alsa mixer. {0}\")]\n    CouldNotOpen(AlsaError),\n    #[error(\"Could not find Alsa mixer control\")]\n    CouldNotFindController,\n    #[error(\"Could not open Alsa softvol with that device. {0}\")]\n    CouldNotOpenWithDevice(AlsaError),\n    #[error(\"Could not open Alsa softvol with that name. {0}\")]\n    CouldNotOpenWithName(NulError),\n    #[error(\"Could not get Alsa softvol dB range. {0}\")]\n    NoDbRange(AlsaError),\n    #[error(\"Could not convert Alsa raw volume to dB volume. {0}\")]\n    CouldNotConvertRaw(AlsaError),\n}\n\nimpl From<AlsaMixerError> for Error {\n    fn from(value: AlsaMixerError) -> Self {\n        Error::failed_precondition(value)\n    }\n}\n\nimpl Mixer for AlsaMixer {\n    fn open(config: MixerConfig) -> Result<Self, Error> {\n        info!(\n            \"Mixing with Alsa and volume control: {:?} for device: {} with mixer control: {},{}\",\n            config.volume_ctrl, config.device, config.control, config.index,\n        );\n\n        let mut config = config; // clone\n\n        let mixer =\n            alsa::mixer::Mixer::new(&config.device, false).map_err(AlsaMixerError::CouldNotOpen)?;\n        let simple_element = mixer\n            .find_selem(&SelemId::new(&config.control, config.index))\n            .ok_or(AlsaMixerError::CouldNotFindController)?;\n\n        // Query capabilities\n        let has_switch = simple_element.has_playback_switch();\n        let is_softvol = simple_element\n            .get_playback_vol_db(SelemChannelId::mono())\n            .is_err();\n\n        // Query raw volume range\n        let (min, max) = simple_element.get_playback_volume_range();\n        let range = i64::abs(max - min);\n\n        // Query dB volume range -- note that Alsa exposes a different\n        // API for hardware and software mixers\n        let (min_millibel, max_millibel) = if is_softvol {\n            let control =\n                Ctl::new(&config.device, false).map_err(AlsaMixerError::CouldNotOpenWithDevice)?;\n            let mut element_id = ElemId::new(ElemIface::Mixer);\n            element_id.set_name(\n                &CString::new(config.control.as_str())\n                    .map_err(AlsaMixerError::CouldNotOpenWithName)?,\n            );\n            element_id.set_index(config.index);\n            let (min_millibel, mut max_millibel) = control\n                .get_db_range(&element_id)\n                .map_err(AlsaMixerError::NoDbRange)?;\n\n            // Alsa can report incorrect maximum volumes due to rounding\n            // errors. e.g. Alsa rounds [-60.0..0.0] in range [0..255] to\n            // step size 0.23. Then multiplying 0.23 by 255 incorrectly\n            // returns a dB range of 58.65 instead of 60 dB, from\n            // [-60.00..-1.35]. This workaround checks the default case\n            // where the maximum dB volume is expected to be 0, and cannot\n            // cover all cases.\n            if max_millibel != ZERO_DB {\n                warn!(\"Alsa mixer reported maximum dB != 0, which is suspect\");\n                let reported_step_size = (max_millibel - min_millibel).0 / range;\n                let assumed_step_size = (ZERO_DB - min_millibel).0 / range;\n                if reported_step_size == assumed_step_size {\n                    warn!(\n                        \"Alsa rounding error detected, setting maximum dB to {:.2} instead of {:.2}\",\n                        ZERO_DB.to_db(),\n                        max_millibel.to_db()\n                    );\n                    max_millibel = ZERO_DB;\n                } else {\n                    warn!(\"Please manually set `--volume-range` if this is incorrect\");\n                }\n            }\n            (min_millibel, max_millibel)\n        } else {\n            let (mut min_millibel, max_millibel) = simple_element.get_playback_db_range();\n\n            // Some controls report that their minimum volume is mute, instead\n            // of their actual lowest dB setting before that.\n            if min_millibel == SND_CTL_TLV_DB_GAIN_MUTE && min < max {\n                debug!(\"Alsa mixer reported minimum dB as mute, trying workaround\");\n                min_millibel = simple_element\n                    .ask_playback_vol_db(min + 1)\n                    .map_err(AlsaMixerError::CouldNotConvertRaw)?;\n            }\n            (min_millibel, max_millibel)\n        };\n\n        let min_db = min_millibel.to_db() as f64;\n        let max_db = max_millibel.to_db() as f64;\n        let reported_db_range = f64::abs(max_db - min_db);\n\n        // Synchronize the volume control dB range with the mixer control,\n        // unless it was already set with a command line option.\n        let db_range = if config.volume_ctrl.range_ok() {\n            let db_range_override = config.volume_ctrl.db_range();\n            if db_range_override.is_normal() {\n                db_range_override\n            } else {\n                reported_db_range\n            }\n        } else {\n            config.volume_ctrl.set_db_range(reported_db_range);\n            reported_db_range\n        };\n\n        if reported_db_range == db_range {\n            debug!(\"Alsa dB volume range was reported as {}\", reported_db_range);\n            if reported_db_range > 100.0 {\n                debug!(\"Alsa mixer reported dB range > 100, which is suspect\");\n                debug!(\"Please manually set `--volume-range` if this is incorrect\");\n            }\n        } else {\n            debug!(\n                \"Alsa dB volume range was reported as {} but overridden to {}\",\n                reported_db_range, db_range\n            );\n        }\n\n        // For hardware controls with a small range (24 dB or less),\n        // force using the dB API with a linear mapping.\n        let mut use_linear_in_db = false;\n        if !is_softvol && db_range <= 24.0 {\n            use_linear_in_db = true;\n            config.volume_ctrl = VolumeCtrl::Linear;\n        }\n\n        debug!(\"Alsa mixer control is softvol: {}\", is_softvol);\n        debug!(\"Alsa support for playback (mute) switch: {}\", has_switch);\n        debug!(\"Alsa raw volume range: [{}..{}] ({})\", min, max, range);\n        debug!(\n            \"Alsa dB volume range: [{:.2}..{:.2}] ({:.2})\",\n            min_db, max_db, db_range\n        );\n        debug!(\"Alsa forcing linear dB mapping: {}\", use_linear_in_db);\n\n        Ok(Self {\n            config,\n            min,\n            max,\n            range,\n            min_db,\n            max_db,\n            db_range,\n            has_switch,\n            is_softvol,\n            use_linear_in_db,\n        })\n    }\n\n    fn volume(&self) -> u16 {\n        let mixer =\n            alsa::mixer::Mixer::new(&self.config.device, false).expect(\"Could not open Alsa mixer\");\n        let simple_element = mixer\n            .find_selem(&SelemId::new(&self.config.control, self.config.index))\n            .expect(\"Could not find Alsa mixer control\");\n\n        if self.switched_off() {\n            return 0;\n        }\n\n        let mut mapped_volume = if self.is_softvol {\n            let raw_volume = simple_element\n                .get_playback_volume(SelemChannelId::mono())\n                .expect(\"Could not get raw Alsa volume\");\n            raw_volume as f64 / self.range as f64 - self.min as f64\n        } else {\n            let db_volume = simple_element\n                .get_playback_vol_db(SelemChannelId::mono())\n                .expect(\"Could not get Alsa dB volume\")\n                .to_db() as f64;\n\n            if self.use_linear_in_db {\n                (db_volume - self.min_db) / self.db_range\n            } else if f64::abs(db_volume - SND_CTL_TLV_DB_GAIN_MUTE.to_db() as f64) <= f64::EPSILON\n            {\n                0.0\n            } else {\n                db_to_ratio(db_volume - self.max_db)\n            }\n        };\n\n        // see comment in `set_volume` why we are handling an antilog volume\n        if mapped_volume > 0.0 && self.is_some_linear() {\n            mapped_volume = LogMapping::linear_to_mapped(mapped_volume, self.db_range);\n        }\n\n        self.config.volume_ctrl.as_unmapped(mapped_volume)\n    }\n\n    fn set_volume(&self, volume: u16) {\n        let mixer =\n            alsa::mixer::Mixer::new(&self.config.device, false).expect(\"Could not open Alsa mixer\");\n        let simple_element = mixer\n            .find_selem(&SelemId::new(&self.config.control, self.config.index))\n            .expect(\"Could not find Alsa mixer control\");\n\n        if self.has_switch {\n            if volume == 0 {\n                debug!(\"Disabling playback (setting mute) on Alsa\");\n                simple_element\n                    .set_playback_switch_all(0)\n                    .expect(\"Could not disable playback (set mute) on Alsa\");\n            } else if self.switched_off() {\n                debug!(\"Enabling playback (unsetting mute) on Alsa\");\n                simple_element\n                    .set_playback_switch_all(1)\n                    .expect(\"Could not enable playback (unset mute) on Alsa\");\n            }\n        }\n\n        let mut mapped_volume = self.config.volume_ctrl.to_mapped(volume);\n\n        // Alsa's linear algorithms map everything onto log. Alsa softvol does\n        // this internally. In the case of `use_linear_in_db` this happens\n        // automatically by virtue of the dB scale. This means that linear\n        // controls become log, log becomes log-on-log, and so on. To make\n        // the controls work as expected, perform an antilog calculation to\n        // counteract what Alsa will be doing to the set volume.\n        if mapped_volume > 0.0 && self.is_some_linear() {\n            mapped_volume = LogMapping::mapped_to_linear(mapped_volume, self.db_range);\n        }\n\n        if self.is_softvol {\n            let scaled_volume = (self.min as f64 + mapped_volume * self.range as f64) as i64;\n            debug!(\"Setting Alsa raw volume to {}\", scaled_volume);\n            simple_element\n                .set_playback_volume_all(scaled_volume)\n                .expect(\"Could not set Alsa raw volume\");\n            return;\n        }\n\n        let db_volume = if self.use_linear_in_db {\n            self.min_db + mapped_volume * self.db_range\n        } else if volume == 0 {\n            // prevent ratio_to_db(0.0) from returning -inf\n            SND_CTL_TLV_DB_GAIN_MUTE.to_db() as f64\n        } else {\n            ratio_to_db(mapped_volume) + self.max_db\n        };\n\n        debug!(\"Setting Alsa volume to {:.2} dB\", db_volume);\n        simple_element\n            .set_playback_db_all(MilliBel::from_db(db_volume as f32), Round::Floor)\n            .expect(\"Could not set Alsa dB volume\");\n    }\n}\n\nimpl AlsaMixer {\n    pub const NAME: &'static str = \"alsa\";\n\n    fn switched_off(&self) -> bool {\n        if !self.has_switch {\n            return false;\n        }\n\n        let mixer =\n            alsa::mixer::Mixer::new(&self.config.device, false).expect(\"Could not open Alsa mixer\");\n        let simple_element = mixer\n            .find_selem(&SelemId::new(&self.config.control, self.config.index))\n            .expect(\"Could not find Alsa mixer control\");\n\n        simple_element\n            .get_playback_switch(SelemChannelId::mono())\n            .map(|playback| playback == 0)\n            .unwrap_or(false)\n    }\n\n    fn is_some_linear(&self) -> bool {\n        self.is_softvol || self.use_linear_in_db\n    }\n}\n"
  },
  {
    "path": "playback/src/mixer/mappings.rs",
    "content": "use super::VolumeCtrl;\nuse crate::player::db_to_ratio;\n\npub trait MappedCtrl {\n    fn to_mapped(&self, volume: u16) -> f64;\n    fn as_unmapped(&self, mapped_volume: f64) -> u16;\n\n    fn db_range(&self) -> f64;\n    fn set_db_range(&mut self, new_db_range: f64);\n    fn range_ok(&self) -> bool;\n}\n\nimpl MappedCtrl for VolumeCtrl {\n    fn to_mapped(&self, volume: u16) -> f64 {\n        // More than just an optimization, this ensures that zero volume is\n        // really mute (both the log and cubic equations would otherwise not\n        // reach zero).\n        if volume == 0 {\n            return 0.0;\n        } else if volume == Self::MAX_VOLUME {\n            // And limit in case of rounding errors (as is the case for log).\n            return 1.0;\n        }\n\n        let normalized_volume = volume as f64 / Self::MAX_VOLUME as f64;\n        let mapped_volume = if self.range_ok() {\n            match *self {\n                Self::Cubic(db_range) => {\n                    CubicMapping::linear_to_mapped(normalized_volume, db_range)\n                }\n                Self::Log(db_range) => LogMapping::linear_to_mapped(normalized_volume, db_range),\n                _ => normalized_volume,\n            }\n        } else {\n            // Ensure not to return -inf or NaN due to division by zero.\n            error!(\"{self:?} does not work with 0 dB range, using linear mapping instead\");\n            normalized_volume\n        };\n\n        debug!(\n            \"Input volume {} mapped to: {:.2}%\",\n            volume,\n            mapped_volume * 100.0\n        );\n\n        mapped_volume\n    }\n\n    fn as_unmapped(&self, mapped_volume: f64) -> u16 {\n        // More than just an optimization, this ensures that zero mapped volume\n        // is unmapped to non-negative real numbers (otherwise the log and cubic\n        // equations would respectively return -inf and -1/9.)\n        if f64::abs(mapped_volume - 0.0) <= f64::EPSILON {\n            return 0;\n        } else if f64::abs(mapped_volume - 1.0) <= f64::EPSILON {\n            return Self::MAX_VOLUME;\n        }\n\n        let unmapped_volume = if self.range_ok() {\n            match *self {\n                Self::Cubic(db_range) => CubicMapping::mapped_to_linear(mapped_volume, db_range),\n                Self::Log(db_range) => LogMapping::mapped_to_linear(mapped_volume, db_range),\n                _ => mapped_volume,\n            }\n        } else {\n            // Ensure not to return -inf or NaN due to division by zero.\n            error!(\"{self:?} does not work with 0 dB range, using linear mapping instead\");\n            mapped_volume\n        };\n\n        (unmapped_volume * Self::MAX_VOLUME as f64) as u16\n    }\n\n    fn db_range(&self) -> f64 {\n        match *self {\n            Self::Fixed => 0.0,\n            Self::Linear => Self::DEFAULT_DB_RANGE, // arbitrary, could be anything > 0\n            Self::Log(db_range) | Self::Cubic(db_range) => db_range,\n        }\n    }\n\n    fn set_db_range(&mut self, new_db_range: f64) {\n        match self {\n            Self::Cubic(db_range) | Self::Log(db_range) => *db_range = new_db_range,\n            _ => error!(\"Invalid to set dB range for volume control type {self:?}\"),\n        }\n\n        debug!(\"Volume control is now {self:?}\")\n    }\n\n    fn range_ok(&self) -> bool {\n        self.db_range() > 0.0 || matches!(self, Self::Fixed | Self::Linear)\n    }\n}\n\npub trait VolumeMapping {\n    fn linear_to_mapped(unmapped_volume: f64, db_range: f64) -> f64;\n    fn mapped_to_linear(mapped_volume: f64, db_range: f64) -> f64;\n}\n\n// Volume conversion taken from: https://www.dr-lex.be/info-stuff/volumecontrols.html#ideal2\n//\n// As the human auditory system has a logarithmic sensitivity curve, this\n// mapping results in a near linear loudness experience with the listener.\npub struct LogMapping {}\nimpl VolumeMapping for LogMapping {\n    fn linear_to_mapped(normalized_volume: f64, db_range: f64) -> f64 {\n        let (db_ratio, ideal_factor) = Self::coefficients(db_range);\n        f64::exp(ideal_factor * normalized_volume) / db_ratio\n    }\n\n    fn mapped_to_linear(mapped_volume: f64, db_range: f64) -> f64 {\n        let (db_ratio, ideal_factor) = Self::coefficients(db_range);\n        f64::ln(db_ratio * mapped_volume) / ideal_factor\n    }\n}\n\nimpl LogMapping {\n    fn coefficients(db_range: f64) -> (f64, f64) {\n        let db_ratio = db_to_ratio(db_range);\n        let ideal_factor = f64::ln(db_ratio);\n        (db_ratio, ideal_factor)\n    }\n}\n\n// Ported from: https://github.com/alsa-project/alsa-utils/blob/master/alsamixer/volume_mapping.c\n// which in turn was inspired by: https://www.robotplanet.dk/audio/audio_gui_design/\n//\n// Though this mapping is computationally less expensive than the logarithmic\n// mapping, it really does not matter as librespot memoizes the mapped value.\n// Use this mapping if you have some reason to mimic Alsa's native mixer or\n// prefer a more granular control in the upper volume range.\n//\n// Note: https://www.dr-lex.be/info-stuff/volumecontrols.html#ideal3 shows\n// better approximations to the logarithmic curve but because we only intend\n// to mimic Alsa here, we do not implement them. If your desire is to use a\n// logarithmic mapping, then use that volume control.\npub struct CubicMapping {}\nimpl VolumeMapping for CubicMapping {\n    fn linear_to_mapped(normalized_volume: f64, db_range: f64) -> f64 {\n        let min_norm = Self::min_norm(db_range);\n        f64::powi(normalized_volume * (1.0 - min_norm) + min_norm, 3)\n    }\n\n    fn mapped_to_linear(mapped_volume: f64, db_range: f64) -> f64 {\n        let min_norm = Self::min_norm(db_range);\n        (mapped_volume.powf(1.0 / 3.0) - min_norm) / (1.0 - min_norm)\n    }\n}\n\nimpl CubicMapping {\n    fn min_norm(db_range: f64) -> f64 {\n        // Note that this 60.0 is unrelated to DEFAULT_DB_RANGE.\n        // Instead, it's the cubic voltage to dB ratio.\n        f64::powf(10.0, -db_range / 60.0)\n    }\n}\n"
  },
  {
    "path": "playback/src/mixer/mod.rs",
    "content": "use crate::config::VolumeCtrl;\nuse librespot_core::Error;\nuse std::sync::Arc;\n\npub mod mappings;\nuse self::mappings::MappedCtrl;\n\npub struct NoOpVolume;\n\npub trait Mixer: Send + Sync {\n    fn open(config: MixerConfig) -> Result<Self, Error>\n    where\n        Self: Sized;\n\n    fn volume(&self) -> u16;\n    fn set_volume(&self, volume: u16);\n\n    fn get_soft_volume(&self) -> Box<dyn VolumeGetter + Send> {\n        Box::new(NoOpVolume)\n    }\n}\n\npub trait VolumeGetter {\n    fn attenuation_factor(&self) -> f64;\n}\n\nimpl VolumeGetter for NoOpVolume {\n    #[inline]\n    fn attenuation_factor(&self) -> f64 {\n        1.0\n    }\n}\n\npub mod softmixer;\nuse self::softmixer::SoftMixer;\n\n#[cfg(feature = \"alsa-backend\")]\npub mod alsamixer;\n#[cfg(feature = \"alsa-backend\")]\nuse self::alsamixer::AlsaMixer;\n\n#[derive(Debug, Clone)]\npub struct MixerConfig {\n    pub device: String,\n    pub control: String,\n    pub index: u32,\n    pub volume_ctrl: VolumeCtrl,\n}\n\nimpl Default for MixerConfig {\n    fn default() -> MixerConfig {\n        MixerConfig {\n            device: String::from(\"default\"),\n            control: String::from(\"PCM\"),\n            index: 0,\n            volume_ctrl: VolumeCtrl::default(),\n        }\n    }\n}\n\npub type MixerFn = fn(MixerConfig) -> Result<Arc<dyn Mixer>, Error>;\n\nfn mk_sink<M: Mixer + 'static>(config: MixerConfig) -> Result<Arc<dyn Mixer>, Error> {\n    Ok(Arc::new(M::open(config)?))\n}\n\npub const MIXERS: &[(&str, MixerFn)] = &[\n    (SoftMixer::NAME, mk_sink::<SoftMixer>), // default goes first\n    #[cfg(feature = \"alsa-backend\")]\n    (AlsaMixer::NAME, mk_sink::<AlsaMixer>),\n];\n\npub fn find(name: Option<&str>) -> Option<MixerFn> {\n    if let Some(name) = name {\n        MIXERS\n            .iter()\n            .find(|mixer| name == mixer.0)\n            .map(|mixer| mixer.1)\n    } else {\n        MIXERS.first().map(|mixer| mixer.1)\n    }\n}\n"
  },
  {
    "path": "playback/src/mixer/softmixer.rs",
    "content": "use super::VolumeGetter;\nuse super::{MappedCtrl, VolumeCtrl};\nuse super::{Mixer, MixerConfig};\nuse librespot_core::Error;\nuse portable_atomic::AtomicU64;\nuse std::sync::Arc;\nuse std::sync::atomic::Ordering;\n\n#[derive(Clone)]\npub struct SoftMixer {\n    // There is no AtomicF64, so we store the f64 as bits in a u64 field.\n    // It's much faster than a Mutex<f64>.\n    volume: Arc<AtomicU64>,\n    volume_ctrl: VolumeCtrl,\n}\n\nimpl Mixer for SoftMixer {\n    fn open(config: MixerConfig) -> Result<Self, Error> {\n        let volume_ctrl = config.volume_ctrl;\n        info!(\"Mixing with softvol and volume control: {volume_ctrl:?}\");\n\n        Ok(Self {\n            volume: Arc::new(AtomicU64::new(f64::to_bits(0.5))),\n            volume_ctrl,\n        })\n    }\n\n    fn volume(&self) -> u16 {\n        let mapped_volume = f64::from_bits(self.volume.load(Ordering::Relaxed));\n        self.volume_ctrl.as_unmapped(mapped_volume)\n    }\n\n    fn set_volume(&self, volume: u16) {\n        let mapped_volume = self.volume_ctrl.to_mapped(volume);\n        self.volume\n            .store(mapped_volume.to_bits(), Ordering::Relaxed)\n    }\n\n    fn get_soft_volume(&self) -> Box<dyn VolumeGetter + Send> {\n        Box::new(SoftVolume(self.volume.clone()))\n    }\n}\n\nimpl SoftMixer {\n    pub const NAME: &'static str = \"softvol\";\n}\n\nstruct SoftVolume(Arc<AtomicU64>);\n\nimpl VolumeGetter for SoftVolume {\n    #[inline]\n    fn attenuation_factor(&self) -> f64 {\n        f64::from_bits(self.0.load(Ordering::Relaxed))\n    }\n}\n"
  },
  {
    "path": "playback/src/player.rs",
    "content": "use std::{\n    collections::HashMap,\n    fmt, fs,\n    fs::File,\n    future::Future,\n    io::{self, Read, Seek, SeekFrom},\n    mem,\n    pin::Pin,\n    process::exit,\n    sync::Mutex,\n    sync::{\n        Arc,\n        atomic::{AtomicUsize, Ordering},\n    },\n    task::{Context, Poll},\n    thread,\n    time::{Duration, Instant},\n};\n\n#[cfg(feature = \"passthrough-decoder\")]\nuse crate::decoder::PassthroughDecoder;\nuse crate::{\n    audio::{AudioDecrypt, AudioFetchParams, AudioFile, StreamLoaderController},\n    audio_backend::Sink,\n    config::{Bitrate, NormalisationMethod, NormalisationType, PlayerConfig},\n    convert::Converter,\n    core::{Error, Session, SpotifyId, SpotifyUri, util::SeqGenerator},\n    decoder::{AudioDecoder, AudioPacket, AudioPacketPosition, SymphoniaDecoder},\n    local_file::{LocalFileLookup, create_local_file_lookup},\n    metadata::audio::{AudioFileFormat, AudioFiles, AudioItem},\n    mixer::VolumeGetter,\n};\nuse futures_util::{\n    StreamExt, TryFutureExt, future, future::FusedFuture,\n    stream::futures_unordered::FuturesUnordered,\n};\nuse librespot_metadata::{audio::UniqueFields, track::Tracks};\n\nuse symphonia::core::io::MediaSource;\nuse symphonia::core::probe::Hint;\nuse tokio::sync::{mpsc, oneshot};\n\nuse crate::SAMPLES_PER_SECOND;\n\nconst PRELOAD_NEXT_TRACK_BEFORE_END_DURATION_MS: u32 = 30000;\npub const DB_VOLTAGE_RATIO: f64 = 20.0;\npub const PCM_AT_0DBFS: f64 = 1.0;\n\n// Spotify inserts a custom Ogg packet at the start with custom metadata values, that you would\n// otherwise expect in Vorbis comments. This packet isn't well-formed and players may balk at it.\nconst SPOTIFY_OGG_HEADER_END: u64 = 0xa7;\n\nconst LOAD_HANDLES_POISON_MSG: &str = \"load handles mutex should not be poisoned\";\n\npub type PlayerResult = Result<(), Error>;\n\npub struct Player {\n    commands: Option<mpsc::UnboundedSender<PlayerCommand>>,\n    thread_handle: Option<thread::JoinHandle<()>>,\n}\n\n#[derive(PartialEq, Eq, Debug, Clone, Copy)]\npub enum SinkStatus {\n    Running,\n    Closed,\n    TemporarilyClosed,\n}\n\npub type SinkEventCallback = Box<dyn Fn(SinkStatus) + Send>;\n\nstruct PlayerInternal {\n    session: Session,\n    config: PlayerConfig,\n    commands: mpsc::UnboundedReceiver<PlayerCommand>,\n    load_handles: Arc<Mutex<HashMap<thread::ThreadId, thread::JoinHandle<()>>>>,\n\n    state: PlayerState,\n    preload: PlayerPreload,\n    sink: Box<dyn Sink>,\n    sink_status: SinkStatus,\n    sink_event_callback: Option<SinkEventCallback>,\n    volume_getter: Box<dyn VolumeGetter + Send>,\n    event_senders: Vec<mpsc::UnboundedSender<PlayerEvent>>,\n    converter: Converter,\n\n    normalisation_integrators: [f64; 2],\n    normalisation_peaks: [f64; 2],\n    normalisation_channel: usize,\n    normalisation_knee_factor: f64,\n\n    auto_normalise_as_album: bool,\n\n    player_id: usize,\n    play_request_id_generator: SeqGenerator<u64>,\n    last_progress_update: Instant,\n\n    local_file_lookup: Arc<LocalFileLookup>,\n}\n\nstatic PLAYER_COUNTER: AtomicUsize = AtomicUsize::new(0);\n\nenum PlayerCommand {\n    Load {\n        track_id: SpotifyUri,\n        play: bool,\n        position_ms: u32,\n    },\n    Preload {\n        track_id: SpotifyUri,\n    },\n    Play,\n    Pause,\n    Stop,\n    Seek(u32),\n    SetSession(Session),\n    AddEventSender(mpsc::UnboundedSender<PlayerEvent>),\n    SetSinkEventCallback(Option<SinkEventCallback>),\n    EmitVolumeChangedEvent(u16),\n    SetAutoNormaliseAsAlbum(bool),\n    EmitSessionDisconnectedEvent {\n        connection_id: String,\n        user_name: String,\n    },\n    EmitSessionConnectedEvent {\n        connection_id: String,\n        user_name: String,\n    },\n    EmitSessionClientChangedEvent {\n        client_id: String,\n        client_name: String,\n        client_brand_name: String,\n        client_model_name: String,\n    },\n    EmitFilterExplicitContentChangedEvent(bool),\n    EmitShuffleChangedEvent(bool),\n    EmitRepeatChangedEvent {\n        context: bool,\n        track: bool,\n    },\n    EmitAutoPlayChangedEvent(bool),\n    EmitSetQueueEvent {\n        context_uri: String,\n        current_track: Option<QueueTrack>,\n        next_tracks: Vec<QueueTrack>,\n        prev_tracks: Vec<QueueTrack>,\n    },\n}\n\n/// Represents a track in the queue with its URI and provider.\n#[derive(Clone, Debug, PartialEq, Eq)]\npub struct QueueTrack {\n    pub uri: String,\n    pub provider: String,\n}\n\n#[derive(Debug, Clone)]\npub enum PlayerEvent {\n    // Play request id changed\n    PlayRequestIdChanged {\n        play_request_id: u64,\n    },\n    // Fired when the player is stopped (e.g. by issuing a \"stop\" command to the player).\n    Stopped {\n        play_request_id: u64,\n        track_id: SpotifyUri,\n    },\n    // The player is delayed by loading a track.\n    Loading {\n        play_request_id: u64,\n        track_id: SpotifyUri,\n        position_ms: u32,\n    },\n    // The player is preloading a track.\n    Preloading {\n        track_id: SpotifyUri,\n    },\n    // The player is playing a track.\n    // This event is issued at the start of playback of whenever the position must be communicated\n    // because it is out of sync. This includes:\n    // start of a track\n    // un-pausing\n    // after a seek\n    // after a buffer-underrun\n    Playing {\n        play_request_id: u64,\n        track_id: SpotifyUri,\n        position_ms: u32,\n    },\n    // The player entered a paused state.\n    Paused {\n        play_request_id: u64,\n        track_id: SpotifyUri,\n        position_ms: u32,\n    },\n    // The player thinks it's a good idea to issue a preload command for the next track now.\n    // This event is intended for use within spirc.\n    TimeToPreloadNextTrack {\n        play_request_id: u64,\n        track_id: SpotifyUri,\n    },\n    // The player reached the end of a track.\n    // This event is intended for use within spirc. Spirc will respond by issuing another command.\n    EndOfTrack {\n        play_request_id: u64,\n        track_id: SpotifyUri,\n    },\n    // The player was unable to load the requested track.\n    Unavailable {\n        play_request_id: u64,\n        track_id: SpotifyUri,\n    },\n    // The mixer volume was set to a new level.\n    VolumeChanged {\n        volume: u16,\n    },\n    PositionCorrection {\n        play_request_id: u64,\n        track_id: SpotifyUri,\n        position_ms: u32,\n    },\n    /// Requires `PlayerConfig::position_update_interval` to be set to Some.\n    /// Once set this event will be sent periodically while playing the track to inform about the\n    /// current playback position\n    PositionChanged {\n        play_request_id: u64,\n        track_id: SpotifyUri,\n        position_ms: u32,\n    },\n    Seeked {\n        play_request_id: u64,\n        track_id: SpotifyUri,\n        position_ms: u32,\n    },\n    TrackChanged {\n        audio_item: Box<AudioItem>,\n    },\n    SessionConnected {\n        connection_id: String,\n        user_name: String,\n    },\n    SessionDisconnected {\n        connection_id: String,\n        user_name: String,\n    },\n    SessionClientChanged {\n        client_id: String,\n        client_name: String,\n        client_brand_name: String,\n        client_model_name: String,\n    },\n    ShuffleChanged {\n        shuffle: bool,\n    },\n    RepeatChanged {\n        context: bool,\n        track: bool,\n    },\n    AutoPlayChanged {\n        auto_play: bool,\n    },\n    FilterExplicitContentChanged {\n        filter: bool,\n    },\n    /// Fired when the queue is set or context is loaded with its track list.\n    SetQueue {\n        context_uri: String,\n        current_track: Option<QueueTrack>,\n        next_tracks: Vec<QueueTrack>,\n        prev_tracks: Vec<QueueTrack>,\n    },\n}\n\nimpl PlayerEvent {\n    pub fn get_play_request_id(&self) -> Option<u64> {\n        use PlayerEvent::*;\n        match self {\n            Loading {\n                play_request_id, ..\n            }\n            | Unavailable {\n                play_request_id, ..\n            }\n            | Playing {\n                play_request_id, ..\n            }\n            | TimeToPreloadNextTrack {\n                play_request_id, ..\n            }\n            | EndOfTrack {\n                play_request_id, ..\n            }\n            | Paused {\n                play_request_id, ..\n            }\n            | Stopped {\n                play_request_id, ..\n            }\n            | PositionCorrection {\n                play_request_id, ..\n            }\n            | Seeked {\n                play_request_id, ..\n            } => Some(*play_request_id),\n            _ => None,\n        }\n    }\n}\n\npub type PlayerEventChannel = mpsc::UnboundedReceiver<PlayerEvent>;\n\n#[inline]\npub fn db_to_ratio(db: f64) -> f64 {\n    f64::powf(10.0, db / DB_VOLTAGE_RATIO)\n}\n\n#[inline]\npub fn ratio_to_db(ratio: f64) -> f64 {\n    ratio.log10() * DB_VOLTAGE_RATIO\n}\n\npub fn duration_to_coefficient(duration: Duration) -> f64 {\n    f64::exp(-1.0 / (duration.as_secs_f64() * SAMPLES_PER_SECOND as f64))\n}\n\npub fn coefficient_to_duration(coefficient: f64) -> Duration {\n    Duration::from_secs_f64(-1.0 / f64::ln(coefficient) / SAMPLES_PER_SECOND as f64)\n}\n\n#[derive(Clone, Copy, Debug)]\npub struct NormalisationData {\n    // Spotify provides these as `f32`, but audio metadata can contain up to `f64`.\n    // Also, this negates the need for casting during sample processing.\n    pub track_gain_db: f64,\n    pub track_peak: f64,\n    pub album_gain_db: f64,\n    pub album_peak: f64,\n}\n\nimpl Default for NormalisationData {\n    fn default() -> Self {\n        Self {\n            track_gain_db: 0.0,\n            track_peak: 1.0,\n            album_gain_db: 0.0,\n            album_peak: 1.0,\n        }\n    }\n}\n\nimpl NormalisationData {\n    fn parse_from_ogg<T: Read + Seek>(mut file: T) -> io::Result<NormalisationData> {\n        const SPOTIFY_NORMALIZATION_HEADER_START_OFFSET: u64 = 144;\n        const NORMALISATION_DATA_SIZE: usize = 16;\n\n        let newpos = file.seek(SeekFrom::Start(SPOTIFY_NORMALIZATION_HEADER_START_OFFSET))?;\n        if newpos != SPOTIFY_NORMALIZATION_HEADER_START_OFFSET {\n            error!(\n                \"NormalisationData::parse_from_file seeking to {SPOTIFY_NORMALIZATION_HEADER_START_OFFSET} but position is now {newpos}\"\n            );\n\n            error!(\"Falling back to default (non-track and non-album) normalisation data.\");\n\n            return Ok(NormalisationData::default());\n        }\n\n        let mut buf = [0u8; NORMALISATION_DATA_SIZE];\n\n        file.read_exact(&mut buf)?;\n\n        let track_gain_db = f32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]) as f64;\n        let track_peak = f32::from_le_bytes([buf[4], buf[5], buf[6], buf[7]]) as f64;\n        let album_gain_db = f32::from_le_bytes([buf[8], buf[9], buf[10], buf[11]]) as f64;\n        let album_peak = f32::from_le_bytes([buf[12], buf[13], buf[14], buf[15]]) as f64;\n\n        Ok(Self {\n            track_gain_db,\n            track_peak,\n            album_gain_db,\n            album_peak,\n        })\n    }\n\n    fn get_factor(config: &PlayerConfig, data: NormalisationData) -> f64 {\n        if !config.normalisation {\n            return 1.0;\n        }\n\n        let (gain_db, gain_peak) = if config.normalisation_type == NormalisationType::Album {\n            (data.album_gain_db, data.album_peak)\n        } else {\n            (data.track_gain_db, data.track_peak)\n        };\n\n        // As per the ReplayGain 1.0 & 2.0 (proposed) spec:\n        // https://wiki.hydrogenaud.io/index.php?title=ReplayGain_1.0_specification#Clipping_prevention\n        // https://wiki.hydrogenaud.io/index.php?title=ReplayGain_2.0_specification#Clipping_prevention\n        let normalisation_factor = if config.normalisation_method == NormalisationMethod::Basic {\n            // For Basic Normalisation, factor = min(ratio of (ReplayGain + PreGain), 1.0 / peak level).\n            // https://wiki.hydrogenaud.io/index.php?title=ReplayGain_1.0_specification#Peak_amplitude\n            // https://wiki.hydrogenaud.io/index.php?title=ReplayGain_2.0_specification#Peak_amplitude\n            // We then limit that to 1.0 as not to exceed dBFS (0.0 dB).\n            let factor = f64::min(\n                db_to_ratio(gain_db + config.normalisation_pregain_db),\n                PCM_AT_0DBFS / gain_peak,\n            );\n\n            if factor > PCM_AT_0DBFS {\n                info!(\n                    \"Lowering gain by {:.2} dB for the duration of this track to avoid potentially exceeding dBFS.\",\n                    ratio_to_db(factor)\n                );\n\n                PCM_AT_0DBFS\n            } else {\n                factor\n            }\n        } else {\n            // For Dynamic Normalisation it's up to the player to decide,\n            // factor = ratio of (ReplayGain + PreGain).\n            // We then let the dynamic limiter handle gain reduction.\n            let factor = db_to_ratio(gain_db + config.normalisation_pregain_db);\n            let threshold_ratio = db_to_ratio(config.normalisation_threshold_dbfs);\n\n            if factor > PCM_AT_0DBFS {\n                let factor_db = gain_db + config.normalisation_pregain_db;\n                let limiting_db = factor_db + config.normalisation_threshold_dbfs.abs();\n\n                warn!(\n                    \"This track may exceed dBFS by {factor_db:.2} dB and be subject to {limiting_db:.2} dB of dynamic limiting at its peak.\"\n                );\n            } else if factor > threshold_ratio {\n                let limiting_db = gain_db\n                    + config.normalisation_pregain_db\n                    + config.normalisation_threshold_dbfs.abs();\n\n                info!(\n                    \"This track may be subject to {limiting_db:.2} dB of dynamic limiting at its peak.\"\n                );\n            }\n\n            factor\n        };\n\n        debug!(\"Normalisation Data: {data:?}\");\n        debug!(\n            \"Calculated Normalisation Factor for {:?}: {:.2}%\",\n            config.normalisation_type,\n            normalisation_factor * 100.0\n        );\n\n        normalisation_factor\n    }\n}\n\nimpl Player {\n    pub fn new<F>(\n        config: PlayerConfig,\n        session: Session,\n        volume_getter: Box<dyn VolumeGetter + Send>,\n        sink_builder: F,\n    ) -> Arc<Self>\n    where\n        F: FnOnce() -> Box<dyn Sink> + Send + 'static,\n    {\n        let (cmd_tx, cmd_rx) = mpsc::unbounded_channel();\n\n        if config.normalisation {\n            debug!(\"Normalisation Type: {:?}\", config.normalisation_type);\n            debug!(\n                \"Normalisation Pregain: {:.1} dB\",\n                config.normalisation_pregain_db\n            );\n            debug!(\n                \"Normalisation Threshold: {:.1} dBFS\",\n                config.normalisation_threshold_dbfs\n            );\n            debug!(\"Normalisation Method: {:?}\", config.normalisation_method);\n\n            if config.normalisation_method == NormalisationMethod::Dynamic {\n                // as_millis() has rounding errors (truncates)\n                debug!(\n                    \"Normalisation Attack: {:.0} ms\",\n                    coefficient_to_duration(config.normalisation_attack_cf).as_secs_f64() * 1000.\n                );\n                debug!(\n                    \"Normalisation Release: {:.0} ms\",\n                    coefficient_to_duration(config.normalisation_release_cf).as_secs_f64() * 1000.\n                );\n                debug!(\"Normalisation Knee: {} dB\", config.normalisation_knee_db);\n            }\n        }\n\n        let handle = thread::spawn(move || {\n            let player_id = PLAYER_COUNTER.fetch_add(1, Ordering::AcqRel);\n            debug!(\"new Player [{player_id}]\");\n\n            let converter = Converter::new(config.ditherer);\n            let normalisation_knee_factor = 1.0 / (8.0 * config.normalisation_knee_db);\n\n            // TODO: it would be neat if we could watch for added or modified files in the\n            // specified directories, and dynamically update the lookup. Currently, a new player\n            // must be created for any new local files to be playable.\n            let local_file_lookup =\n                create_local_file_lookup(config.local_file_directories.as_slice());\n\n            let internal = PlayerInternal {\n                session,\n                config,\n                commands: cmd_rx,\n                load_handles: Arc::new(Mutex::new(HashMap::new())),\n\n                state: PlayerState::Stopped,\n                preload: PlayerPreload::None,\n                sink: sink_builder(),\n                sink_status: SinkStatus::Closed,\n                sink_event_callback: None,\n                volume_getter,\n                event_senders: vec![],\n                converter,\n\n                normalisation_peaks: [0.0; 2],\n                normalisation_integrators: [0.0; 2],\n                normalisation_channel: 0,\n                normalisation_knee_factor,\n\n                auto_normalise_as_album: false,\n\n                player_id,\n                play_request_id_generator: SeqGenerator::new(0),\n                last_progress_update: Instant::now(),\n\n                local_file_lookup: Arc::new(local_file_lookup),\n            };\n\n            // While PlayerInternal is written as a future, it still contains blocking code.\n            // It must be run by using block_on() in a dedicated thread.\n            let runtime = tokio::runtime::Runtime::new().expect(\"Failed to create Tokio runtime\");\n            runtime.block_on(internal);\n\n            debug!(\"PlayerInternal thread finished.\");\n        });\n\n        Arc::new(Self {\n            commands: Some(cmd_tx),\n            thread_handle: Some(handle),\n        })\n    }\n\n    pub fn is_invalid(&self) -> bool {\n        if let Some(handle) = self.thread_handle.as_ref() {\n            return handle.is_finished();\n        }\n        true\n    }\n\n    fn command(&self, cmd: PlayerCommand) {\n        if let Some(commands) = self.commands.as_ref() {\n            if let Err(e) = commands.send(cmd) {\n                error!(\"Player Commands Error: {e}\");\n            }\n        }\n    }\n\n    pub fn load(&self, track_id: SpotifyUri, start_playing: bool, position_ms: u32) {\n        self.command(PlayerCommand::Load {\n            track_id,\n            play: start_playing,\n            position_ms,\n        });\n    }\n\n    pub fn preload(&self, track_id: SpotifyUri) {\n        self.command(PlayerCommand::Preload { track_id });\n    }\n\n    pub fn play(&self) {\n        self.command(PlayerCommand::Play)\n    }\n\n    pub fn pause(&self) {\n        self.command(PlayerCommand::Pause)\n    }\n\n    pub fn stop(&self) {\n        self.command(PlayerCommand::Stop)\n    }\n\n    pub fn seek(&self, position_ms: u32) {\n        self.command(PlayerCommand::Seek(position_ms));\n    }\n\n    pub fn set_session(&self, session: Session) {\n        self.command(PlayerCommand::SetSession(session));\n    }\n\n    pub fn get_player_event_channel(&self) -> PlayerEventChannel {\n        let (event_sender, event_receiver) = mpsc::unbounded_channel();\n        self.command(PlayerCommand::AddEventSender(event_sender));\n        event_receiver\n    }\n\n    pub async fn await_end_of_track(&self) {\n        let mut channel = self.get_player_event_channel();\n        while let Some(event) = channel.recv().await {\n            if matches!(\n                event,\n                PlayerEvent::EndOfTrack { .. } | PlayerEvent::Stopped { .. }\n            ) {\n                return;\n            }\n        }\n    }\n\n    pub fn set_sink_event_callback(&self, callback: Option<SinkEventCallback>) {\n        self.command(PlayerCommand::SetSinkEventCallback(callback));\n    }\n\n    pub fn emit_volume_changed_event(&self, volume: u16) {\n        self.command(PlayerCommand::EmitVolumeChangedEvent(volume));\n    }\n\n    pub fn set_auto_normalise_as_album(&self, setting: bool) {\n        self.command(PlayerCommand::SetAutoNormaliseAsAlbum(setting));\n    }\n\n    pub fn emit_filter_explicit_content_changed_event(&self, filter: bool) {\n        self.command(PlayerCommand::EmitFilterExplicitContentChangedEvent(filter));\n    }\n\n    pub fn emit_session_connected_event(&self, connection_id: String, user_name: String) {\n        self.command(PlayerCommand::EmitSessionConnectedEvent {\n            connection_id,\n            user_name,\n        });\n    }\n\n    pub fn emit_session_disconnected_event(&self, connection_id: String, user_name: String) {\n        self.command(PlayerCommand::EmitSessionDisconnectedEvent {\n            connection_id,\n            user_name,\n        });\n    }\n\n    pub fn emit_session_client_changed_event(\n        &self,\n        client_id: String,\n        client_name: String,\n        client_brand_name: String,\n        client_model_name: String,\n    ) {\n        self.command(PlayerCommand::EmitSessionClientChangedEvent {\n            client_id,\n            client_name,\n            client_brand_name,\n            client_model_name,\n        });\n    }\n\n    pub fn emit_shuffle_changed_event(&self, shuffle: bool) {\n        self.command(PlayerCommand::EmitShuffleChangedEvent(shuffle));\n    }\n\n    pub fn emit_repeat_changed_event(&self, context: bool, track: bool) {\n        self.command(PlayerCommand::EmitRepeatChangedEvent { context, track });\n    }\n\n    pub fn emit_auto_play_changed_event(&self, auto_play: bool) {\n        self.command(PlayerCommand::EmitAutoPlayChangedEvent(auto_play));\n    }\n\n    pub fn emit_set_queue_event(\n        &self,\n        context_uri: String,\n        current_track: Option<QueueTrack>,\n        next_tracks: Vec<QueueTrack>,\n        prev_tracks: Vec<QueueTrack>,\n    ) {\n        self.command(PlayerCommand::EmitSetQueueEvent {\n            context_uri,\n            current_track,\n            next_tracks,\n            prev_tracks,\n        });\n    }\n}\n\nimpl Drop for Player {\n    fn drop(&mut self) {\n        debug!(\"Shutting down player thread ...\");\n        self.commands = None;\n        if let Some(handle) = self.thread_handle.take() {\n            if let Err(e) = handle.join() {\n                error!(\"Player thread Error: {e:?}\");\n            }\n        }\n    }\n}\n\nstruct PlayerLoadedTrackData {\n    decoder: Decoder,\n    normalisation_data: NormalisationData,\n    stream_loader_controller: StreamLoaderController,\n    audio_item: AudioItem,\n    bytes_per_second: usize,\n    duration_ms: u32,\n    stream_position_ms: u32,\n    is_explicit: bool,\n}\n\nenum PlayerPreload {\n    None,\n    Loading {\n        track_id: SpotifyUri,\n        loader: Pin<Box<dyn FusedFuture<Output = Result<PlayerLoadedTrackData, ()>> + Send>>,\n    },\n    Ready {\n        track_id: SpotifyUri,\n        loaded_track: Box<PlayerLoadedTrackData>,\n    },\n}\n\ntype Decoder = Box<dyn AudioDecoder + Send>;\n\nenum PlayerState {\n    Stopped,\n    Loading {\n        track_id: SpotifyUri,\n        play_request_id: u64,\n        start_playback: bool,\n        loader: Pin<Box<dyn FusedFuture<Output = Result<PlayerLoadedTrackData, ()>> + Send>>,\n    },\n    Paused {\n        track_id: SpotifyUri,\n        play_request_id: u64,\n        decoder: Decoder,\n        audio_item: AudioItem,\n        normalisation_data: NormalisationData,\n        normalisation_factor: f64,\n        stream_loader_controller: StreamLoaderController,\n        bytes_per_second: usize,\n        duration_ms: u32,\n        stream_position_ms: u32,\n        suggested_to_preload_next_track: bool,\n        is_explicit: bool,\n    },\n    Playing {\n        track_id: SpotifyUri,\n        play_request_id: u64,\n        decoder: Decoder,\n        normalisation_data: NormalisationData,\n        audio_item: AudioItem,\n        normalisation_factor: f64,\n        stream_loader_controller: StreamLoaderController,\n        bytes_per_second: usize,\n        duration_ms: u32,\n        stream_position_ms: u32,\n        reported_nominal_start_time: Option<Instant>,\n        suggested_to_preload_next_track: bool,\n        is_explicit: bool,\n    },\n    EndOfTrack {\n        track_id: SpotifyUri,\n        play_request_id: u64,\n        loaded_track: PlayerLoadedTrackData,\n    },\n    Invalid,\n}\n\nimpl PlayerState {\n    fn is_playing(&self) -> bool {\n        use self::PlayerState::*;\n        match *self {\n            Stopped | EndOfTrack { .. } | Paused { .. } | Loading { .. } => false,\n            Playing { .. } => true,\n            Invalid => {\n                error!(\"PlayerState::is_playing in invalid state\");\n                exit(1);\n            }\n        }\n    }\n\n    #[allow(dead_code)]\n    fn is_stopped(&self) -> bool {\n        use self::PlayerState::*;\n        matches!(self, Stopped)\n    }\n\n    #[allow(dead_code)]\n    fn is_loading(&self) -> bool {\n        use self::PlayerState::*;\n        matches!(self, Loading { .. })\n    }\n\n    fn decoder(&mut self) -> Option<&mut Decoder> {\n        use self::PlayerState::*;\n        match *self {\n            Stopped | EndOfTrack { .. } | Loading { .. } => None,\n            Paused {\n                ref mut decoder, ..\n            }\n            | Playing {\n                ref mut decoder, ..\n            } => Some(decoder),\n            Invalid => {\n                error!(\"PlayerState::decoder in invalid state\");\n                exit(1);\n            }\n        }\n    }\n\n    fn playing_to_end_of_track(&mut self) {\n        use self::PlayerState::*;\n        let new_state = mem::replace(self, Invalid);\n        match new_state {\n            Playing {\n                track_id,\n                play_request_id,\n                decoder,\n                duration_ms,\n                bytes_per_second,\n                normalisation_data,\n                stream_loader_controller,\n                stream_position_ms,\n                is_explicit,\n                audio_item,\n                ..\n            } => {\n                *self = EndOfTrack {\n                    track_id,\n                    play_request_id,\n                    loaded_track: PlayerLoadedTrackData {\n                        decoder,\n                        normalisation_data,\n                        stream_loader_controller,\n                        audio_item,\n                        bytes_per_second,\n                        duration_ms,\n                        stream_position_ms,\n                        is_explicit,\n                    },\n                };\n            }\n            _ => {\n                error!(\"Called playing_to_end_of_track in non-playing state: {new_state:?}\");\n                exit(1);\n            }\n        }\n    }\n\n    fn paused_to_playing(&mut self) {\n        use self::PlayerState::*;\n        let new_state = mem::replace(self, Invalid);\n        match new_state {\n            Paused {\n                track_id,\n                play_request_id,\n                decoder,\n                audio_item,\n                normalisation_data,\n                normalisation_factor,\n                stream_loader_controller,\n                duration_ms,\n                bytes_per_second,\n                stream_position_ms,\n                suggested_to_preload_next_track,\n                is_explicit,\n            } => {\n                *self = Playing {\n                    track_id,\n                    play_request_id,\n                    decoder,\n                    audio_item,\n                    normalisation_data,\n                    normalisation_factor,\n                    stream_loader_controller,\n                    duration_ms,\n                    bytes_per_second,\n                    stream_position_ms,\n                    reported_nominal_start_time: Instant::now()\n                        .checked_sub(Duration::from_millis(stream_position_ms as u64)),\n                    suggested_to_preload_next_track,\n                    is_explicit,\n                };\n            }\n            _ => {\n                error!(\"PlayerState::paused_to_playing in invalid state: {new_state:?}\");\n                exit(1);\n            }\n        }\n    }\n\n    fn playing_to_paused(&mut self) {\n        use self::PlayerState::*;\n        let new_state = mem::replace(self, Invalid);\n        match new_state {\n            Playing {\n                track_id,\n                play_request_id,\n                decoder,\n                audio_item,\n                normalisation_data,\n                normalisation_factor,\n                stream_loader_controller,\n                duration_ms,\n                bytes_per_second,\n                stream_position_ms,\n                suggested_to_preload_next_track,\n                is_explicit,\n                ..\n            } => {\n                *self = Paused {\n                    track_id,\n                    play_request_id,\n                    decoder,\n                    audio_item,\n                    normalisation_data,\n                    normalisation_factor,\n                    stream_loader_controller,\n                    duration_ms,\n                    bytes_per_second,\n                    stream_position_ms,\n                    suggested_to_preload_next_track,\n                    is_explicit,\n                };\n            }\n            _ => {\n                error!(\"PlayerState::playing_to_paused in invalid state: {new_state:?}\");\n                exit(1);\n            }\n        }\n    }\n}\n\nstruct PlayerTrackLoader {\n    session: Session,\n    config: PlayerConfig,\n    local_file_lookup: Arc<LocalFileLookup>,\n}\n\nimpl PlayerTrackLoader {\n    async fn find_available_alternative(&self, audio_item: AudioItem) -> Option<AudioItem> {\n        if let Err(e) = audio_item.availability {\n            error!(\"Track is unavailable: {e}\");\n            None\n        } else if !audio_item.files.is_empty() {\n            Some(audio_item)\n        } else if let Some(alternatives) = audio_item.alternatives {\n            let Tracks(alternatives_vec) = alternatives; // required to make `into_iter` able to move\n\n            let alternatives: FuturesUnordered<_> = alternatives_vec\n                .into_iter()\n                .map(|alt_id| AudioItem::get_file(&self.session, alt_id))\n                .collect();\n\n            alternatives\n                .filter_map(|x| future::ready(x.ok()))\n                .filter(|x| future::ready(x.availability.is_ok()))\n                .next()\n                .await\n        } else {\n            error!(\"Track should be available, but no alternatives found.\");\n            None\n        }\n    }\n\n    fn stream_data_rate(&self, format: AudioFileFormat) -> Option<usize> {\n        let kbps = match format {\n            AudioFileFormat::OGG_VORBIS_96 => 12.,\n            AudioFileFormat::OGG_VORBIS_160 => 20.,\n            AudioFileFormat::OGG_VORBIS_320 => 40.,\n            AudioFileFormat::MP3_256 => 32.,\n            AudioFileFormat::MP3_320 => 40.,\n            AudioFileFormat::MP3_160 => 20.,\n            AudioFileFormat::MP3_96 => 12.,\n            AudioFileFormat::MP3_160_ENC => 20.,\n            AudioFileFormat::AAC_24 => 3.,\n            AudioFileFormat::AAC_48 => 6.,\n            AudioFileFormat::AAC_160 => 20.,\n            AudioFileFormat::AAC_320 => 40.,\n            AudioFileFormat::MP4_128 => 16.,\n            AudioFileFormat::OTHER5 => 40.,\n            AudioFileFormat::FLAC_FLAC => 112., // assume 900 kbit/s on average\n            AudioFileFormat::XHE_AAC_12 => 1.5,\n            AudioFileFormat::XHE_AAC_16 => 2.,\n            AudioFileFormat::XHE_AAC_24 => 3.,\n            AudioFileFormat::FLAC_FLAC_24BIT => 3.,\n        };\n        let data_rate: f32 = kbps * 1024.;\n        Some(data_rate.ceil() as usize)\n    }\n\n    async fn load_track(\n        &self,\n        track_uri: SpotifyUri,\n        position_ms: u32,\n    ) -> Option<PlayerLoadedTrackData> {\n        match track_uri {\n            SpotifyUri::Track { .. } | SpotifyUri::Episode { .. } => {\n                self.load_remote_track(track_uri, position_ms).await\n            }\n            SpotifyUri::Local { .. } => self.load_local_track(track_uri, position_ms).await,\n            _ => {\n                error!(\"Cannot handle load of track with URI: <{track_uri}>\",);\n                None\n            }\n        }\n    }\n\n    async fn load_remote_track(\n        &self,\n        track_uri: SpotifyUri,\n        position_ms: u32,\n    ) -> Option<PlayerLoadedTrackData> {\n        let track_id: SpotifyId = match (&track_uri).try_into() {\n            Ok(id) => id,\n            Err(_) => {\n                warn!(\"<{track_uri}> could not be converted to a base62 ID\");\n                return None;\n            }\n        };\n\n        let audio_item = match AudioItem::get_file(&self.session, track_uri).await {\n            Ok(audio) => match self.find_available_alternative(audio).await {\n                Some(audio) => audio,\n                None => {\n                    warn!(\"spotify:track:<{}> is not available\", track_id.to_base62());\n                    return None;\n                }\n            },\n            Err(e) => {\n                error!(\"Unable to load audio item: {e:?}\");\n                return None;\n            }\n        };\n\n        info!(\n            \"Loading <{}> with Spotify URI <{}>\",\n            audio_item.name, audio_item.uri\n        );\n\n        // (Most) podcasts seem to support only 96 kbps Ogg Vorbis, so fall back to it\n        let formats = match self.config.bitrate {\n            Bitrate::Bitrate96 => [\n                AudioFileFormat::OGG_VORBIS_96,\n                AudioFileFormat::MP3_96,\n                AudioFileFormat::OGG_VORBIS_160,\n                AudioFileFormat::MP3_160,\n                AudioFileFormat::MP3_256,\n                AudioFileFormat::OGG_VORBIS_320,\n                AudioFileFormat::MP3_320,\n            ],\n            Bitrate::Bitrate160 => [\n                AudioFileFormat::OGG_VORBIS_160,\n                AudioFileFormat::MP3_160,\n                AudioFileFormat::OGG_VORBIS_96,\n                AudioFileFormat::MP3_96,\n                AudioFileFormat::MP3_256,\n                AudioFileFormat::OGG_VORBIS_320,\n                AudioFileFormat::MP3_320,\n            ],\n            Bitrate::Bitrate320 => [\n                AudioFileFormat::OGG_VORBIS_320,\n                AudioFileFormat::MP3_320,\n                AudioFileFormat::MP3_256,\n                AudioFileFormat::OGG_VORBIS_160,\n                AudioFileFormat::MP3_160,\n                AudioFileFormat::OGG_VORBIS_96,\n                AudioFileFormat::MP3_96,\n            ],\n        };\n\n        let (format, file_id) =\n            match formats\n                .iter()\n                .find_map(|format| match audio_item.files.get(format) {\n                    Some(&file_id) => Some((*format, file_id)),\n                    _ => None,\n                }) {\n                Some(t) => t,\n                None => {\n                    warn!(\n                        \"<{}> is not available in any supported format\",\n                        audio_item.name\n                    );\n                    return None;\n                }\n            };\n\n        let bytes_per_second = self.stream_data_rate(format)?;\n\n        // This is only a loop to be able to reload the file if an error occurred\n        // while opening a cached file.\n        loop {\n            let encrypted_file = AudioFile::open(&self.session, file_id, bytes_per_second);\n\n            let encrypted_file = match encrypted_file.await {\n                Ok(encrypted_file) => encrypted_file,\n                Err(e) => {\n                    error!(\"Unable to load encrypted file: {e:?}\");\n                    return None;\n                }\n            };\n\n            let is_cached = encrypted_file.is_cached();\n\n            let stream_loader_controller = encrypted_file.get_stream_loader_controller().ok()?;\n\n            // Not all audio files are encrypted. If we can't get a key, try loading the track\n            // without decryption. If the file was encrypted after all, the decoder will fail\n            // parsing and bail out, so we should be safe from outputting ear-piercing noise.\n            let key = match self.session.audio_key().request(track_id, file_id).await {\n                Ok(key) => Some(key),\n                Err(e) => {\n                    warn!(\"Unable to load key, continuing without decryption: {e}\");\n                    None\n                }\n            };\n\n            let mut decrypted_file = AudioDecrypt::new(key, encrypted_file);\n\n            let is_ogg_vorbis = AudioFiles::is_ogg_vorbis(format);\n            let (offset, mut normalisation_data) = if is_ogg_vorbis {\n                // Spotify stores normalisation data in a custom Ogg packet instead of Vorbis comments.\n                let normalisation_data =\n                    NormalisationData::parse_from_ogg(&mut decrypted_file).ok();\n                (SPOTIFY_OGG_HEADER_END, normalisation_data)\n            } else {\n                (0, None)\n            };\n\n            let audio_file = match Subfile::new(\n                decrypted_file,\n                offset,\n                stream_loader_controller.len() as u64,\n            ) {\n                Ok(audio_file) => audio_file,\n                Err(e) => {\n                    error!(\"PlayerTrackLoader::load_track error opening subfile: {e}\");\n                    return None;\n                }\n            };\n\n            let mut symphonia_decoder = |audio_file, format| {\n                SymphoniaDecoder::new(audio_file, format).map(|mut decoder| {\n                    // For formats other that Vorbis, we'll try getting normalisation data from\n                    // ReplayGain metadata fields, if present.\n                    if normalisation_data.is_none() {\n                        normalisation_data = decoder.normalisation_data();\n                    }\n                    Box::new(decoder) as Decoder\n                })\n            };\n\n            let mut hint = Hint::new();\n            if let Some(mime_type) = AudioFiles::mime_type(format) {\n                hint.mime_type(mime_type);\n            }\n\n            #[cfg(feature = \"passthrough-decoder\")]\n            let decoder_type = if self.config.passthrough {\n                PassthroughDecoder::new(audio_file, format).map(|x| Box::new(x) as Decoder)\n            } else {\n                symphonia_decoder(audio_file, hint)\n            };\n\n            #[cfg(not(feature = \"passthrough-decoder\"))]\n            let decoder_type = { symphonia_decoder(audio_file, hint) };\n\n            let normalisation_data = normalisation_data.unwrap_or_else(|| {\n                warn!(\"Unable to get normalisation data, continuing with defaults.\");\n                NormalisationData::default()\n            });\n\n            let mut decoder = match decoder_type {\n                Ok(decoder) => decoder,\n                Err(e) if is_cached => {\n                    warn!(\"Unable to read cached audio file: {e}. Trying to download it.\");\n\n                    match self.session.cache() {\n                        Some(cache) => {\n                            if cache.remove_file(file_id).is_err() {\n                                error!(\"Error removing file from cache\");\n                                return None;\n                            }\n                        }\n                        None => {\n                            error!(\"If the audio file is cached, a cache should exist\");\n                            return None;\n                        }\n                    }\n\n                    // Just try it again\n                    continue;\n                }\n                Err(e) => {\n                    error!(\"Unable to read audio file: {e}\");\n                    return None;\n                }\n            };\n\n            let duration_ms = audio_item.duration_ms;\n            // Don't try to seek past the track's duration.\n            // If the position is invalid just start from\n            // the beginning of the track.\n            let position_ms = if position_ms > duration_ms {\n                warn!(\n                    \"Invalid start position of {position_ms} ms exceeds track's duration of {duration_ms} ms, starting track from the beginning\"\n                );\n                0\n            } else {\n                position_ms\n            };\n\n            // Ensure the starting position. Even when we want to play from the beginning,\n            // the cursor may have been moved by parsing normalisation data. This may not\n            // matter for playback (but won't hurt either), but may be useful for the\n            // passthrough decoder.\n            let stream_position_ms = match decoder.seek(position_ms) {\n                Ok(new_position_ms) => new_position_ms,\n                Err(e) => {\n                    error!(\n                        \"PlayerTrackLoader::load_track error seeking to starting position {position_ms}: {e}\"\n                    );\n                    return None;\n                }\n            };\n\n            // Ensure streaming mode now that we are ready to play from the requested position.\n            stream_loader_controller.set_stream_mode();\n\n            let is_explicit = audio_item.is_explicit;\n\n            info!(\"<{}> ({} ms) loaded\", audio_item.name, duration_ms);\n\n            return Some(PlayerLoadedTrackData {\n                decoder,\n                normalisation_data,\n                stream_loader_controller,\n                audio_item,\n                bytes_per_second,\n                duration_ms,\n                stream_position_ms,\n                is_explicit,\n            });\n        }\n    }\n\n    async fn load_local_track(\n        &self,\n        track_uri: SpotifyUri,\n        position_ms: u32,\n    ) -> Option<PlayerLoadedTrackData> {\n        info!(\"Loading local file with Spotify URI <{}>\", track_uri);\n\n        let SpotifyUri::Local { duration, .. } = track_uri else {\n            error!(\"Unable to determine track duration for local file: not a local file URI\");\n            return None;\n        };\n\n        let entry = self.local_file_lookup.get(&track_uri);\n\n        let Some(path) = entry else {\n            error!(\"Unable to find file path for local file <{track_uri}>\");\n            return None;\n        };\n\n        let src = match File::open(path) {\n            Ok(src) => src,\n            Err(e) => {\n                error!(\"Failed to open local file: {e}\");\n                return None;\n            }\n        };\n\n        let mut hint = Hint::new();\n        if let Some(file_extension) = path.extension().and_then(|e| e.to_str()) {\n            hint.with_extension(file_extension);\n        }\n\n        let decoder = match SymphoniaDecoder::new(src, hint) {\n            Ok(decoder) => decoder,\n            Err(e) => {\n                error!(\"Error decoding local file: {e}\");\n                return None;\n            }\n        };\n\n        let mut decoder = Box::new(decoder);\n        let normalisation_data = decoder.normalisation_data().unwrap_or_else(|| {\n            warn!(\"Unable to get normalisation data, continuing with defaults.\");\n            NormalisationData::default()\n        });\n\n        let local_file_metadata = decoder.local_file_metadata().unwrap_or_default();\n\n        let stream_position_ms = match decoder.seek(position_ms) {\n            Ok(new_position_ms) => new_position_ms,\n            Err(e) => {\n                error!(\n                    \"PlayerTrackLoader::load_local_track error seeking to starting position {position_ms}: {e}\"\n                );\n                return None;\n            }\n        };\n\n        let file_size = fs::metadata(path).ok()?.len();\n        let bytes_per_second = (file_size / duration.as_secs()) as usize;\n\n        let stream_loader_controller = StreamLoaderController::from_local_file(file_size);\n\n        let name = local_file_metadata.name.unwrap_or_default();\n\n        info!(\"Loaded <{name}> from path <{}>\", path.display());\n\n        Some(PlayerLoadedTrackData {\n            decoder,\n            normalisation_data,\n            stream_loader_controller,\n            bytes_per_second,\n            duration_ms: duration.as_millis() as u32,\n            stream_position_ms,\n            is_explicit: false,\n            audio_item: AudioItem {\n                duration_ms: duration.as_millis() as u32,\n                uri: track_uri.to_uri(),\n                track_id: track_uri,\n                files: Default::default(),\n                name,\n                // We can't get a CoverImage.URL for the track image, applications will have to parse the file metadata themselves using unique_fields.path\n                covers: vec![],\n                language: local_file_metadata\n                    .language\n                    .map(|val| vec![val])\n                    .unwrap_or_default(),\n                is_explicit: false,\n                availability: Ok(()),\n                alternatives: None,\n                unique_fields: UniqueFields::Local {\n                    artists: local_file_metadata.artists,\n                    album: local_file_metadata.album,\n                    album_artists: local_file_metadata.album_artists,\n                    number: local_file_metadata.number,\n                    disc_number: local_file_metadata.disc_number,\n                    path: path.to_path_buf(),\n                },\n            },\n        })\n    }\n}\n\nimpl Future for PlayerInternal {\n    type Output = ();\n\n    fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {\n        // While this is written as a future, it still contains blocking code.\n        // It must be run on its own thread.\n        let passthrough = self.config.passthrough;\n\n        loop {\n            let mut all_futures_completed_or_not_ready = true;\n\n            // process commands that were sent to us\n            let cmd = match self.commands.poll_recv(cx) {\n                Poll::Ready(None) => return Poll::Ready(()), // client has disconnected - shut down.\n                Poll::Ready(Some(cmd)) => {\n                    all_futures_completed_or_not_ready = false;\n                    Some(cmd)\n                }\n                _ => None,\n            };\n\n            if let Some(cmd) = cmd {\n                if let Err(e) = self.handle_command(cmd) {\n                    error!(\"Error handling command: {e}\");\n                }\n            }\n\n            // Handle loading of a new track to play\n            if let PlayerState::Loading {\n                ref mut loader,\n                ref track_id,\n                start_playback,\n                play_request_id,\n            } = self.state\n            {\n                // The loader may be terminated if we are trying to load the same track\n                // as before, and that track failed to open before.\n                let track_id = track_id.clone();\n\n                if !loader.as_mut().is_terminated() {\n                    match loader.as_mut().poll(cx) {\n                        Poll::Ready(Ok(loaded_track)) => {\n                            self.start_playback(\n                                track_id,\n                                play_request_id,\n                                loaded_track,\n                                start_playback,\n                            );\n                            if let PlayerState::Loading { .. } = self.state {\n                                error!(\"The state wasn't changed by start_playback()\");\n                                exit(1);\n                            }\n                        }\n                        Poll::Ready(Err(e)) => {\n                            error!(\n                                \"Skipping to next track, unable to load track <{track_id:?}>: {e:?}\"\n                            );\n                            self.send_event(PlayerEvent::Unavailable {\n                                track_id,\n                                play_request_id,\n                            })\n                        }\n                        Poll::Pending => (),\n                    }\n                }\n            }\n\n            // handle pending preload requests.\n            if let PlayerPreload::Loading {\n                ref mut loader,\n                ref track_id,\n            } = self.preload\n            {\n                let track_id = track_id.clone();\n                match loader.as_mut().poll(cx) {\n                    Poll::Ready(Ok(loaded_track)) => {\n                        self.send_event(PlayerEvent::Preloading {\n                            track_id: track_id.clone(),\n                        });\n                        self.preload = PlayerPreload::Ready {\n                            track_id,\n                            loaded_track: Box::new(loaded_track),\n                        };\n                    }\n                    Poll::Ready(Err(_)) => {\n                        debug!(\"Unable to preload {track_id:?}\");\n                        self.preload = PlayerPreload::None;\n                        // Let Spirc know that the track was unavailable.\n                        if let PlayerState::Playing {\n                            play_request_id, ..\n                        }\n                        | PlayerState::Paused {\n                            play_request_id, ..\n                        } = self.state\n                        {\n                            self.send_event(PlayerEvent::Unavailable {\n                                track_id,\n                                play_request_id,\n                            });\n                        }\n                    }\n                    Poll::Pending => (),\n                }\n            }\n\n            if self.state.is_playing() {\n                self.ensure_sink_running();\n\n                if let PlayerState::Playing {\n                    ref track_id,\n                    play_request_id,\n                    ref mut decoder,\n                    normalisation_factor,\n                    ref mut stream_position_ms,\n                    ref mut reported_nominal_start_time,\n                    ..\n                } = self.state\n                {\n                    let track_id = track_id.clone();\n                    match decoder.next_packet() {\n                        Ok(result) => {\n                            if let Some((ref packet_position, ref packet)) = result {\n                                let new_stream_position_ms = packet_position.position_ms;\n                                let expected_position_ms = std::mem::replace(\n                                    &mut *stream_position_ms,\n                                    new_stream_position_ms,\n                                );\n\n                                if !passthrough {\n                                    match packet.samples() {\n                                        Ok(_) => {\n                                            let new_stream_position = Duration::from_millis(\n                                                new_stream_position_ms as u64,\n                                            );\n\n                                            let now = Instant::now();\n\n                                            // Only notify if we're skipped some packets *or* we are behind.\n                                            // If we're ahead it's probably due to a buffer of the backend\n                                            // and we're actually in time.\n                                            let notify_about_position =\n                                                match *reported_nominal_start_time {\n                                                    None => true,\n                                                    Some(reported_nominal_start_time) => {\n                                                        let mut notify = false;\n\n                                                        if packet_position.skipped {\n                                                            if let Some(ahead) = new_stream_position\n                                                                .checked_sub(Duration::from_millis(\n                                                                    expected_position_ms as u64,\n                                                                ))\n                                                            {\n                                                                notify |=\n                                                                    ahead >= Duration::from_secs(1)\n                                                            }\n                                                        }\n\n                                                        if let Some(lag) = now\n                                                            .checked_duration_since(\n                                                                reported_nominal_start_time,\n                                                            )\n                                                        {\n                                                            if let Some(lag) =\n                                                                lag.checked_sub(new_stream_position)\n                                                            {\n                                                                notify |=\n                                                                    lag >= Duration::from_secs(1)\n                                                            }\n                                                        }\n\n                                                        notify\n                                                    }\n                                                };\n\n                                            if notify_about_position {\n                                                *reported_nominal_start_time =\n                                                    now.checked_sub(new_stream_position);\n                                                self.send_event(PlayerEvent::PositionCorrection {\n                                                    play_request_id,\n                                                    track_id: track_id.clone(),\n                                                    position_ms: new_stream_position_ms,\n                                                });\n                                            }\n\n                                            if let Some(interval) =\n                                                self.config.position_update_interval\n                                            {\n                                                let last_progress_update_since_ms =\n                                                    now.duration_since(self.last_progress_update);\n\n                                                if last_progress_update_since_ms > interval {\n                                                    self.last_progress_update = now;\n                                                    self.send_event(PlayerEvent::PositionChanged {\n                                                        play_request_id,\n                                                        track_id,\n                                                        position_ms: new_stream_position_ms,\n                                                    });\n                                                }\n                                            }\n                                        }\n                                        Err(e) => {\n                                            error!(\n                                                \"Skipping to next track, unable to decode samples for track <{track_id:?}>: {e:?}\"\n                                            );\n                                            self.send_event(PlayerEvent::EndOfTrack {\n                                                track_id,\n                                                play_request_id,\n                                            })\n                                        }\n                                    }\n                                }\n                            }\n\n                            self.handle_packet(result, normalisation_factor);\n                        }\n                        Err(e) => {\n                            error!(\n                                \"Skipping to next track, unable to get next packet for track <{track_id:?}>: {e:?}\"\n                            );\n                            self.send_event(PlayerEvent::EndOfTrack {\n                                track_id,\n                                play_request_id,\n                            })\n                        }\n                    }\n                } else {\n                    error!(\"PlayerInternal poll: Invalid PlayerState\");\n                    exit(1);\n                };\n            }\n\n            if let PlayerState::Playing {\n                ref track_id,\n                play_request_id,\n                duration_ms,\n                stream_position_ms,\n                ref mut stream_loader_controller,\n                ref mut suggested_to_preload_next_track,\n                ..\n            }\n            | PlayerState::Paused {\n                ref track_id,\n                play_request_id,\n                duration_ms,\n                stream_position_ms,\n                ref mut stream_loader_controller,\n                ref mut suggested_to_preload_next_track,\n                ..\n            } = self.state\n            {\n                let track_id = track_id.clone();\n\n                if (!*suggested_to_preload_next_track)\n                    && ((duration_ms as i64 - stream_position_ms as i64)\n                        < PRELOAD_NEXT_TRACK_BEFORE_END_DURATION_MS as i64)\n                    && stream_loader_controller.range_to_end_available()\n                {\n                    *suggested_to_preload_next_track = true;\n                    self.send_event(PlayerEvent::TimeToPreloadNextTrack {\n                        track_id,\n                        play_request_id,\n                    });\n                }\n            }\n\n            if (!self.state.is_playing()) && all_futures_completed_or_not_ready {\n                return Poll::Pending;\n            }\n        }\n    }\n}\n\nimpl PlayerInternal {\n    fn ensure_sink_running(&mut self) {\n        if self.sink_status != SinkStatus::Running {\n            trace!(\"== Starting sink ==\");\n            if let Some(callback) = &mut self.sink_event_callback {\n                callback(SinkStatus::Running);\n            }\n            match self.sink.start() {\n                Ok(()) => self.sink_status = SinkStatus::Running,\n                Err(e) => {\n                    error!(\"{e}\");\n                    self.handle_pause();\n                }\n            }\n        }\n    }\n\n    fn ensure_sink_stopped(&mut self, temporarily: bool) {\n        match self.sink_status {\n            SinkStatus::Running => {\n                trace!(\"== Stopping sink ==\");\n                match self.sink.stop() {\n                    Ok(()) => {\n                        self.sink_status = if temporarily {\n                            SinkStatus::TemporarilyClosed\n                        } else {\n                            SinkStatus::Closed\n                        };\n                        if let Some(callback) = &mut self.sink_event_callback {\n                            callback(self.sink_status);\n                        }\n                    }\n                    Err(e) => {\n                        error!(\"{e}\");\n                        exit(1);\n                    }\n                }\n            }\n            SinkStatus::TemporarilyClosed => {\n                if !temporarily {\n                    self.sink_status = SinkStatus::Closed;\n                    if let Some(callback) = &mut self.sink_event_callback {\n                        callback(SinkStatus::Closed);\n                    }\n                }\n            }\n            SinkStatus::Closed => (),\n        }\n    }\n\n    fn handle_player_stop(&mut self) {\n        match self.state {\n            PlayerState::Playing {\n                ref track_id,\n                play_request_id,\n                ..\n            }\n            | PlayerState::Paused {\n                ref track_id,\n                play_request_id,\n                ..\n            }\n            | PlayerState::EndOfTrack {\n                ref track_id,\n                play_request_id,\n                ..\n            }\n            | PlayerState::Loading {\n                ref track_id,\n                play_request_id,\n                ..\n            } => {\n                let track_id = track_id.clone();\n\n                self.ensure_sink_stopped(false);\n                self.send_event(PlayerEvent::Stopped {\n                    track_id,\n                    play_request_id,\n                });\n                self.state = PlayerState::Stopped;\n            }\n            PlayerState::Stopped => (),\n            PlayerState::Invalid => {\n                error!(\"PlayerInternal::handle_player_stop in invalid state\");\n                exit(1);\n            }\n        }\n    }\n\n    fn handle_play(&mut self) {\n        match self.state {\n            PlayerState::Paused {\n                ref track_id,\n                play_request_id,\n                stream_position_ms,\n                ..\n            } => {\n                let track_id = track_id.clone();\n\n                self.state.paused_to_playing();\n                self.send_event(PlayerEvent::Playing {\n                    track_id,\n                    play_request_id,\n                    position_ms: stream_position_ms,\n                });\n                self.ensure_sink_running();\n            }\n            PlayerState::Loading {\n                ref mut start_playback,\n                ..\n            } => {\n                *start_playback = true;\n            }\n            _ => error!(\"Player::play called from invalid state: {:?}\", self.state),\n        }\n    }\n\n    fn handle_pause(&mut self) {\n        match self.state {\n            PlayerState::Paused { .. } => self.ensure_sink_stopped(false),\n            PlayerState::Playing {\n                ref track_id,\n                play_request_id,\n                stream_position_ms,\n                ..\n            } => {\n                let track_id = track_id.clone();\n\n                self.state.playing_to_paused();\n\n                self.ensure_sink_stopped(false);\n                self.send_event(PlayerEvent::Paused {\n                    track_id,\n                    play_request_id,\n                    position_ms: stream_position_ms,\n                });\n            }\n            PlayerState::Loading {\n                ref mut start_playback,\n                ..\n            } => {\n                *start_playback = false;\n            }\n            _ => error!(\"Player::pause called from invalid state: {:?}\", self.state),\n        }\n    }\n\n    fn handle_packet(\n        &mut self,\n        packet: Option<(AudioPacketPosition, AudioPacket)>,\n        normalisation_factor: f64,\n    ) {\n        match packet {\n            Some((_, mut packet)) => {\n                if !packet.is_empty() {\n                    if let AudioPacket::Samples(ref mut data) = packet {\n                        // Get the volume for the packet. In the case of hardware volume control\n                        // this will always be 1.0 (no change).\n                        let volume = self.volume_getter.attenuation_factor();\n\n                        // For the basic normalisation method, a normalisation factor of 1.0\n                        // indicates that there is nothing to normalise (all samples should pass\n                        // unaltered). For the dynamic method, there may still be peaks that we\n                        // want to shave off.\n                        //\n                        // No matter the case we apply volume attenuation last if there is any.\n                        match (self.config.normalisation, self.config.normalisation_method) {\n                            (false, _) => {\n                                if volume < 1.0 {\n                                    for sample in data.iter_mut() {\n                                        *sample *= volume;\n                                    }\n                                }\n                            }\n                            (true, NormalisationMethod::Dynamic) => {\n                                // zero-cost shorthands\n                                let threshold_db = self.config.normalisation_threshold_dbfs;\n                                let knee_db = self.config.normalisation_knee_db;\n                                let attack_cf = self.config.normalisation_attack_cf;\n                                let release_cf = self.config.normalisation_release_cf;\n\n                                for sample in data.iter_mut() {\n                                    // Feedforward limiter in the log domain\n                                    // After: Giannoulis, D., Massberg, M., & Reiss, J.D. (2012).\n                                    // Digital Dynamic Range Compressor Design—A Tutorial and\n                                    // Analysis. Journal of The Audio Engineering Society, 60,\n                                    // 399-408.\n\n                                    // This implementation assumes audio is stereo.\n\n                                    // step 0: apply gain stage\n                                    *sample *= normalisation_factor;\n\n                                    // step 1-4: half-wave rectification and conversion into dB, and\n                                    // gain computer with soft knee and subtractor\n                                    let limiter_db = {\n                                        // Add slight DC offset. Some samples are silence, which is\n                                        // -inf dB and gets the limiter stuck. Adding a small\n                                        // positive offset prevents this.\n                                        *sample += f64::MIN_POSITIVE;\n\n                                        let bias_db = ratio_to_db(sample.abs()) - threshold_db;\n                                        let knee_boundary_db = bias_db * 2.0;\n                                        if knee_boundary_db < -knee_db {\n                                            0.0\n                                        } else if knee_boundary_db.abs() <= knee_db {\n                                            let term = knee_boundary_db + knee_db;\n                                            term * term * self.normalisation_knee_factor\n                                        } else {\n                                            bias_db\n                                        }\n                                    };\n\n                                    // track left/right channel\n                                    let channel = self.normalisation_channel;\n                                    self.normalisation_channel ^= 1;\n\n                                    // step 5: smooth, decoupled peak detector for each channel\n                                    // Use direct references to reduce repeated array indexing\n                                    let integrator = &mut self.normalisation_integrators[channel];\n                                    let peak = &mut self.normalisation_peaks[channel];\n\n                                    *integrator = f64::max(\n                                        limiter_db,\n                                        release_cf * *integrator + (1.0 - release_cf) * limiter_db,\n                                    );\n                                    *peak = attack_cf * *peak + (1.0 - attack_cf) * *integrator;\n\n                                    // steps 6-8: conversion into level and multiplication into gain\n                                    // stage. Find maximum peak across both channels to couple the\n                                    // gain and maintain stereo imaging.\n                                    let max_peak = f64::max(\n                                        self.normalisation_peaks[0],\n                                        self.normalisation_peaks[1],\n                                    );\n                                    *sample *= db_to_ratio(-max_peak) * volume;\n                                }\n                            }\n                            (true, NormalisationMethod::Basic) => {\n                                if normalisation_factor < 1.0 || volume < 1.0 {\n                                    for sample in data.iter_mut() {\n                                        *sample *= normalisation_factor * volume;\n                                    }\n                                }\n                            }\n                        }\n                    }\n\n                    if let Err(e) = self.sink.write(packet, &mut self.converter) {\n                        error!(\"{e}\");\n                        self.handle_pause();\n                    }\n                }\n            }\n\n            None => {\n                self.state.playing_to_end_of_track();\n                if let PlayerState::EndOfTrack {\n                    ref track_id,\n                    play_request_id,\n                    ..\n                } = self.state\n                {\n                    self.send_event(PlayerEvent::EndOfTrack {\n                        track_id: track_id.clone(),\n                        play_request_id,\n                    })\n                } else {\n                    error!(\"PlayerInternal handle_packet: Invalid PlayerState\");\n                    exit(1);\n                }\n            }\n        }\n    }\n\n    fn start_playback(\n        &mut self,\n        track_id: SpotifyUri,\n        play_request_id: u64,\n        loaded_track: PlayerLoadedTrackData,\n        start_playback: bool,\n    ) {\n        let audio_item = Box::new(loaded_track.audio_item.clone());\n\n        self.send_event(PlayerEvent::TrackChanged { audio_item });\n\n        let position_ms = loaded_track.stream_position_ms;\n\n        let mut config = self.config.clone();\n        if config.normalisation_type == NormalisationType::Auto {\n            if self.auto_normalise_as_album {\n                config.normalisation_type = NormalisationType::Album;\n            } else {\n                config.normalisation_type = NormalisationType::Track;\n            }\n        };\n        let normalisation_factor =\n            NormalisationData::get_factor(&config, loaded_track.normalisation_data);\n\n        if start_playback {\n            self.ensure_sink_running();\n            self.send_event(PlayerEvent::Playing {\n                track_id: track_id.clone(),\n                play_request_id,\n                position_ms,\n            });\n\n            self.state = PlayerState::Playing {\n                track_id,\n                play_request_id,\n                decoder: loaded_track.decoder,\n                audio_item: loaded_track.audio_item,\n                normalisation_data: loaded_track.normalisation_data,\n                normalisation_factor,\n                stream_loader_controller: loaded_track.stream_loader_controller,\n                duration_ms: loaded_track.duration_ms,\n                bytes_per_second: loaded_track.bytes_per_second,\n                stream_position_ms: loaded_track.stream_position_ms,\n                reported_nominal_start_time: Instant::now()\n                    .checked_sub(Duration::from_millis(position_ms as u64)),\n                suggested_to_preload_next_track: false,\n                is_explicit: loaded_track.is_explicit,\n            };\n        } else {\n            self.ensure_sink_stopped(false);\n\n            self.state = PlayerState::Paused {\n                track_id: track_id.clone(),\n                play_request_id,\n                decoder: loaded_track.decoder,\n                audio_item: loaded_track.audio_item,\n                normalisation_data: loaded_track.normalisation_data,\n                normalisation_factor,\n                stream_loader_controller: loaded_track.stream_loader_controller,\n                duration_ms: loaded_track.duration_ms,\n                bytes_per_second: loaded_track.bytes_per_second,\n                stream_position_ms: loaded_track.stream_position_ms,\n                suggested_to_preload_next_track: false,\n                is_explicit: loaded_track.is_explicit,\n            };\n\n            self.send_event(PlayerEvent::Paused {\n                track_id,\n                play_request_id,\n                position_ms,\n            });\n        }\n    }\n\n    fn handle_command_load(\n        &mut self,\n        track_id: SpotifyUri,\n        play_request_id_option: Option<u64>,\n        play: bool,\n        position_ms: u32,\n    ) -> PlayerResult {\n        let play_request_id =\n            play_request_id_option.unwrap_or(self.play_request_id_generator.get());\n\n        self.send_event(PlayerEvent::PlayRequestIdChanged { play_request_id });\n\n        if !self.config.gapless {\n            self.ensure_sink_stopped(play);\n        }\n\n        if matches!(self.state, PlayerState::Invalid) {\n            return Err(Error::internal(format!(\n                \"Player::handle_command_load called from invalid state: {:?}\",\n                self.state\n            )));\n        }\n\n        // Now we check at different positions whether we already have a pre-loaded version\n        // of this track somewhere. If so, use it and return.\n\n        // Check if there's a matching loaded track in the EndOfTrack player state.\n        // This is the case if we're repeating the same track again.\n        if let PlayerState::EndOfTrack {\n            track_id: previous_track_id,\n            ..\n        } = &self.state\n        {\n            if *previous_track_id == track_id {\n                let mut loaded_track = match mem::replace(&mut self.state, PlayerState::Invalid) {\n                    PlayerState::EndOfTrack { loaded_track, .. } => loaded_track,\n                    _ => {\n                        return Err(Error::internal(format!(\n                            \"PlayerInternal::handle_command_load repeating the same track: invalid state: {:?}\",\n                            self.state\n                        )));\n                    }\n                };\n\n                if position_ms != loaded_track.stream_position_ms {\n                    // This may be blocking.\n                    loaded_track.stream_position_ms = loaded_track.decoder.seek(position_ms)?;\n                }\n                self.preload = PlayerPreload::None;\n                self.start_playback(track_id, play_request_id, loaded_track, play);\n                if let PlayerState::Invalid = self.state {\n                    return Err(Error::internal(format!(\n                        \"PlayerInternal::handle_command_load repeating the same track: start_playback() did not transition to valid player state: {:?}\",\n                        self.state\n                    )));\n                }\n                return Ok(());\n            }\n        }\n\n        // Check if we are already playing the track. If so, just do a seek and update our info.\n        if let PlayerState::Playing {\n            track_id: ref current_track_id,\n            ref mut stream_position_ms,\n            ref mut decoder,\n            ..\n        }\n        | PlayerState::Paused {\n            track_id: ref current_track_id,\n            ref mut stream_position_ms,\n            ref mut decoder,\n            ..\n        } = self.state\n        {\n            if *current_track_id == track_id {\n                // we can use the current decoder. Ensure it's at the correct position.\n                if position_ms != *stream_position_ms {\n                    // This may be blocking.\n                    *stream_position_ms = decoder.seek(position_ms)?;\n                }\n\n                // Move the info from the current state into a PlayerLoadedTrackData so we can use\n                // the usual code path to start playback.\n                let old_state = mem::replace(&mut self.state, PlayerState::Invalid);\n\n                if let PlayerState::Playing {\n                    stream_position_ms,\n                    decoder,\n                    audio_item,\n                    stream_loader_controller,\n                    bytes_per_second,\n                    duration_ms,\n                    normalisation_data,\n                    is_explicit,\n                    ..\n                }\n                | PlayerState::Paused {\n                    stream_position_ms,\n                    decoder,\n                    audio_item,\n                    stream_loader_controller,\n                    bytes_per_second,\n                    duration_ms,\n                    normalisation_data,\n                    is_explicit,\n                    ..\n                } = old_state\n                {\n                    let loaded_track = PlayerLoadedTrackData {\n                        decoder,\n                        normalisation_data,\n                        stream_loader_controller,\n                        audio_item,\n                        bytes_per_second,\n                        duration_ms,\n                        stream_position_ms,\n                        is_explicit,\n                    };\n\n                    self.preload = PlayerPreload::None;\n                    self.start_playback(track_id, play_request_id, loaded_track, play);\n\n                    if let PlayerState::Invalid = self.state {\n                        return Err(Error::internal(format!(\n                            \"PlayerInternal::handle_command_load already playing this track: start_playback() did not transition to valid player state: {:?}\",\n                            self.state\n                        )));\n                    }\n\n                    return Ok(());\n                } else {\n                    return Err(Error::internal(format!(\n                        \"PlayerInternal::handle_command_load already playing this track: invalid state: {:?}\",\n                        self.state\n                    )));\n                }\n            }\n        }\n\n        // Check if the requested track has been preloaded already. If so use the preloaded data.\n        if let PlayerPreload::Ready {\n            track_id: loaded_track_id,\n            ..\n        } = &self.preload\n        {\n            if track_id == *loaded_track_id {\n                let preload = std::mem::replace(&mut self.preload, PlayerPreload::None);\n                if let PlayerPreload::Ready {\n                    track_id,\n                    mut loaded_track,\n                } = preload\n                {\n                    if position_ms != loaded_track.stream_position_ms {\n                        // This may be blocking\n                        loaded_track.stream_position_ms = loaded_track.decoder.seek(position_ms)?;\n                    }\n                    self.start_playback(track_id, play_request_id, *loaded_track, play);\n                    return Ok(());\n                } else {\n                    return Err(Error::internal(format!(\n                        \"PlayerInternal::handle_command_loading preloaded track: invalid state: {:?}\",\n                        self.state\n                    )));\n                }\n            }\n        }\n\n        self.send_event(PlayerEvent::Loading {\n            track_id: track_id.clone(),\n            play_request_id,\n            position_ms,\n        });\n\n        // Try to extract a pending loader from the preloading mechanism\n        let loader = if let PlayerPreload::Loading {\n            track_id: loaded_track_id,\n            ..\n        } = &self.preload\n        {\n            if (track_id == *loaded_track_id) && (position_ms == 0) {\n                let mut preload = PlayerPreload::None;\n                std::mem::swap(&mut preload, &mut self.preload);\n                if let PlayerPreload::Loading { loader, .. } = preload {\n                    Some(loader)\n                } else {\n                    None\n                }\n            } else {\n                None\n            }\n        } else {\n            None\n        };\n\n        self.preload = PlayerPreload::None;\n\n        // If we don't have a loader yet, create one from scratch.\n        let loader =\n            loader.unwrap_or_else(|| Box::pin(self.load_track(track_id.clone(), position_ms)));\n\n        // Set ourselves to a loading state.\n        self.state = PlayerState::Loading {\n            track_id,\n            play_request_id,\n            start_playback: play,\n            loader,\n        };\n\n        Ok(())\n    }\n\n    fn handle_command_preload(&mut self, track_id: SpotifyUri) {\n        debug!(\"Preloading track\");\n        let mut preload_track = true;\n        // check whether the track is already loaded somewhere or being loaded.\n        if let PlayerPreload::Loading {\n            track_id: currently_loading,\n            ..\n        }\n        | PlayerPreload::Ready {\n            track_id: currently_loading,\n            ..\n        } = &self.preload\n        {\n            if *currently_loading == track_id {\n                // we're already preloading the requested track.\n                preload_track = false;\n            } else {\n                // we're preloading something else - cancel it.\n                self.preload = PlayerPreload::None;\n            }\n        }\n\n        if let PlayerState::Playing {\n            track_id: current_track_id,\n            ..\n        }\n        | PlayerState::Paused {\n            track_id: current_track_id,\n            ..\n        }\n        | PlayerState::EndOfTrack {\n            track_id: current_track_id,\n            ..\n        } = &self.state\n        {\n            if *current_track_id == track_id {\n                // we already have the requested track loaded.\n                preload_track = false;\n            }\n        }\n\n        // schedule the preload of the current track if desired.\n        if preload_track {\n            let loader = self.load_track(track_id.clone(), 0);\n            self.preload = PlayerPreload::Loading {\n                track_id,\n                loader: Box::pin(loader),\n            }\n        }\n    }\n\n    fn handle_command_seek(&mut self, position_ms: u32) -> PlayerResult {\n        // When we are still loading, the user may immediately ask to\n        // seek to another position yet the decoder won't be ready for\n        // that. In this case just restart the loading process but\n        // with the requested position.\n        if let PlayerState::Loading {\n            ref track_id,\n            play_request_id,\n            start_playback,\n            ..\n        } = self.state\n        {\n            return self.handle_command_load(\n                track_id.clone(),\n                Some(play_request_id),\n                start_playback,\n                position_ms,\n            );\n        }\n\n        if let Some(decoder) = self.state.decoder() {\n            match decoder.seek(position_ms) {\n                Ok(new_position_ms) => {\n                    if let PlayerState::Playing {\n                        ref mut stream_position_ms,\n                        ref track_id,\n                        play_request_id,\n                        ..\n                    }\n                    | PlayerState::Paused {\n                        ref mut stream_position_ms,\n                        ref track_id,\n                        play_request_id,\n                        ..\n                    } = self.state\n                    {\n                        *stream_position_ms = new_position_ms;\n\n                        self.send_event(PlayerEvent::Seeked {\n                            play_request_id,\n                            track_id: track_id.clone(),\n                            position_ms: new_position_ms,\n                        });\n                    }\n                }\n                Err(e) => error!(\"PlayerInternal::handle_command_seek error: {e}\"),\n            }\n        } else {\n            error!(\"Player::seek called from invalid state: {:?}\", self.state);\n        }\n\n        // ensure we have a bit of a buffer of downloaded data\n        self.preload_data_before_playback()?;\n\n        if let PlayerState::Playing {\n            ref mut reported_nominal_start_time,\n            ..\n        } = self.state\n        {\n            *reported_nominal_start_time =\n                Instant::now().checked_sub(Duration::from_millis(position_ms as u64));\n        }\n\n        Ok(())\n    }\n\n    fn handle_command(&mut self, cmd: PlayerCommand) -> PlayerResult {\n        debug!(\"command={cmd:?}\");\n        match cmd {\n            PlayerCommand::Load {\n                track_id,\n                play,\n                position_ms,\n            } => self.handle_command_load(track_id, None, play, position_ms)?,\n\n            PlayerCommand::Preload { track_id } => self.handle_command_preload(track_id),\n\n            PlayerCommand::Seek(position_ms) => self.handle_command_seek(position_ms)?,\n\n            PlayerCommand::Play => self.handle_play(),\n\n            PlayerCommand::Pause => self.handle_pause(),\n\n            PlayerCommand::Stop => self.handle_player_stop(),\n\n            PlayerCommand::SetSession(session) => self.session = session,\n\n            PlayerCommand::AddEventSender(sender) => self.event_senders.push(sender),\n\n            PlayerCommand::SetSinkEventCallback(callback) => self.sink_event_callback = callback,\n\n            PlayerCommand::EmitVolumeChangedEvent(volume) => {\n                self.send_event(PlayerEvent::VolumeChanged { volume })\n            }\n\n            PlayerCommand::EmitRepeatChangedEvent { context, track } => {\n                self.send_event(PlayerEvent::RepeatChanged { context, track })\n            }\n\n            PlayerCommand::EmitShuffleChangedEvent(shuffle) => {\n                self.send_event(PlayerEvent::ShuffleChanged { shuffle })\n            }\n\n            PlayerCommand::EmitAutoPlayChangedEvent(auto_play) => {\n                self.send_event(PlayerEvent::AutoPlayChanged { auto_play })\n            }\n\n            PlayerCommand::EmitSessionClientChangedEvent {\n                client_id,\n                client_name,\n                client_brand_name,\n                client_model_name,\n            } => self.send_event(PlayerEvent::SessionClientChanged {\n                client_id,\n                client_name,\n                client_brand_name,\n                client_model_name,\n            }),\n\n            PlayerCommand::EmitSessionConnectedEvent {\n                connection_id,\n                user_name,\n            } => self.send_event(PlayerEvent::SessionConnected {\n                connection_id,\n                user_name,\n            }),\n\n            PlayerCommand::EmitSessionDisconnectedEvent {\n                connection_id,\n                user_name,\n            } => self.send_event(PlayerEvent::SessionDisconnected {\n                connection_id,\n                user_name,\n            }),\n\n            PlayerCommand::SetAutoNormaliseAsAlbum(setting) => {\n                self.auto_normalise_as_album = setting\n            }\n\n            PlayerCommand::EmitSetQueueEvent {\n                context_uri,\n                current_track,\n                next_tracks,\n                prev_tracks,\n            } => self.send_event(PlayerEvent::SetQueue {\n                context_uri,\n                current_track,\n                next_tracks,\n                prev_tracks,\n            }),\n\n            PlayerCommand::EmitFilterExplicitContentChangedEvent(filter) => {\n                self.send_event(PlayerEvent::FilterExplicitContentChanged { filter });\n\n                if filter {\n                    if let PlayerState::Playing {\n                        ref track_id,\n                        play_request_id,\n                        is_explicit,\n                        ..\n                    }\n                    | PlayerState::Paused {\n                        ref track_id,\n                        play_request_id,\n                        is_explicit,\n                        ..\n                    } = self.state\n                    {\n                        let track_id = track_id.clone();\n\n                        if is_explicit {\n                            warn!(\n                                \"Currently loaded track is explicit, which client setting forbids -- skipping to next track.\"\n                            );\n                            self.send_event(PlayerEvent::EndOfTrack {\n                                track_id,\n                                play_request_id,\n                            })\n                        }\n                    }\n                }\n            }\n        };\n\n        Ok(())\n    }\n\n    fn send_event(&mut self, event: PlayerEvent) {\n        self.event_senders\n            .retain(|sender| sender.send(event.clone()).is_ok());\n    }\n\n    fn load_track(\n        &mut self,\n        spotify_uri: SpotifyUri,\n        position_ms: u32,\n    ) -> impl FusedFuture<Output = Result<PlayerLoadedTrackData, ()>> + Send + 'static {\n        // This method creates a future that returns the loaded stream and associated info.\n        // Ideally all work should be done using asynchronous code. However, seek() on the\n        // audio stream is implemented in a blocking fashion. Thus, we can't turn it into future\n        // easily. Instead we spawn a thread to do the work and return a one-shot channel as the\n        // future to work with.\n\n        let loader = PlayerTrackLoader {\n            session: self.session.clone(),\n            config: self.config.clone(),\n            local_file_lookup: self.local_file_lookup.clone(),\n        };\n\n        let (result_tx, result_rx) = oneshot::channel();\n\n        let load_handles_clone = self.load_handles.clone();\n        let handle = tokio::runtime::Handle::current();\n\n        let load_handle = thread::spawn(move || {\n            let data = handle.block_on(loader.load_track(spotify_uri, position_ms));\n            if let Some(data) = data {\n                let _ = result_tx.send(data);\n            }\n\n            let mut load_handles = load_handles_clone.lock().expect(LOAD_HANDLES_POISON_MSG);\n            load_handles.remove(&thread::current().id());\n        });\n\n        let mut load_handles = self.load_handles.lock().expect(LOAD_HANDLES_POISON_MSG);\n        load_handles.insert(load_handle.thread().id(), load_handle);\n\n        result_rx.map_err(|_| ())\n    }\n\n    fn preload_data_before_playback(&mut self) -> PlayerResult {\n        if let PlayerState::Playing {\n            bytes_per_second,\n            ref mut stream_loader_controller,\n            ..\n        } = self.state\n        {\n            let read_ahead_during_playback = AudioFetchParams::get().read_ahead_during_playback;\n            // Request our read ahead range\n            let request_data_length =\n                (read_ahead_during_playback.as_secs_f32() * bytes_per_second as f32) as usize;\n\n            // Request the part we want to wait for blocking. This effectively means we wait for the previous request to partially complete.\n            let wait_for_data_length =\n                (read_ahead_during_playback.as_secs_f32() * bytes_per_second as f32) as usize;\n\n            stream_loader_controller.fetch_next_and_wait(request_data_length, wait_for_data_length)\n        } else {\n            Ok(())\n        }\n    }\n}\n\nimpl Drop for PlayerInternal {\n    fn drop(&mut self) {\n        debug!(\"drop PlayerInternal[{}]\", self.player_id);\n\n        let handles: Vec<thread::JoinHandle<()>> = {\n            // waiting for the thread while holding the mutex would result in a deadlock\n            let mut load_handles = self.load_handles.lock().expect(LOAD_HANDLES_POISON_MSG);\n\n            load_handles\n                .drain()\n                .map(|(_thread_id, handle)| handle)\n                .collect()\n        };\n\n        for handle in handles {\n            let _ = handle.join();\n        }\n    }\n}\n\nimpl fmt::Debug for PlayerCommand {\n    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n        match self {\n            PlayerCommand::Load {\n                track_id,\n                play,\n                position_ms,\n                ..\n            } => f\n                .debug_tuple(\"Load\")\n                .field(&track_id)\n                .field(&play)\n                .field(&position_ms)\n                .finish(),\n            PlayerCommand::Preload { track_id } => {\n                f.debug_tuple(\"Preload\").field(&track_id).finish()\n            }\n            PlayerCommand::Play => f.debug_tuple(\"Play\").finish(),\n            PlayerCommand::Pause => f.debug_tuple(\"Pause\").finish(),\n            PlayerCommand::Stop => f.debug_tuple(\"Stop\").finish(),\n            PlayerCommand::Seek(position) => f.debug_tuple(\"Seek\").field(&position).finish(),\n            PlayerCommand::SetSession(_) => f.debug_tuple(\"SetSession\").finish(),\n            PlayerCommand::AddEventSender(_) => f.debug_tuple(\"AddEventSender\").finish(),\n            PlayerCommand::SetSinkEventCallback(_) => {\n                f.debug_tuple(\"SetSinkEventCallback\").finish()\n            }\n            PlayerCommand::EmitVolumeChangedEvent(volume) => f\n                .debug_tuple(\"EmitVolumeChangedEvent\")\n                .field(&volume)\n                .finish(),\n            PlayerCommand::SetAutoNormaliseAsAlbum(setting) => f\n                .debug_tuple(\"SetAutoNormaliseAsAlbum\")\n                .field(&setting)\n                .finish(),\n            PlayerCommand::EmitFilterExplicitContentChangedEvent(filter) => f\n                .debug_tuple(\"EmitFilterExplicitContentChangedEvent\")\n                .field(&filter)\n                .finish(),\n            PlayerCommand::EmitSessionConnectedEvent {\n                connection_id,\n                user_name,\n            } => f\n                .debug_tuple(\"EmitSessionConnectedEvent\")\n                .field(&connection_id)\n                .field(&user_name)\n                .finish(),\n            PlayerCommand::EmitSessionDisconnectedEvent {\n                connection_id,\n                user_name,\n            } => f\n                .debug_tuple(\"EmitSessionDisconnectedEvent\")\n                .field(&connection_id)\n                .field(&user_name)\n                .finish(),\n            PlayerCommand::EmitSessionClientChangedEvent {\n                client_id,\n                client_name,\n                client_brand_name,\n                client_model_name,\n            } => f\n                .debug_tuple(\"EmitSessionClientChangedEvent\")\n                .field(&client_id)\n                .field(&client_name)\n                .field(&client_brand_name)\n                .field(&client_model_name)\n                .finish(),\n            PlayerCommand::EmitShuffleChangedEvent(shuffle) => f\n                .debug_tuple(\"EmitShuffleChangedEvent\")\n                .field(&shuffle)\n                .finish(),\n            PlayerCommand::EmitRepeatChangedEvent { context, track } => f\n                .debug_tuple(\"EmitRepeatChangedEvent\")\n                .field(&context)\n                .field(&track)\n                .finish(),\n            PlayerCommand::EmitAutoPlayChangedEvent(auto_play) => f\n                .debug_tuple(\"EmitAutoPlayChangedEvent\")\n                .field(&auto_play)\n                .finish(),\n            PlayerCommand::EmitSetQueueEvent {\n                context_uri,\n                next_tracks,\n                prev_tracks,\n                ..\n            } => f\n                .debug_tuple(\"EmitSetQueueEvent\")\n                .field(&context_uri)\n                .field(&next_tracks.len())\n                .field(&prev_tracks.len())\n                .finish(),\n        }\n    }\n}\n\nimpl fmt::Debug for PlayerState {\n    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n        use PlayerState::*;\n        match self {\n            Stopped => f.debug_struct(\"Stopped\").finish(),\n            Loading {\n                track_id,\n                play_request_id,\n                ..\n            } => f\n                .debug_struct(\"Loading\")\n                .field(\"track_id\", &track_id)\n                .field(\"play_request_id\", &play_request_id)\n                .finish(),\n            Paused {\n                track_id,\n                play_request_id,\n                ..\n            } => f\n                .debug_struct(\"Paused\")\n                .field(\"track_id\", &track_id)\n                .field(\"play_request_id\", &play_request_id)\n                .finish(),\n            Playing {\n                track_id,\n                play_request_id,\n                ..\n            } => f\n                .debug_struct(\"Playing\")\n                .field(\"track_id\", &track_id)\n                .field(\"play_request_id\", &play_request_id)\n                .finish(),\n            EndOfTrack {\n                track_id,\n                play_request_id,\n                ..\n            } => f\n                .debug_struct(\"EndOfTrack\")\n                .field(\"track_id\", &track_id)\n                .field(\"play_request_id\", &play_request_id)\n                .finish(),\n            Invalid => f.debug_struct(\"Invalid\").finish(),\n        }\n    }\n}\n\nstruct Subfile<T: Read + Seek> {\n    stream: T,\n    offset: u64,\n    length: u64,\n}\n\nimpl<T: Read + Seek> Subfile<T> {\n    pub fn new(mut stream: T, offset: u64, length: u64) -> Result<Subfile<T>, io::Error> {\n        let target = SeekFrom::Start(offset);\n        stream.seek(target)?;\n\n        Ok(Subfile {\n            stream,\n            offset,\n            length,\n        })\n    }\n}\n\nimpl<T: Read + Seek> Read for Subfile<T> {\n    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {\n        self.stream.read(buf)\n    }\n}\n\nimpl<T: Read + Seek> Seek for Subfile<T> {\n    fn seek(&mut self, pos: SeekFrom) -> io::Result<u64> {\n        let pos = match pos {\n            SeekFrom::Start(offset) => SeekFrom::Start(offset + self.offset),\n            SeekFrom::End(offset) => {\n                if (self.length as i64 - offset) < self.offset as i64 {\n                    return Err(io::Error::new(\n                        io::ErrorKind::InvalidInput,\n                        \"newpos would be < self.offset\",\n                    ));\n                }\n                pos\n            }\n            _ => pos,\n        };\n\n        let newpos = self.stream.seek(pos)?;\n        Ok(newpos - self.offset)\n    }\n}\n\nimpl<R> MediaSource for Subfile<R>\nwhere\n    R: Read + Seek + Send + Sync,\n{\n    fn is_seekable(&self) -> bool {\n        true\n    }\n\n    fn byte_len(&self) -> Option<u64> {\n        Some(self.length)\n    }\n}\n"
  },
  {
    "path": "playback/src/symphonia_util.rs",
    "content": "use symphonia::core::meta::Metadata;\nuse symphonia::core::probe::ProbeResult;\n\npub fn get_latest_metadata(probe_result: &mut ProbeResult) -> Option<Metadata<'_>> {\n    let mut metadata = probe_result.format.metadata();\n\n    // If we can't get metadata from the container, fall back to other tags found by probing.\n    // Note that this is only relevant for local files.\n    if metadata.current().is_none() {\n        if let Some(inner_probe_metadata) = probe_result.metadata.get() {\n            metadata = inner_probe_metadata;\n        }\n    }\n\n    _ = metadata.skip_to_latest();\n\n    Some(metadata)\n}\n"
  },
  {
    "path": "protocol/Cargo.toml",
    "content": "[package]\nname = \"librespot-protocol\"\nversion = \"0.8.0\"\nrust-version.workspace = true\nauthors = [\"Paul Liétar <paul@lietar.net>\"]\nlicense.workspace = true\ndescription = \"The protobuf logic for communicating with Spotify servers\"\nrepository.workspace = true\nedition.workspace = true\nbuild = \"build.rs\"\n\n[dependencies]\nprotobuf = \"3\"\n\n[build-dependencies]\nprotobuf-codegen = \"3\"\n\n[lints]\nworkspace = true\n"
  },
  {
    "path": "protocol/build.rs",
    "content": "use std::{\n    env, fs,\n    ops::Deref,\n    path::{Path, PathBuf},\n};\n\nfn out_dir() -> PathBuf {\n    Path::new(&env::var(\"OUT_DIR\").expect(\"env\")).to_path_buf()\n}\n\nfn cleanup() {\n    let _ = fs::remove_dir_all(out_dir());\n}\n\nfn compile() {\n    let proto_dir = Path::new(&env::var(\"CARGO_MANIFEST_DIR\").expect(\"env\")).join(\"proto\");\n\n    let files = &[\n        proto_dir.join(\"connect.proto\"),\n        proto_dir.join(\"media.proto\"),\n        proto_dir.join(\"connectivity.proto\"),\n        proto_dir.join(\"devices.proto\"),\n        proto_dir.join(\"entity_extension_data.proto\"),\n        proto_dir.join(\"extended_metadata.proto\"),\n        proto_dir.join(\"extension_kind.proto\"),\n        proto_dir.join(\"metadata.proto\"),\n        proto_dir.join(\"player.proto\"),\n        proto_dir.join(\"playlist_annotate3.proto\"),\n        proto_dir.join(\"playlist_permission.proto\"),\n        proto_dir.join(\"playlist4_external.proto\"),\n        proto_dir.join(\"lens-model.proto\"),\n        proto_dir.join(\"signal-model.proto\"),\n        proto_dir.join(\"spotify/clienttoken/v0/clienttoken_http.proto\"),\n        proto_dir.join(\"spotify/login5/v3/challenges/code.proto\"),\n        proto_dir.join(\"spotify/login5/v3/challenges/hashcash.proto\"),\n        proto_dir.join(\"spotify/login5/v3/client_info.proto\"),\n        proto_dir.join(\"spotify/login5/v3/credentials/credentials.proto\"),\n        proto_dir.join(\"spotify/login5/v3/identifiers/identifiers.proto\"),\n        proto_dir.join(\"spotify/login5/v3/login5.proto\"),\n        proto_dir.join(\"spotify/login5/v3/user_info.proto\"),\n        proto_dir.join(\"storage-resolve.proto\"),\n        proto_dir.join(\"user_attributes.proto\"),\n        proto_dir.join(\"autoplay_context_request.proto\"),\n        proto_dir.join(\"social_connect_v2.proto\"),\n        proto_dir.join(\"transfer_state.proto\"),\n        proto_dir.join(\"context_player_options.proto\"),\n        proto_dir.join(\"playback.proto\"),\n        proto_dir.join(\"play_history.proto\"),\n        proto_dir.join(\"session.proto\"),\n        proto_dir.join(\"queue.proto\"),\n        proto_dir.join(\"context_track.proto\"),\n        proto_dir.join(\"context.proto\"),\n        proto_dir.join(\"restrictions.proto\"),\n        proto_dir.join(\"context_page.proto\"),\n        proto_dir.join(\"play_origin.proto\"),\n        proto_dir.join(\"suppressions.proto\"),\n        proto_dir.join(\"instrumentation_params.proto\"),\n        // TODO: remove these legacy protobufs when we are on the new API completely\n        proto_dir.join(\"authentication.proto\"),\n        proto_dir.join(\"canvaz.proto\"),\n        proto_dir.join(\"canvaz-meta.proto\"),\n        proto_dir.join(\"explicit_content_pubsub.proto\"),\n        proto_dir.join(\"keyexchange.proto\"),\n        proto_dir.join(\"mercury.proto\"),\n        proto_dir.join(\"pubsub.proto\"),\n    ];\n\n    let slices = files.iter().map(Deref::deref).collect::<Vec<_>>();\n\n    let out_dir = out_dir();\n    fs::create_dir(&out_dir).expect(\"create_dir\");\n\n    protobuf_codegen::Codegen::new()\n        .pure()\n        .out_dir(&out_dir)\n        .inputs(&slices)\n        .include(&proto_dir)\n        .run()\n        .expect(\"Codegen failed.\");\n}\n\nfn main() {\n    cleanup();\n    compile();\n}\n"
  },
  {
    "path": "protocol/proto/AdContext.proto",
    "content": "// Extracted from: Spotify 1.1.73.517 (macOS)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage AdContext {\n    optional string preceding_content_uri = 1;\n    optional string preceding_playback_id = 2;\n    optional int32 preceding_end_position = 3;\n    repeated string ad_ids = 4;\n    optional string ad_request_id = 5;\n    optional string succeeding_content_uri = 6;\n    optional string succeeding_playback_id = 7;\n    optional int32 succeeding_start_position = 8;\n    optional int32 preceding_duration = 9;\n}\n"
  },
  {
    "path": "protocol/proto/AdDecisionEvent.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage AdDecisionEvent {\n    optional string request_id = 1;\n    optional string decision_request_id = 2;\n    optional string decision_type = 3;\n}\n"
  },
  {
    "path": "protocol/proto/AdError.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage AdError {\n    optional string request_type = 1;\n    optional string error_message = 2;\n    optional int64 http_error_code = 3;\n    optional string request_url = 4;\n    optional string tracking_event = 5;\n}\n"
  },
  {
    "path": "protocol/proto/AdEvent.proto",
    "content": "// Extracted from: Spotify 1.1.73.517 (macOS)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage AdEvent {\n    optional string request_id = 1;\n    optional string app_startup_id = 2;\n    optional string ad_id = 3;\n    optional string lineitem_id = 4;\n    optional string creative_id = 5;\n    optional string slot = 6;\n    optional string format = 7;\n    optional string type = 8;\n    optional bool skippable = 9;\n    optional string event = 10;\n    optional string event_source = 11;\n    optional string event_reason = 12;\n    optional int32 event_sequence_num = 13;\n    optional int32 position = 14;\n    optional int32 duration = 15;\n    optional bool in_focus = 16;\n    optional float volume = 17;\n    optional string product_name = 18;\n}\n"
  },
  {
    "path": "protocol/proto/AdRequestEvent.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage AdRequestEvent {\n    optional string feature_identifier = 1;\n    optional string requested_ad_type = 2;\n    optional int64 latency_ms = 3;\n    repeated string requested_ad_types = 4;\n}\n"
  },
  {
    "path": "protocol/proto/AdSlotEvent.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage AdSlotEvent {\n    optional string event = 1;\n    optional string ad_id = 2;\n    optional string lineitem_id = 3;\n    optional string creative_id = 4;\n    optional string slot = 5;\n    optional string format = 6;\n    optional bool in_focus = 7;\n    optional string app_startup_id = 8;\n    optional string request_id = 9;\n}\n"
  },
  {
    "path": "protocol/proto/AmazonWakeUpTime.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage AmazonWakeUpTime {\n    optional int64 delay_to_online = 1;\n}\n"
  },
  {
    "path": "protocol/proto/AudioDriverError.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage AudioDriverError {\n    optional int64 error_code = 1;\n    optional string location = 2;\n    optional string driver_name = 3;\n    optional string additional_data = 4;\n}\n"
  },
  {
    "path": "protocol/proto/AudioDriverInfo.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage AudioDriverInfo {\n    optional string driver_name = 1;\n    optional string output_device_name = 2;\n    optional string output_device_category = 3;\n    optional string reason = 4;\n}\n"
  },
  {
    "path": "protocol/proto/AudioFileSelection.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage AudioFileSelection {\n    optional bytes playback_id = 1;\n    optional string strategy_name = 2;\n    optional int64 bitrate = 3;\n    optional bytes predict_id = 4;\n    optional string file_origin = 5;\n    optional int32 target_bitrate = 6;\n}\n"
  },
  {
    "path": "protocol/proto/AudioOffliningSettingsReport.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage AudioOffliningSettingsReport {\n    optional string default_sync_bitrate_product_state = 1;\n    optional int64 user_selected_sync_bitrate = 2;\n    optional int64 sync_bitrate = 3;\n    optional bool sync_over_cellular = 4;\n    optional string primary_resource_type = 5;\n}\n"
  },
  {
    "path": "protocol/proto/AudioRateLimit.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage AudioRateLimit {\n    optional string driver_name = 1;\n    optional string output_device_name = 2;\n    optional string output_device_category = 3;\n    optional int64 max_size = 4;\n    optional int64 refill_per_milliseconds = 5;\n    optional int64 frames_requested = 6;\n    optional int64 frames_acquired = 7;\n    optional bytes playback_id = 8;\n}\n"
  },
  {
    "path": "protocol/proto/AudioSessionEvent.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage AudioSessionEvent {\n    optional string event = 1;\n    optional string context = 2;\n    optional string json_data = 3;\n}\n"
  },
  {
    "path": "protocol/proto/AudioSettingsReport.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage AudioSettingsReport {\n    optional bool offline_mode = 1;\n    optional string default_play_bitrate_product_state = 2;\n    optional int64 user_selected_bitrate = 3;\n    optional int64 play_bitrate = 4;\n    optional bool low_bitrate_on_cellular = 5;\n    optional string default_sync_bitrate_product_state = 6;\n    optional int64 user_selected_sync_bitrate = 7;\n    optional int64 sync_bitrate = 8;\n    optional bool sync_over_cellular = 9;\n    optional string enable_gapless_product_state = 10;\n    optional bool enable_gapless = 11;\n    optional string enable_crossfade_product_state = 12;\n    optional bool enable_crossfade = 13;\n    optional int64 crossfade_time = 14;\n    optional bool enable_normalization = 15;\n    optional int64 playback_speed = 16;\n    optional string audio_loudness_level = 17;\n    optional bool enable_automix = 18;\n    optional bool enable_silence_trimmer = 19;\n    optional bool enable_mono_downmixer = 20;\n}\n"
  },
  {
    "path": "protocol/proto/AudioStreamingSettingsReport.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage AudioStreamingSettingsReport {\n    optional string default_play_bitrate_product_state = 1;\n    optional int64 user_selected_play_bitrate_cellular = 2;\n    optional int64 user_selected_play_bitrate_wifi = 3;\n    optional int64 play_bitrate_cellular = 4;\n    optional int64 play_bitrate_wifi = 5;\n    optional bool allow_downgrade = 6;\n}\n"
  },
  {
    "path": "protocol/proto/BoomboxPlaybackInstrumentation.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage BoomboxPlaybackInstrumentation {\n    optional bytes playback_id = 1;\n    optional bool was_playback_paused = 2;\n    repeated string dimensions = 3;\n    map<string, int64> total_buffer_size = 4;\n    map<string, int64> number_of_calls = 5;\n    map<string, int64> total_duration = 6;\n    map<string, int64> first_call_time = 7;\n    map<string, int64> last_call_time = 8;\n}\n"
  },
  {
    "path": "protocol/proto/BrokenObject.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage BrokenObject {\n    optional string type = 1;\n    optional string id = 2;\n    optional int64 error_code = 3;\n    optional bytes playback_id = 4;\n}\n"
  },
  {
    "path": "protocol/proto/CacheError.proto",
    "content": "// Extracted from: Spotify 1.1.73.517 (macOS)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage CacheError {\n    optional int64 error_code = 1;\n    optional int64 os_error_code = 2;\n    optional string realm = 3;\n    optional bytes file_id = 4;\n    optional int64 num_errors = 5;\n    optional string cache_path = 6;\n    optional int64 size = 7;\n    optional int64 range_start = 8;\n    optional int64 range_end = 9;\n}\n"
  },
  {
    "path": "protocol/proto/CachePruningReport.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage CachePruningReport {\n    optional bytes cache_id = 1;\n    optional int64 time_spent_pruning_ms = 2;\n    optional int64 size_before_prune_kb = 3;\n    optional int64 size_after_prune_kb = 4;\n    optional int64 num_entries_pruned = 5;\n    optional int64 num_entries_pruned_expired = 6;\n    optional int64 size_entries_pruned_expired_kb = 7;\n    optional int64 num_entries_pruned_limit = 8;\n    optional int64 size_pruned_limit_kb = 9;\n    optional int64 num_entries_pruned_never_used = 10;\n    optional int64 size_pruned_never_used_kb = 11;\n    optional int64 num_entries_pruned_max_realm_size = 12;\n    optional int64 size_pruned_max_realm_size_kb = 13;\n    optional int64 num_entries_pruned_min_free_space = 14;\n    optional int64 size_pruned_min_free_space_kb = 15;\n}\n"
  },
  {
    "path": "protocol/proto/CacheRealmPruningReport.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage CacheRealmPruningReport {\n    optional bytes cache_id = 1;\n    optional int64 realm_id = 2;\n    optional int64 num_entries_pruned = 3;\n    optional int64 num_entries_pruned_expired = 4;\n    optional int64 size_entries_pruned_expired_kb = 5;\n    optional int64 num_entries_pruned_limit = 6;\n    optional int64 size_pruned_limit_kb = 7;\n    optional int64 num_entries_pruned_never_used = 8;\n    optional int64 size_pruned_never_used_kb = 9;\n    optional int64 num_entries_pruned_max_realm_size = 10;\n    optional int64 size_pruned_max_realm_size_kb = 11;\n    optional int64 num_entries_pruned_min_free_space = 12;\n    optional int64 size_pruned_min_free_space_kb = 13;\n}\n"
  },
  {
    "path": "protocol/proto/CacheRealmReport.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage CacheRealmReport {\n    optional bytes cache_id = 1;\n    optional int64 realm_id = 2;\n    optional int64 num_entries = 3;\n    optional int64 num_locked_entries = 4;\n    optional int64 num_locked_entries_current_user = 5;\n    optional int64 num_full_entries = 6;\n    optional int64 size_kb = 7;\n    optional int64 locked_size_kb = 8;\n}\n"
  },
  {
    "path": "protocol/proto/CacheReport.proto",
    "content": "// Extracted from: Spotify 1.1.73.517 (macOS)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage CacheReport {\n    optional bytes cache_id = 1;\n    optional string cache_path = 21;\n    optional string volatile_path = 22;\n    optional int64 max_cache_size = 2;\n    optional int64 free_space = 3;\n    optional int64 total_space = 4;\n    optional int64 cache_age = 5;\n    optional int64 num_users_with_locked_entries = 6;\n    optional int64 permanent_files = 7;\n    optional int64 permanent_size_kb = 8;\n    optional int64 unknown_permanent_files = 9;\n    optional int64 unknown_permanent_size_kb = 10;\n    optional int64 volatile_files = 11;\n    optional int64 volatile_size_kb = 12;\n    optional int64 unknown_volatile_files = 13;\n    optional int64 unknown_volatile_size_kb = 14;\n    optional int64 num_entries = 15;\n    optional int64 num_locked_entries = 16;\n    optional int64 num_locked_entries_current_user = 17;\n    optional int64 num_full_entries = 18;\n    optional int64 size_kb = 19;\n    optional int64 locked_size_kb = 20;\n}\n"
  },
  {
    "path": "protocol/proto/ClientLocale.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage ClientLocale {\n    optional string client_default_locale = 1;\n    optional string user_specified_locale = 2;\n}\n"
  },
  {
    "path": "protocol/proto/ColdStartupSequence.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage ColdStartupSequence {\n    optional string terminal_state = 1;\n    map<string, int64> steps = 2;\n    map<string, string> metadata = 3;\n    optional string connection_type = 4;\n    optional string initial_application_state = 5;\n    optional string terminal_application_state = 6;\n    optional string view_load_sequence_id = 7;\n    optional int32 device_year_class = 8;\n    map<string, int64> subdurations = 9;\n}\n"
  },
  {
    "path": "protocol/proto/CollectionLevelDbInfo.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage CollectionLevelDbInfo {\n    optional string bucket = 1;\n    optional bool use_leveldb = 2;\n    optional bool migration_from_file_ok = 3;\n    optional bool index_check_ok = 4;\n    optional bool leveldb_works = 5;\n    optional bool already_migrated = 6;\n}\n"
  },
  {
    "path": "protocol/proto/CollectionOfflineControllerEmptyTrackList.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage CollectionOfflineControllerEmptyTrackList {\n    optional string link_type = 1;\n    optional bool consistent_with_collection = 2;\n    optional int64 collection_size = 3;\n}\n"
  },
  {
    "path": "protocol/proto/ConfigurationApplied.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage ConfigurationApplied {\n    optional int64 last_rcs_fetch_time = 1;\n    optional string installation_id = 2;\n    repeated int32 policy_group_ids = 3;\n    optional string configuration_assignment_id = 4;\n    optional string rc_client_id = 5;\n    optional string rc_client_version = 6;\n    optional string platform = 7;\n    optional string fetch_type = 8;\n}\n"
  },
  {
    "path": "protocol/proto/ConfigurationFetched.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage ConfigurationFetched {\n    optional int64 last_rcs_fetch_time = 1;\n    optional string installation_id = 2;\n    optional string configuration_assignment_id = 3;\n    optional string property_set_id = 4;\n    optional string attributes_set_id = 5;\n    optional string rc_client_id = 6;\n    optional string rc_client_version = 7;\n    optional string rc_sdk_version = 8;\n    optional string platform = 9;\n    optional string fetch_type = 10;\n    optional int64 latency = 11;\n    optional int64 payload_size = 12;\n    optional int32 status_code = 13;\n    optional string error_reason = 14;\n    optional string error_message = 15;\n    optional string error_reason_configuration_resolve = 16;\n    optional string error_message_configuration_resolve = 17;\n    optional string error_reason_account_attributes = 18;\n    optional string error_message_account_attributes = 19;\n    optional int32 error_code_account_attributes = 20;\n    optional int32 error_code_configuration_resolve = 21;\n}\n"
  },
  {
    "path": "protocol/proto/ConfigurationFetchedNonAuth.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage ConfigurationFetchedNonAuth {\n    optional int64 last_rcs_fetch_time = 1;\n    optional string installation_id = 2;\n    optional string configuration_assignment_id = 3;\n    optional string property_set_id = 4;\n    optional string attributes_set_id = 5;\n    optional string rc_client_id = 6;\n    optional string rc_client_version = 7;\n    optional string rc_sdk_version = 8;\n    optional string platform = 9;\n    optional string fetch_type = 10;\n    optional int64 latency = 11;\n    optional int64 payload_size = 12;\n    optional int32 status_code = 13;\n    optional string error_reason = 14;\n    optional string error_message = 15;\n    optional string error_reason_configuration_resolve = 16;\n    optional string error_message_configuration_resolve = 17;\n    optional string error_reason_account_attributes = 18;\n    optional string error_message_account_attributes = 19;\n    optional int32 error_code_account_attributes = 20;\n    optional int32 error_code_configuration_resolve = 21;\n}\n"
  },
  {
    "path": "protocol/proto/ConnectCredentialsRequest.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage ConnectCredentialsRequest {\n    optional string token_type = 1;\n    optional string client_id = 2;\n}\n"
  },
  {
    "path": "protocol/proto/ConnectDeviceDiscovered.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage ConnectDeviceDiscovered {\n    optional string device_id = 1;\n    optional string discover_method = 2;\n    optional string discovered_device_id = 3;\n    optional string discovered_device_type = 4;\n    optional string discovered_library_version = 5;\n    optional string discovered_brand_display_name = 6;\n    optional string discovered_model_display_name = 7;\n    optional string discovered_client_id = 8;\n    optional string discovered_product_id = 9;\n    optional string discovered_device_availablilty = 10;\n    optional string discovered_device_public_key = 11;\n    optional bool capabilities_resolved = 12;\n}\n"
  },
  {
    "path": "protocol/proto/ConnectDialError.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage ConnectDialError {\n    optional string type = 1;\n    optional string request = 2;\n    optional string response = 3;\n    optional int64 error = 4;\n    optional string context = 5;\n}\n"
  },
  {
    "path": "protocol/proto/ConnectMdnsPacketParseError.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage ConnectMdnsPacketParseError {\n    optional string type = 1;\n    optional string buffer = 2;\n    optional string ttl = 3;\n    optional string txt = 4;\n    optional string host = 5;\n    optional string discovery_name = 6;\n    optional string context = 7;\n}\n"
  },
  {
    "path": "protocol/proto/ConnectPullFailure.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage ConnectPullFailure {\n    optional bytes transfer_data = 1;\n    optional int64 error_code = 2;\n    map<string, string> reasons = 3;\n}\n"
  },
  {
    "path": "protocol/proto/ConnectTransferResult.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage ConnectTransferResult {\n    optional string result = 1;\n    optional string device_type = 2;\n    optional string discovery_class = 3;\n    optional string device_model = 4;\n    optional string device_brand = 5;\n    optional string device_software_version = 6;\n    optional int64 duration = 7;\n    optional string device_client_id = 8;\n    optional string transfer_intent_id = 9;\n    optional string transfer_debug_log = 10;\n    optional string error_code = 11;\n    optional int32 http_response_code = 12;\n    optional string initial_device_state = 13;\n    optional int32 retry_count = 14;\n    optional int32 login_retry_count = 15;\n    optional int64 login_duration = 16;\n    optional string target_device_id = 17;\n    optional bool target_device_is_local = 18;\n    optional string final_device_state = 19;\n}\n"
  },
  {
    "path": "protocol/proto/ConnectionError.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage ConnectionError {\n    optional int64 error_code = 1;\n    optional string ap = 2;\n    optional string proxy = 3;\n}\n"
  },
  {
    "path": "protocol/proto/ConnectionInfo.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage ConnectionInfo {\n    optional string ap = 1;\n    optional string proxy = 2;\n    optional bool user_initated_login = 3;\n    optional string reachability_type = 4;\n    optional string web_installer_unique_id = 5;\n    optional string ap_resolve_source = 6;\n    optional string address_type = 7;\n    optional bool ipv6_failed = 8;\n}\n"
  },
  {
    "path": "protocol/proto/ConnectionStateChange.proto",
    "content": "// Extracted from: Spotify 1.1.73.517 (macOS)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage ConnectionStateChange {\n    optional string type = 1;\n    optional string old = 2;\n    optional string new = 3;\n}\n"
  },
  {
    "path": "protocol/proto/DefaultConfigurationApplied.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage DefaultConfigurationApplied {\n    optional string installation_id = 1;\n    optional string configuration_assignment_id = 2;\n    optional string rc_client_id = 3;\n    optional string rc_client_version = 4;\n    optional string platform = 5;\n    optional string fetch_type = 6;\n    optional string reason = 7;\n}\n"
  },
  {
    "path": "protocol/proto/DesktopAuthenticationFailureNonAuth.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage DesktopAuthenticationFailureNonAuth {\n    optional string action_hash = 1;\n    optional string error_category = 2;\n    optional int32 error_code = 3;\n}\n"
  },
  {
    "path": "protocol/proto/DesktopAuthenticationSuccess.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage DesktopAuthenticationSuccess {\n    optional string action_hash = 1;\n}\n"
  },
  {
    "path": "protocol/proto/DesktopDeviceInformation.proto",
    "content": "// Extracted from: Spotify 1.1.73.517 (macOS)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage DesktopDeviceInformation {\n    optional string os_platform = 1;\n    optional string os_version = 2;\n    optional string computer_manufacturer = 3;\n    optional string mac_computer_model = 4;\n    optional string mac_computer_model_family = 5;\n    optional bool computer_has_internal_battery = 6;\n    optional bool computer_is_currently_running_on_battery_power = 7;\n    optional string mac_cpu_product_name = 8;\n    optional int64 mac_cpu_family_code = 9;\n    optional int64 cpu_num_physical_cores = 10;\n    optional int64 cpu_num_logical_cores = 11;\n    optional int64 cpu_clock_frequency_herz = 12;\n    optional int64 cpu_level_1_cache_size_bytes = 13;\n    optional int64 cpu_level_2_cache_size_bytes = 14;\n    optional int64 cpu_level_3_cache_size_bytes = 15;\n    optional bool cpu_is_64_bit_capable = 16;\n    optional int64 computer_ram_size_bytes = 17;\n    optional int64 computer_ram_speed_herz = 18;\n    optional int64 num_graphics_cards = 19;\n    optional int64 num_connected_screens = 20;\n    optional string app_screen_model_name = 21;\n    optional double app_screen_width_logical_points = 22;\n    optional double app_screen_height_logical_points = 23;\n    optional double mac_app_screen_scale_factor = 24;\n    optional double app_screen_physical_size_inches = 25;\n    optional int64 app_screen_bits_per_pixel = 26;\n    optional bool app_screen_supports_dci_p3_color_gamut = 27;\n    optional bool app_screen_is_built_in = 28;\n    optional string app_screen_graphics_card_model = 29;\n    optional int64 app_screen_graphics_card_vram_size_bytes = 30;\n    optional bool mac_app_screen_currently_contains_the_dock = 31;\n    optional bool mac_app_screen_currently_contains_active_menu_bar = 32;\n    optional bool boot_disk_is_known_ssd = 33;\n    optional string mac_boot_disk_connection_type = 34;\n    optional int64 boot_disk_capacity_bytes = 35;\n    optional int64 boot_disk_free_space_bytes = 36;\n    optional bool application_disk_is_same_as_boot_disk = 37;\n    optional bool application_disk_is_known_ssd = 38;\n    optional string mac_application_disk_connection_type = 39;\n    optional int64 application_disk_capacity_bytes = 40;\n    optional int64 application_disk_free_space_bytes = 41;\n    optional bool application_cache_disk_is_same_as_boot_disk = 42;\n    optional bool application_cache_disk_is_known_ssd = 43;\n    optional string mac_application_cache_disk_connection_type = 44;\n    optional int64 application_cache_disk_capacity_bytes = 45;\n    optional int64 application_cache_disk_free_space_bytes = 46;\n    optional bool has_pointing_device = 47;\n    optional bool has_builtin_pointing_device = 48;\n    optional bool has_touchpad = 49;\n    optional bool has_keyboard = 50;\n    optional bool has_builtin_keyboard = 51;\n    optional bool mac_has_touch_bar = 52;\n    optional bool has_touch_screen = 53;\n    optional bool has_pen_input = 54;\n    optional bool has_game_controller = 55;\n    optional bool has_bluetooth_support = 56;\n    optional int64 bluetooth_link_manager_version = 57;\n    optional string bluetooth_version_string = 58;\n    optional int64 num_audio_output_devices = 59;\n    optional string default_audio_output_device_name = 60;\n    optional string default_audio_output_device_manufacturer = 61;\n    optional double default_audio_output_device_current_sample_rate = 62;\n    optional int64 default_audio_output_device_current_bit_depth = 63;\n    optional int64 default_audio_output_device_current_buffer_size = 64;\n    optional int64 default_audio_output_device_current_num_channels = 65;\n    optional double default_audio_output_device_maximum_sample_rate = 66;\n    optional int64 default_audio_output_device_maximum_bit_depth = 67;\n    optional int64 default_audio_output_device_maximum_num_channels = 68;\n    optional bool default_audio_output_device_is_builtin = 69;\n    optional bool default_audio_output_device_is_virtual = 70;\n    optional string mac_default_audio_output_device_transport_type = 71;\n    optional string mac_default_audio_output_device_terminal_type = 72;\n    optional int64 num_video_capture_devices = 73;\n    optional string default_video_capture_device_manufacturer = 74;\n    optional string default_video_capture_device_model = 75;\n    optional string default_video_capture_device_name = 76;\n    optional int64 default_video_capture_device_image_width = 77;\n    optional int64 default_video_capture_device_image_height = 78;\n    optional string mac_default_video_capture_device_transport_type = 79;\n    optional bool default_video_capture_device_is_builtin = 80;\n    optional int64 num_active_network_interfaces = 81;\n    optional string mac_main_network_interface_name = 82;\n    optional string mac_main_network_interface_type = 83;\n    optional bool main_network_interface_supports_ipv4 = 84;\n    optional bool main_network_interface_supports_ipv6 = 85;\n    optional string main_network_interface_hardware_vendor = 86;\n    optional string main_network_interface_hardware_model = 87;\n    optional int64 main_network_interface_medium_speed_bps = 88;\n    optional int64 main_network_interface_link_speed_bps = 89;\n    optional double system_up_time_including_sleep_seconds = 90;\n    optional double system_up_time_awake_seconds = 91;\n    optional double app_up_time_including_sleep_seconds = 92;\n    optional string system_user_preferred_language_code = 93;\n    optional string system_user_preferred_locale = 94;\n    optional string mac_app_system_localization_language = 95;\n    optional string app_localization_language = 96;\n}\n"
  },
  {
    "path": "protocol/proto/DesktopGPUAccelerationInfo.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage DesktopGPUAccelerationInfo {\n    optional bool is_enabled = 1;\n}\n"
  },
  {
    "path": "protocol/proto/DesktopHighMemoryUsage.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage DesktopHighMemoryUsage {\n    optional bool is_continuation_event = 1;\n    optional double sample_time_interval_seconds = 2;\n    optional int64 win_committed_bytes = 3;\n    optional int64 win_peak_committed_bytes = 4;\n    optional int64 win_working_set_bytes = 5;\n    optional int64 win_peak_working_set_bytes = 6;\n    optional int64 mac_virtual_size_bytes = 7;\n    optional int64 mac_resident_size_bytes = 8;\n    optional int64 mac_footprint_bytes = 9;\n}\n"
  },
  {
    "path": "protocol/proto/DesktopPerformanceIssue.proto",
    "content": "// Extracted from: Spotify 1.1.73.517 (macOS)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage DesktopPerformanceIssue {\n    optional string event_type = 1;\n    optional bool is_continuation_event = 2;\n    optional double sample_time_interval_seconds = 3;\n    optional string computer_platform = 4;\n    optional double last_seen_main_thread_latency_seconds = 5;\n    optional double last_seen_core_thread_latency_seconds = 6;\n    optional double total_spotify_processes_cpu_load_percent = 7;\n    optional double main_process_cpu_load_percent = 8;\n    optional int64 mac_main_process_vm_size_bytes = 9;\n    optional int64 mac_main_process_resident_size_bytes = 10;\n    optional double mac_main_process_num_page_faults_per_second = 11;\n    optional double mac_main_process_num_pageins_per_second = 12;\n    optional double mac_main_process_num_cow_faults_per_second = 13;\n    optional double mac_main_process_num_context_switches_per_second = 14;\n    optional int64 main_process_num_total_threads = 15;\n    optional int64 main_process_num_running_threads = 16;\n    optional double renderer_process_cpu_load_percent = 17;\n    optional int64 mac_renderer_process_vm_size_bytes = 18;\n    optional int64 mac_renderer_process_resident_size_bytes = 19;\n    optional double mac_renderer_process_num_page_faults_per_second = 20;\n    optional double mac_renderer_process_num_pageins_per_second = 21;\n    optional double mac_renderer_process_num_cow_faults_per_second = 22;\n    optional double mac_renderer_process_num_context_switches_per_second = 23;\n    optional int64 renderer_process_num_total_threads = 24;\n    optional int64 renderer_process_num_running_threads = 25;\n    optional double system_total_cpu_load_percent = 26;\n    optional int64 mac_system_total_free_memory_size_bytes = 27;\n    optional int64 mac_system_total_active_memory_size_bytes = 28;\n    optional int64 mac_system_total_inactive_memory_size_bytes = 29;\n    optional int64 mac_system_total_wired_memory_size_bytes = 30;\n    optional int64 mac_system_total_compressed_memory_size_bytes = 31;\n    optional double mac_system_current_num_pageins_per_second = 32;\n    optional double mac_system_current_num_pageouts_per_second = 33;\n    optional double mac_system_current_num_page_faults_per_second = 34;\n    optional double mac_system_current_num_cow_faults_per_second = 35;\n    optional int64 system_current_num_total_processes = 36;\n    optional int64 system_current_num_total_threads = 37;\n    optional int64 computer_boot_disk_free_space_bytes = 38;\n    optional int64 application_disk_free_space_bytes = 39;\n    optional int64 application_cache_disk_free_space_bytes = 40;\n    optional bool computer_is_currently_running_on_battery_power = 41;\n    optional double computer_remaining_battery_capacity_percent = 42;\n    optional double computer_estimated_remaining_battery_time_seconds = 43;\n    optional int64 mac_computer_num_available_logical_cpu_cores_due_to_power_management = 44;\n    optional double mac_computer_current_processor_speed_percent_due_to_power_management = 45;\n    optional double mac_computer_current_cpu_time_limit_percent_due_to_power_management = 46;\n    optional double app_screen_width_points = 47;\n    optional double app_screen_height_points = 48;\n    optional double mac_app_screen_scale_factor = 49;\n    optional int64 app_screen_bits_per_pixel = 50;\n    optional bool app_screen_supports_dci_p3_color_gamut = 51;\n    optional bool app_screen_is_built_in = 52;\n    optional string app_screen_graphics_card_model = 53;\n    optional int64 app_screen_graphics_card_vram_size_bytes = 54;\n    optional double app_window_width_points = 55;\n    optional double app_window_height_points = 56;\n    optional double app_window_percentage_on_screen = 57;\n    optional double app_window_percentage_non_obscured = 58;\n    optional double system_up_time_including_sleep_seconds = 59;\n    optional double system_up_time_awake_seconds = 60;\n    optional double app_up_time_including_sleep_seconds = 61;\n    optional double computer_time_since_last_sleep_start_seconds = 62;\n    optional double computer_time_since_last_sleep_end_seconds = 63;\n    optional bool mac_system_user_session_is_currently_active = 64;\n    optional double mac_system_time_since_last_user_session_deactivation_seconds = 65;\n    optional double mac_system_time_since_last_user_session_reactivation_seconds = 66;\n    optional bool application_is_currently_active = 67;\n    optional bool application_window_is_currently_visible = 68;\n    optional bool mac_application_window_is_currently_minimized = 69;\n    optional bool application_window_is_currently_fullscreen = 70;\n    optional bool mac_application_is_currently_hidden = 71;\n    optional bool application_user_is_currently_logged_in = 72;\n    optional double application_time_since_last_user_log_in = 73;\n    optional double application_time_since_last_user_log_out = 74;\n    optional bool application_is_playing_now = 75;\n    optional string application_currently_playing_type = 76;\n    optional string application_currently_playing_uri = 77;\n    optional string application_currently_playing_ad_id = 78;\n}\n"
  },
  {
    "path": "protocol/proto/DesktopUpdateDownloadComplete.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage DesktopUpdateDownloadComplete {\n    optional int64 revision = 1;\n    optional bool is_critical = 2;\n    optional string source = 3;\n    optional bool is_successful = 4;\n    optional bool is_employee = 5;\n}\n"
  },
  {
    "path": "protocol/proto/DesktopUpdateDownloadError.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage DesktopUpdateDownloadError {\n    optional int64 revision = 1;\n    optional bool is_critical = 2;\n    optional string error_message = 3;\n    optional string source = 4;\n    optional bool is_employee = 5;\n}\n"
  },
  {
    "path": "protocol/proto/DesktopUpdateMessageAction.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage DesktopUpdateMessageAction {\n    optional bool will_download = 1;\n    optional int64 this_message_from_revision = 2;\n    optional int64 this_message_to_revision = 3;\n    optional bool is_critical = 4;\n    optional int64 already_downloaded_from_revision = 5;\n    optional int64 already_downloaded_to_revision = 6;\n    optional string source = 7;\n    optional bool is_employee = 8;\n}\n"
  },
  {
    "path": "protocol/proto/DesktopUpdateMessageProcessed.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage DesktopUpdateMessageProcessed {\n    optional bool success = 1;\n    optional string source = 2;\n    optional int64 revision = 3;\n    optional bool is_critical = 4;\n    optional string binary_hash = 5;\n    optional bool is_employee = 6;\n}\n"
  },
  {
    "path": "protocol/proto/DesktopUpdateResponse.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage DesktopUpdateResponse {\n    optional int64 status_code = 1;\n    optional int64 request_time_ms = 2;\n    optional int64 payload_size = 3;\n    optional bool is_employee = 4;\n    optional string error_message = 5;\n}\n"
  },
  {
    "path": "protocol/proto/Download.proto",
    "content": "// Extracted from: Spotify 1.1.73.517 (macOS)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage Download {\n    optional bytes file_id = 1;\n    optional bytes playback_id = 2;\n    optional int64 bytes_from_ap = 3;\n    optional int64 waste_from_ap = 4;\n    optional int64 reqs_from_ap = 5;\n    optional int64 error_from_ap = 6;\n    optional int64 bytes_from_cdn = 7;\n    optional int64 waste_from_cdn = 8;\n    optional int64 bytes_from_cache = 9;\n    optional int64 content_size = 10;\n    optional string content_type = 11;\n    optional int64 ap_initial_latency = 12;\n    optional int64 ap_max_latency = 13;\n    optional int64 ap_min_latency = 14;\n    optional double ap_avg_latency = 15;\n    optional int64 ap_median_latency = 16;\n    optional double ap_avg_bw = 17;\n    optional int64 cdn_initial_latency = 18;\n    optional int64 cdn_max_latency = 19;\n    optional int64 cdn_min_latency = 20;\n    optional double cdn_avg_latency = 21;\n    optional int64 cdn_median_latency = 22;\n    optional int64 cdn_64k_initial_latency = 23;\n    optional int64 cdn_64k_max_latency = 24;\n    optional int64 cdn_64k_min_latency = 25;\n    optional double cdn_64k_avg_latency = 26;\n    optional int64 cdn_64k_median_latency = 27;\n    optional double cdn_avg_bw = 28;\n    optional double cdn_initial_bw_estimate = 29;\n    optional string cdn_uri_scheme = 30;\n    optional string cdn_domain = 31;\n    optional string cdn_socket_reuse = 32;\n    optional int64 num_cache_error = 33;\n    optional int64 bytes_from_carrier = 34;\n    optional int64 bytes_from_unknown = 35;\n    optional int64 bytes_from_wifi = 36;\n    optional int64 bytes_from_ethernet = 37;\n    optional string request_type = 38;\n    optional int64 total_time = 39;\n    optional int64 bitrate = 40;\n    optional int64 reqs_from_cdn = 41;\n    optional int64 error_from_cdn = 42;\n    optional string file_origin = 43;\n    optional string initial_disk_state = 44;\n    optional bool locked = 45;\n}\n"
  },
  {
    "path": "protocol/proto/DrmRequestFailure.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage DrmRequestFailure {\n    optional string reason = 1;\n    optional int64 error_code = 2;\n    optional bool fatal = 3;\n    optional bytes playback_id = 4;\n}\n"
  },
  {
    "path": "protocol/proto/EndAd.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage EndAd {\n    optional bytes file_id = 1;\n    optional bytes playback_id = 2;\n    optional bytes song_id = 3;\n    optional string source_start = 4;\n    optional string reason_start = 5;\n    optional string source_end = 6;\n    optional string reason_end = 7;\n    optional int64 bytes_played = 8;\n    optional int64 bytes_in_song = 9;\n    optional int64 ms_played = 10;\n    optional int64 ms_total_est = 11;\n    optional int64 ms_rcv_latency = 12;\n    optional int64 n_seekback = 13;\n    optional int64 ms_seekback = 14;\n    optional int64 n_seekfwd = 15;\n    optional int64 ms_seekfwd = 16;\n    optional int64 ms_latency = 17;\n    optional int64 n_stutter = 18;\n    optional int64 p_lowbuffer = 19;\n    optional bool skipped = 20;\n    optional bool ad_clicked = 21;\n    optional string token = 22;\n    optional int64 client_ad_count = 23;\n    optional int64 client_campaign_count = 24;\n}\n"
  },
  {
    "path": "protocol/proto/EventSenderInternalErrorNonAuth.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage EventSenderInternalErrorNonAuth {\n    optional string error_message = 1;\n    optional string error_type = 2;\n    optional string error_context = 3;\n    optional int32 error_code = 4;\n}\n"
  },
  {
    "path": "protocol/proto/EventSenderStats.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage EventSenderStats {\n    map<string, int64> storage_size = 1;\n    map<string, int64> sequence_number_min = 2;\n    map<string, int64> sequence_number_next = 3;\n}\n"
  },
  {
    "path": "protocol/proto/EventSenderStats2NonAuth.proto",
    "content": "// Extracted from: Spotify 1.1.73.517 (macOS)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage EventSenderStats2NonAuth {\n    repeated bytes sequence_ids = 1;\n    repeated string event_names = 2;\n    repeated int32 loss_stats_num_entries_per_sequence_id = 3;\n    repeated int32 loss_stats_event_name_index = 4;\n    repeated int64 loss_stats_storage_sizes = 5;\n    repeated int64 loss_stats_sequence_number_mins = 6;\n    repeated int64 loss_stats_sequence_number_nexts = 7;\n    repeated int32 ratelimiter_stats_event_name_index = 8;\n    repeated int64 ratelimiter_stats_drop_count = 9;\n    repeated int32 drop_list_num_entries_per_sequence_id = 10;\n    repeated int32 drop_list_event_name_index = 11;\n    repeated int64 drop_list_counts_total = 12;\n    repeated int64 drop_list_counts_unreported = 13;\n}\n"
  },
  {
    "path": "protocol/proto/ExternalDeviceInfo.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage ExternalDeviceInfo {\n    optional string type = 1;\n    optional string subtype = 2;\n    optional string reason = 3;\n    optional bool taken_over = 4;\n    optional int64 num_tracks = 5;\n    optional int64 num_purchased_tracks = 6;\n    optional int64 num_playlists = 7;\n    optional string error = 8;\n    optional bool full = 9;\n    optional bool sync_all = 10;\n}\n"
  },
  {
    "path": "protocol/proto/GetInfoFailures.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage GetInfoFailures {\n    optional string device_id = 1;\n    optional int64 error_code = 2;\n    optional string request = 3;\n    optional string response_body = 4;\n    optional string context = 5;\n}\n"
  },
  {
    "path": "protocol/proto/HeadFileDownload.proto",
    "content": "// Extracted from: Spotify 1.1.73.517 (macOS)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage HeadFileDownload {\n    optional bytes file_id = 1;\n    optional bytes playback_id = 2;\n    optional string cdn_uri_scheme = 3;\n    optional string cdn_domain = 4;\n    optional int64 head_file_size = 5;\n    optional int64 bytes_downloaded = 6;\n    optional int64 bytes_wasted = 7;\n    optional int64 http_latency = 8;\n    optional int64 http_64k_latency = 9;\n    optional int64 total_time = 10;\n    optional int64 http_result = 11;\n    optional int64 error_code = 12;\n    optional int64 cached_bytes = 13;\n    optional int64 bytes_from_cache = 14;\n    optional string socket_reuse = 15;\n    optional string request_type = 16;\n    optional string initial_disk_state = 17;\n}\n"
  },
  {
    "path": "protocol/proto/LegacyEndSong.proto",
    "content": "// Extracted from: Spotify 1.1.73.517 (macOS)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage LegacyEndSong {\n    optional int64 sequence_number = 1;\n    optional string sequence_id = 2;\n    optional bytes playback_id = 3;\n    optional bytes parent_playback_id = 4;\n    optional string source_start = 5;\n    optional string reason_start = 6;\n    optional string source_end = 7;\n    optional string reason_end = 8;\n    optional int64 bytes_played = 9;\n    optional int64 bytes_in_song = 10;\n    optional int64 ms_played = 11;\n    optional int64 ms_nominal_played = 12;\n    optional int64 ms_total_est = 13;\n    optional int64 ms_rcv_latency = 14;\n    optional int64 ms_overlapping = 15;\n    optional int64 n_seekback = 16;\n    optional int64 ms_seekback = 17;\n    optional int64 n_seekfwd = 18;\n    optional int64 ms_seekfwd = 19;\n    optional int64 ms_latency = 20;\n    optional int64 ui_latency = 21;\n    optional string player_id = 22;\n    optional int64 ms_key_latency = 23;\n    optional bool offline_key = 24;\n    optional bool cached_key = 25;\n    optional int64 n_stutter = 26;\n    optional int64 p_lowbuffer = 27;\n    optional bool shuffle = 28;\n    optional int64 max_continous = 29;\n    optional int64 union_played = 30;\n    optional int64 artificial_delay = 31;\n    optional int64 bitrate = 32;\n    optional string play_context = 33;\n    optional string audiocodec = 34;\n    optional string play_track = 35;\n    optional string display_track = 36;\n    optional bool offline = 37;\n    optional int64 offline_timestamp = 38;\n    optional bool incognito_mode = 39;\n    optional string provider = 40;\n    optional string referer = 41;\n    optional string referrer_version = 42;\n    optional string referrer_vendor = 43;\n    optional string transition = 44;\n    optional string streaming_rule = 45;\n    optional string gaia_dev_id = 46;\n    optional string accepted_tc = 47;\n    optional string promotion_type = 48;\n    optional string page_instance_id = 49;\n    optional string interaction_id = 50;\n    optional string parent_play_track = 51;\n    optional int64 core_version = 52;\n}\n"
  },
  {
    "path": "protocol/proto/LocalFileSyncError.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage LocalFileSyncError {\n    optional string error = 1;\n}\n"
  },
  {
    "path": "protocol/proto/LocalFilesError.proto",
    "content": "// Extracted from: Spotify 1.1.73.517 (macOS)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage LocalFilesError {\n    optional int64 error_code = 1;\n    optional string context = 2;\n    optional string info = 3;\n}\n"
  },
  {
    "path": "protocol/proto/LocalFilesImport.proto",
    "content": "// Extracted from: Spotify 1.1.73.517 (macOS)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage LocalFilesImport {\n    optional int64 tracks = 1;\n    optional int64 duplicate_tracks = 2;\n    optional int64 failed_tracks = 3;\n    optional int64 matched_tracks = 4;\n    optional string source = 5;\n    optional int64 invalid_tracks = 6;\n}\n"
  },
  {
    "path": "protocol/proto/LocalFilesReport.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage LocalFilesReport {\n    optional int64 total_tracks = 1;\n    optional int64 total_size = 2;\n    optional int64 owned_tracks = 3;\n    optional int64 owned_size = 4;\n    optional int64 tracks_not_found = 5;\n    optional int64 tracks_bad_format = 6;\n    optional int64 tracks_drm_protected = 7;\n    optional int64 tracks_unknown_pruned = 8;\n    optional int64 tracks_reallocated_repaired = 9;\n    optional int64 enabled_sources = 10;\n}\n"
  },
  {
    "path": "protocol/proto/LocalFilesSourceReport.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage LocalFilesSourceReport {\n    optional string id = 1;\n    optional int64 tracks = 2;\n}\n"
  },
  {
    "path": "protocol/proto/MdnsLoginFailures.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage MdnsLoginFailures {\n    optional string device_id = 1;\n    optional int64 error_code = 2;\n    optional string response_body = 3;\n    optional string request = 4;\n    optional int64 esdk_internal_error_code = 5;\n    optional string context = 6;\n}\n"
  },
  {
    "path": "protocol/proto/MetadataExtensionClientStatistic.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage MetadataExtensionClientStatistic {\n    optional bytes task_id = 1;\n    optional string feature_id = 2;\n    optional bool is_online_param = 3;\n    optional int32 num_extensions_with_etags = 4;\n    optional int32 num_extensions_requested = 5;\n    optional int32 num_extensions_needed = 6;\n    optional int32 num_uris_requested = 7;\n    optional int32 num_uris_needed = 8;\n    optional int32 num_prepared_requests = 9;\n    optional int32 num_sent_requests = 10;\n}\n"
  },
  {
    "path": "protocol/proto/Offline2ClientError.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage Offline2ClientError {\n    optional string error = 1;\n    optional string device_id = 2;\n    optional string cache_id = 3;\n}\n"
  },
  {
    "path": "protocol/proto/Offline2ClientEvent.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage Offline2ClientEvent {\n    optional string event = 1;\n    optional string device_id = 2;\n    optional string cache_id = 3;\n}\n"
  },
  {
    "path": "protocol/proto/OfflineError.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage OfflineError {\n    optional int64 error_code = 1;\n    optional string track = 2;\n}\n"
  },
  {
    "path": "protocol/proto/OfflineEvent.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage OfflineEvent {\n    optional string event = 1;\n    optional string data = 2;\n}\n"
  },
  {
    "path": "protocol/proto/OfflineReport.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage OfflineReport {\n    optional int64 total_num_tracks = 1;\n    optional int64 num_downloaded_tracks = 2;\n    optional int64 num_downloaded_tracks_keyless = 3;\n    optional int64 total_num_links = 4;\n    optional int64 total_num_links_keyless = 5;\n    map<string, int64> context_num_links_map = 6;\n    map<string, int64> linktype_num_tracks_map = 7;\n    optional int64 track_limit = 8;\n    optional int64 expiry = 9;\n    optional string change_reason = 10;\n    optional int64 offline_keys = 11;\n    optional int64 cached_keys = 12;\n    optional int64 total_num_episodes = 13;\n    optional int64 num_downloaded_episodes = 14;\n    optional int64 episode_limit = 15;\n    optional int64 episode_expiry = 16;\n}\n"
  },
  {
    "path": "protocol/proto/PlaybackError.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage PlaybackError {\n    optional bytes file_id = 1;\n    optional bytes playback_id = 2;\n    optional string track_id = 3;\n    optional int64 bitrate = 4;\n    optional int64 error_code = 5;\n    optional bool fatal = 6;\n    optional string audiocodec = 7;\n    optional bool external_track = 8;\n    optional int64 position_ms = 9;\n}\n"
  },
  {
    "path": "protocol/proto/PlaybackRetry.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage PlaybackRetry {\n    optional string track = 1;\n    optional bytes playback_id = 2;\n    optional string method = 3;\n    optional string status = 4;\n    optional string reason = 5;\n}\n"
  },
  {
    "path": "protocol/proto/PlaybackSegments.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage PlaybackSegments {\n    optional bytes playback_id = 1;\n    optional string track_uri = 2;\n    optional bool overflow = 3;\n    optional string segments = 4;\n}\n"
  },
  {
    "path": "protocol/proto/PlayerStateRestore.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage PlayerStateRestore {\n    optional string error = 1;\n    optional int64 size = 2;\n    optional string context_uri = 3;\n    optional string state = 4;\n}\n"
  },
  {
    "path": "protocol/proto/PlaylistSyncEvent.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage PlaylistSyncEvent {\n    optional string playlist_id = 1;\n    optional bool is_playlist = 2;\n    optional int64 timestamp_ms = 3;\n    optional int32 error_code = 4;\n    optional string event_description = 5;\n}\n"
  },
  {
    "path": "protocol/proto/PodcastAdSegmentReceived.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage PodcastAdSegmentReceived {\n    optional string episode_uri = 1;\n    optional string playback_id = 2;\n    optional string slots = 3;\n    optional bool is_audio = 4;\n}\n"
  },
  {
    "path": "protocol/proto/Prefetch.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage Prefetch {\n    optional int64 strategies = 1;\n    optional int64 strategy = 2;\n    optional bytes file_id = 3;\n    optional string track = 4;\n    optional int64 prefetch_index = 5;\n    optional int64 current_window_size = 6;\n    optional int64 max_window_size = 7;\n}\n"
  },
  {
    "path": "protocol/proto/PrefetchError.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage PrefetchError {\n    optional int64 strategy = 1;\n    optional string description = 2;\n}\n"
  },
  {
    "path": "protocol/proto/ProductStateUcsVerification.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage ProductStateUcsVerification {\n    map<string, string> additional_entries = 1;\n    map<string, string> missing_entries = 2;\n    optional string fetch_type = 3;\n}\n"
  },
  {
    "path": "protocol/proto/PubSubCountPerIdent.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage PubSubCountPerIdent {\n    optional string ident_filter = 1;\n    optional int32 no_of_messages_received = 2;\n    optional int32 no_of_failed_conversions = 3;\n}\n"
  },
  {
    "path": "protocol/proto/RawCoreStream.proto",
    "content": "// Extracted from: Spotify 1.1.73.517 (macOS)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage RawCoreStream {\n    optional bytes playback_id = 1;\n    optional bytes parent_playback_id = 2;\n    optional string video_session_id = 3;\n    optional bytes media_id = 4;\n    optional string media_type = 5;\n    optional string feature_identifier = 6;\n    optional string feature_version = 7;\n    optional string view_uri = 8;\n    optional string source_start = 9;\n    optional string reason_start = 10;\n    optional string source_end = 11;\n    optional string reason_end = 12;\n    optional int64 playback_start_time = 13;\n    optional int32 ms_played = 14;\n    optional int32 ms_played_nominal = 15;\n    optional int32 ms_played_overlapping = 16;\n    optional int32 ms_played_video = 17;\n    optional int32 ms_played_background = 18;\n    optional int32 ms_played_fullscreen = 19;\n    optional bool live = 20;\n    optional bool shuffle = 21;\n    optional string audio_format = 22;\n    optional string play_context = 23;\n    optional string content_uri = 24;\n    optional string displayed_content_uri = 25;\n    optional bool content_is_downloaded = 26;\n    optional bool incognito_mode = 27;\n    optional string provider = 28;\n    optional string referrer = 29;\n    optional string referrer_version = 30;\n    optional string referrer_vendor = 31;\n    optional string streaming_rule = 32;\n    optional string connect_controller_device_id = 33;\n    optional string page_instance_id = 34;\n    optional string interaction_id = 35;\n    optional string parent_content_uri = 36;\n    optional int64 core_version = 37;\n    optional string core_bundle = 38;\n    optional bool is_assumed_premium = 39;\n    optional int32 ms_played_external = 40;\n    optional string local_content_uri = 41;\n    optional bool client_offline_at_stream_start = 42;\n}\n"
  },
  {
    "path": "protocol/proto/ReachabilityChanged.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage ReachabilityChanged {\n    optional string type = 1;\n    optional string info = 2;\n}\n"
  },
  {
    "path": "protocol/proto/RejectedClientEventNonAuth.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage RejectedClientEventNonAuth {\n    optional string reject_reason = 1;\n    optional string event_name = 2;\n}\n"
  },
  {
    "path": "protocol/proto/RemainingSkips.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage RemainingSkips {\n    optional string interaction_id = 1;\n    optional int32 remaining_skips_before_skip = 2;\n    optional int32 remaining_skips_after_skip = 3;\n    repeated string interaction_ids = 4;\n}\n"
  },
  {
    "path": "protocol/proto/RequestAccounting.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage RequestAccounting {\n    optional string request = 1;\n    optional int64 downloaded = 2;\n    optional int64 uploaded = 3;\n    optional int64 num_requests = 4;\n    optional string connection = 5;\n    optional string source_identifier = 6;\n    optional string reason = 7;\n    optional int64 duration_ms = 8;\n}\n"
  },
  {
    "path": "protocol/proto/RequestTime.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage RequestTime {\n    optional string type = 1;\n    optional int64 first_byte = 2;\n    optional int64 last_byte = 3;\n    optional int64 size = 4;\n    optional int64 size_sent = 5;\n    optional bool error = 6;\n    optional string url = 7;\n    optional string verb = 8;\n    optional int64 payload_size_sent = 9;\n    optional int32 connection_reuse = 10;\n    optional double sampling_probability = 11;\n    optional bool cached = 12;\n}\n"
  },
  {
    "path": "protocol/proto/StartTrack.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage StartTrack {\n    optional bytes playback_id = 1;\n    optional string context_player_session_id = 2;\n    optional int64 timestamp = 3;\n}\n"
  },
  {
    "path": "protocol/proto/Stutter.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage Stutter {\n    optional bytes file_id = 1;\n    optional bytes playback_id = 2;\n    optional string track = 3;\n    optional int64 buffer_size = 4;\n    optional int64 max_buffer_size = 5;\n    optional int64 file_byte_offset = 6;\n    optional int64 file_byte_total = 7;\n    optional int64 target_buffer = 8;\n    optional string audio_driver = 9;\n}\n"
  },
  {
    "path": "protocol/proto/TierFeatureFlags.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage TierFeatureFlags {\n    optional bool ads = 1;\n    optional bool high_quality = 2;\n    optional bool offline = 3;\n    optional bool on_demand = 4;\n    optional string max_album_plays_consecutive = 5;\n    optional string max_album_plays_per_hour = 6;\n    optional string max_skips_per_hour = 7;\n    optional string max_track_plays_per_hour = 8;\n}\n"
  },
  {
    "path": "protocol/proto/TrackNotPlayed.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage TrackNotPlayed {\n    optional bytes playback_id = 1;\n    optional string source_start = 2;\n    optional string reason_start = 3;\n    optional string source_end = 4;\n    optional string reason_end = 5;\n    optional string play_context = 6;\n    optional string play_track = 7;\n    optional string display_track = 8;\n    optional string provider = 9;\n    optional string referer = 10;\n    optional string referrer_version = 11;\n    optional string referrer_vendor = 12;\n    optional string gaia_dev_id = 13;\n    optional string reason_not_played = 14;\n}\n"
  },
  {
    "path": "protocol/proto/TrackStuck.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage TrackStuck {\n    optional string track = 1;\n    optional bytes playback_id = 2;\n    optional string source_start = 3;\n    optional string reason_start = 4;\n    optional bool offline = 5;\n    optional int64 position = 6;\n    optional int64 count = 7;\n    optional string audio_driver = 8;\n}\n"
  },
  {
    "path": "protocol/proto/WindowSize.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage WindowSize {\n    optional int64 width = 1;\n    optional int64 height = 2;\n    optional int64 mode = 3;\n    optional int64 duration = 4;\n}\n"
  },
  {
    "path": "protocol/proto/apiv1.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.offline.proto;\n\nimport \"google/protobuf/timestamp.proto\";\nimport \"offline.proto\";\n\noption optimize_for = CODE_SIZE;\n\nmessage ListDevicesRequest {\n    string user_id = 1;\n}\n\nmessage ListDevicesResponse {\n    repeated Device devices = 1;\n}\n\nmessage PutDeviceRequest {\n    string user_id = 1;\n\n    Body body = 2;\n    message Body {\n        Device device = 1;\n    }\n}\n\nmessage BasicDeviceRequest {\n    DeviceKey key = 1;\n}\n\nmessage GetDeviceResponse {\n    Device device = 1;\n}\n\nmessage RemoveDeviceRequest {\n    DeviceKey key = 1;\n    bool is_force_remove = 2;\n}\n\nmessage OfflineEnableDeviceRequest {\n    message Body {\n      bool auto_opc = 1;\n    }\n\n    DeviceKey key = 1;\n    Body body = 2;\n    string name = 9;\n    int32 platform = 7;\n    string client_id = 8;\n}\n\nmessage OfflineEnableDeviceResponse {\n    enum StatusCode {\n      UNKNOWN = 0;\n      OK = 1;\n      DEVICE_LIMIT_REACHED = 2;\n    }\n\n    Restrictions restrictions = 1;\n    StatusCode status_code = 2;\n}\n\nmessage ListResourcesResponse {\n    repeated Resource resources = 1;\n    google.protobuf.Timestamp server_time = 2;\n}\n\nmessage WriteResourcesRequest {\n    DeviceKey key = 1;\n\n    Body body = 2;\n    message Body {\n        repeated ResourceOperation operations = 1;\n        string source_device_id = 2;\n        string source_cache_id = 3;\n    }\n}\n\nmessage ResourcesUpdate {\n    string source_device_id = 1;\n    string source_cache_id = 2;\n}\n\nmessage DeltaResourcesRequest {\n    DeviceKey key = 1;\n\n    Body body = 2;\n    message Body {\n        google.protobuf.Timestamp last_known_server_time = 1;\n    }\n}\n\nmessage DeltaResourcesResponse {\n    bool delta_update_possible = 1;\n    repeated ResourceOperation operations = 2;\n    google.protobuf.Timestamp server_time = 3;\n}\n\nmessage GetResourceRequest {\n    DeviceKey key = 1;\n    string uri = 2;\n}\n\nmessage GetResourceResponse {\n    Resource resource = 1;\n}\n\nmessage WriteResourcesDetailsRequest {\n    DeviceKey key = 1;\n\n    Body body = 2;\n    message Body {\n        repeated Resource resources = 1;\n    }\n}\n\nmessage GetResourceForDevicesRequest {\n    string user_id = 1;\n    string uri = 2;\n}\n\nmessage GetResourceForDevicesResponse {\n    repeated Device devices = 1;\n    repeated ResourceForDevice resources = 2;\n}\n\nmessage ListDevicesWithResourceRequest {\n    message Body {\n        string uri = 1;\n    }\n\n    string user_id = 1;\n    string username = 2;\n    Body body = 3;\n}\n\nmessage ListDevicesWithResourceResponse {\n    message DeviceWithResource {\n        Device device = 1;\n        bool is_supported = 2;\n        optional Resource resource = 3;\n    }\n\n    repeated DeviceWithResource deviceWithResource = 1;\n    FetchStrategy fetch_strategy = 2;\n}\n\nmessage FetchStrategy {\n    oneof fetch_strategy {\n        PollStrategy poll_strategy = 1;\n        SubStrategy sub_strategy = 2;\n    }\n}\n\nmessage PollStrategy {\n    int32 interval_ms = 1;\n}\n\nmessage SubStrategy {\n}\n\n"
  },
  {
    "path": "protocol/proto/app_state.proto",
    "content": "// Extracted from: Spotify 1.1.73.517 (macOS)\n\nsyntax = \"proto3\";\n\npackage spotify.offline.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage AppStateRequest {\n    AppState state = 1;\n}\n\nenum AppState {\n    UNKNOWN = 0;\n    BACKGROUND = 1;\n    FOREGROUND = 2;\n}\n"
  },
  {
    "path": "protocol/proto/audio_files_extension.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.extendedmetadata.audiofiles;\n\nimport \"metadata.proto\";\n\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.audiophile.proto\";\n\nmessage NormalizationParams {\n    float loudness_db = 1;\n    float true_peak_db = 2;\n}\n\nmessage ExtendedAudioFile {\n    reserved 2;\n    reserved 3;\n    metadata.AudioFile file = 1;\n    int32 average_bitrate = 4;\n}\n\nmessage AudioFilesExtensionResponse {\n    repeated ExtendedAudioFile files = 1;\n    NormalizationParams default_file_normalization_params = 2;\n    NormalizationParams default_album_normalization_params = 3;\n    bytes audio_id = 4;\n}\n"
  },
  {
    "path": "protocol/proto/audio_format.proto",
    "content": "syntax = \"proto3\";\n\npackage spotify.stream_reporting_esperanto.proto;\n\noption java_package = \"com.spotify.stream_reporting_esperanto.proto\";\noption objc_class_prefix = \"ESP\";\n\nenum AudioFormat {\n  FORMAT_UNKNOWN = 0;\n  FORMAT_OGG_VORBIS_96 = 1;\n  FORMAT_OGG_VORBIS_160 = 2;\n  FORMAT_OGG_VORBIS_320 = 3;\n  FORMAT_MP3_256 = 4;\n  FORMAT_MP3_320 = 5;\n  FORMAT_MP3_160 = 6;\n  FORMAT_MP3_96 = 7;\n  FORMAT_MP3_160_ENCRYPTED = 8;\n  FORMAT_AAC_24 = 9;\n  FORMAT_AAC_48 = 10;\n  FORMAT_MP4_128 = 11;\n  FORMAT_MP4_128_DUAL = 12;\n  FORMAT_MP4_128_CBCS = 13;\n  FORMAT_MP4_256 = 14;\n  FORMAT_MP4_256_DUAL = 15;\n  FORMAT_MP4_256_CBCS = 16;\n  FORMAT_FLAC_FLAC = 17;\n  FORMAT_MP4_FLAC = 18;\n  FORMAT_MP4_Unknown = 19;\n  FORMAT_MP3_Unknown = 20;\n  FORMAT_XHE_AAC_12 = 21;\n  FORMAT_XHE_AAC_16 = 22;\n  FORMAT_XHE_AAC_24 = 23;\n  FORMAT_FLAC_FLAC_24 = 24;\n}\n\n"
  },
  {
    "path": "protocol/proto/authentication.proto",
    "content": "syntax = \"proto2\";\n\nmessage ClientResponseEncrypted {\n    required LoginCredentials login_credentials = 0xa; \n    optional AccountCreation account_creation = 0x14; \n    optional FingerprintResponseUnion fingerprint_response = 0x1e; \n    optional PeerTicketUnion peer_ticket = 0x28; \n    required SystemInfo system_info = 0x32; \n    optional string platform_model = 0x3c; \n    optional string version_string = 0x46; \n    optional LibspotifyAppKey appkey = 0x50; \n    optional ClientInfo client_info = 0x5a; \n}\n\nmessage LoginCredentials {\n    optional string username = 0xa; \n    required AuthenticationType typ = 0x14; \n    optional bytes auth_data = 0x1e; \n}\n\nenum AuthenticationType {\n    AUTHENTICATION_USER_PASS = 0x0;\n    AUTHENTICATION_STORED_SPOTIFY_CREDENTIALS = 0x1;\n    AUTHENTICATION_STORED_FACEBOOK_CREDENTIALS = 0x2;\n    AUTHENTICATION_SPOTIFY_TOKEN = 0x3;\n    AUTHENTICATION_FACEBOOK_TOKEN = 0x4;\n}\n\nenum AccountCreation {\n    ACCOUNT_CREATION_ALWAYS_PROMPT = 0x1;\n    ACCOUNT_CREATION_ALWAYS_CREATE = 0x3;\n}\n\nmessage FingerprintResponseUnion {\n    optional FingerprintGrainResponse grain = 0xa; \n    optional FingerprintHmacRipemdResponse hmac_ripemd = 0x14; \n}\n\nmessage FingerprintGrainResponse {\n    required bytes encrypted_key = 0xa; \n}\n\nmessage FingerprintHmacRipemdResponse {\n    required bytes hmac = 0xa; \n}\n\nmessage PeerTicketUnion {\n    optional PeerTicketPublicKey public_key = 0xa; \n    optional PeerTicketOld old_ticket = 0x14; \n}\n\nmessage PeerTicketPublicKey {\n    required bytes public_key = 0xa; \n}\n\nmessage PeerTicketOld {\n    required bytes peer_ticket = 0xa; \n    required bytes peer_ticket_signature = 0x14; \n}\n\nmessage SystemInfo {\n    required CpuFamily cpu_family = 0xa; \n    optional uint32 cpu_subtype = 0x14; \n    optional uint32 cpu_ext = 0x1e; \n    optional Brand brand = 0x28; \n    optional uint32 brand_flags = 0x32; \n    required Os os = 0x3c; \n    optional uint32 os_version = 0x46; \n    optional uint32 os_ext = 0x50; \n    optional string system_information_string = 0x5a; \n    optional string device_id = 0x64; \n}\n\nenum CpuFamily {\n    CPU_UNKNOWN = 0x0;\n    CPU_X86 = 0x1;\n    CPU_X86_64 = 0x2;\n    CPU_PPC = 0x3;\n    CPU_PPC_64 = 0x4;\n    CPU_ARM = 0x5;\n    CPU_IA64 = 0x6;\n    CPU_SH = 0x7;\n    CPU_MIPS = 0x8;\n    CPU_BLACKFIN = 0x9;\n}\n\nenum Brand {\n    BRAND_UNBRANDED = 0x0;\n    BRAND_INQ = 0x1;\n    BRAND_HTC = 0x2;\n    BRAND_NOKIA = 0x3;\n}\n\nenum Os {\n    OS_UNKNOWN = 0x0;\n    OS_WINDOWS = 0x1;\n    OS_OSX = 0x2;\n    OS_IPHONE = 0x3;\n    OS_S60 = 0x4;\n    OS_LINUX = 0x5;\n    OS_WINDOWS_CE = 0x6;\n    OS_ANDROID = 0x7;\n    OS_PALM = 0x8;\n    OS_FREEBSD = 0x9;\n    OS_BLACKBERRY = 0xa;\n    OS_SONOS = 0xb;\n    OS_LOGITECH = 0xc;\n    OS_WP7 = 0xd;\n    OS_ONKYO = 0xe;\n    OS_PHILIPS = 0xf;\n    OS_WD = 0x10;\n    OS_VOLVO = 0x11;\n    OS_TIVO = 0x12;\n    OS_AWOX = 0x13;\n    OS_MEEGO = 0x14;\n    OS_QNXNTO = 0x15;\n    OS_BCO = 0x16;\n}\n\nmessage LibspotifyAppKey {\n    required uint32 version = 0x1; \n    required bytes devkey = 0x2; \n    required bytes signature = 0x3; \n    required string useragent = 0x4; \n    required bytes callback_hash = 0x5; \n}\n\nmessage ClientInfo {\n    optional bool limited = 0x1; \n    optional ClientInfoFacebook fb = 0x2; \n    optional string language = 0x3; \n}\n\nmessage ClientInfoFacebook {\n    optional string machine_id = 0x1; \n}\n\nmessage APWelcome {\n    required string canonical_username = 0xa;\n    required AccountType account_type_logged_in = 0x14;\n    required AccountType credentials_type_logged_in = 0x19;\n    required AuthenticationType reusable_auth_credentials_type = 0x1e;\n    required bytes reusable_auth_credentials = 0x28;\n    optional bytes lfs_secret = 0x32; \n    optional AccountInfo account_info = 0x3c;\n    optional AccountInfoFacebook fb = 0x46;\n}\n\nenum AccountType {\n    Spotify = 0x0;\n    Facebook = 0x1;\n}\n\nmessage AccountInfo {\n    optional AccountInfoSpotify spotify = 0x1;\n    optional AccountInfoFacebook facebook = 0x2;\n}\n\nmessage AccountInfoSpotify {\n}\n\nmessage AccountInfoFacebook {\n    optional string access_token = 0x1;\n    optional string machine_id = 0x2;\n}\n"
  },
  {
    "path": "protocol/proto/autodownload_backend_service.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.autodownloadservice.v1.proto;\n\nimport \"google/protobuf/timestamp.proto\";\n\nmessage Identifiers {\n    string device_id = 1;\n    string cache_id = 2;\n}\n\nmessage Settings {\n    oneof episode_download {\n        bool most_recent_no_limit = 1;\n        int32 most_recent_count = 2;\n    }\n}\n\nmessage SetSettingsRequest {\n    Identifiers identifiers = 1;\n    Settings settings = 2;\n    google.protobuf.Timestamp client_timestamp = 3;\n}\n\nmessage GetSettingsRequest {\n    Identifiers identifiers = 1;\n}\n\nmessage GetSettingsResponse {\n    Settings settings = 1;\n}\n\nmessage ShowRequest {\n    Identifiers identifiers = 1;\n    string show_uri = 2;\n    google.protobuf.Timestamp client_timestamp = 3;\n}\n\nmessage ReplaceIdentifiersRequest {\n    Identifiers old_identifiers = 1;\n    Identifiers new_identifiers = 2;\n}\n\nmessage PendingItem {\n    google.protobuf.Timestamp client_timestamp = 1;\n    \n    oneof pending {\n        bool is_removed = 2;\n        Settings settings = 3;\n    }\n}\n"
  },
  {
    "path": "protocol/proto/autodownload_config_common.proto",
    "content": "// Extracted from: Spotify 1.1.73.517 (macOS)\n\nsyntax = \"proto3\";\n\npackage spotify.autodownload_esperanto.proto;\n\noption objc_class_prefix = \"ESP\";\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"spotify.autodownload.esperanto.proto\";\n\nmessage AutoDownloadGlobalConfig {\n    uint32 number_of_episodes = 1;\n}\n\nmessage AutoDownloadShowConfig {\n    string uri = 1;\n    bool active = 2;\n}\n"
  },
  {
    "path": "protocol/proto/autodownload_config_get_request.proto",
    "content": "// Extracted from: Spotify 1.1.73.517 (macOS)\n\nsyntax = \"proto3\";\n\npackage spotify.autodownload_esperanto.proto;\n\nimport \"autodownload_config_common.proto\";\n\noption objc_class_prefix = \"ESP\";\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"spotify.autodownload.esperanto.proto\";\n\nmessage AutoDownloadGetRequest {\n    repeated string uri = 1;\n}\n\nmessage AutoDownloadGetResponse {\n    AutoDownloadGlobalConfig global = 1;\n    repeated AutoDownloadShowConfig show = 2;\n    string error = 99;\n}\n"
  },
  {
    "path": "protocol/proto/autodownload_config_set_request.proto",
    "content": "// Extracted from: Spotify 1.1.73.517 (macOS)\n\nsyntax = \"proto3\";\n\npackage spotify.autodownload_esperanto.proto;\n\nimport \"autodownload_config_common.proto\";\n\noption objc_class_prefix = \"ESP\";\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"spotify.autodownload.esperanto.proto\";\n\nmessage AutoDownloadSetRequest {\n    oneof config {\n        AutoDownloadGlobalConfig global = 1;\n        AutoDownloadShowConfig show = 2;\n    }\n}\n\nmessage AutoDownloadSetResponse {\n    string error = 99;\n}\n"
  },
  {
    "path": "protocol/proto/automix_mode.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.automix.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage AutomixConfig {\n    TransitionType transition_type = 1;\n    string fade_out_curves = 2;\n    string fade_in_curves = 3;\n    int32 beats_min = 4;\n    int32 beats_max = 5;\n    int32 fade_duration_max_ms = 6;\n}\n\nmessage AutomixMode {\n    AutomixStyle style = 1;\n    AutomixConfig config = 2;\n    AutomixConfig ml_config = 3;\n    AutomixConfig shuffle_config = 4;\n    AutomixConfig shuffle_ml_config = 5;\n}\n\nenum AutomixStyle {\n    NONE = 0;\n    DEFAULT = 1;\n    REGULAR = 2;\n    AIRBAG = 3;\n    RADIO_AIRBAG = 4;\n    SLEEP = 5;\n    MIXED = 6;\n    CUSTOM = 7;\n    HEURISTIC = 8;\n    BACKEND = 9;\n}\n\nenum TransitionType {\n    CUEPOINTS = 0;\n    CROSSFADE = 1;\n    GAPLESS = 2;\n    HEURISTIC_TRANSITION = 3;\n    BACKEND_TRANSITION = 4;\n}\n"
  },
  {
    "path": "protocol/proto/autoplay_context_request.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.player.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage AutoplayContextRequest {\n    required string context_uri = 1;\n    repeated string recent_track_uri = 2;\n    optional bool is_video = 3;\n}\n"
  },
  {
    "path": "protocol/proto/autoplay_node.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.player.proto;\n\nimport \"logging_params.proto\";\n\noption optimize_for = CODE_SIZE;\n\nmessage AutoplayNode {\n    map<string, bytes> filler_node = 1;\n    required bool is_playing_filler = 2;\n    required LoggingParams logging_params = 3;\n    optional bool called_play_on_filler = 4;\n}\n"
  },
  {
    "path": "protocol/proto/canvas.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto3\";\n\npackage spotify.context_track_exts.canvas;\n\nmessage Artist {\n    string uri = 1;\n    string name = 2;\n    string avatar = 3;\n}\n\nmessage CanvasRecord {\n    string id = 1;\n    string url = 2;\n    string file_id = 3;\n    Type type = 4;\n    string entity_uri = 5;\n    Artist artist = 6;\n    bool explicit = 7;\n    string uploaded_by = 8;\n    string etag = 9;\n    string canvas_uri = 11;\n    string storylines_id = 12;\n}\n\nenum Type {\n    IMAGE = 0;\n    VIDEO = 1;\n    VIDEO_LOOPING = 2;\n    VIDEO_LOOPING_RANDOM = 3;\n    GIF = 4;\n}\n"
  },
  {
    "path": "protocol/proto/canvas_storage.proto",
    "content": "// Extracted from: Spotify 1.1.73.517 (macOS)\n\nsyntax = \"proto3\";\n\npackage spotify.canvas.proto.storage;\n\nimport \"canvaz.proto\";\n\noption optimize_for = CODE_SIZE;\n\nmessage CanvasCacheEntry {\n    string entity_uri = 1;\n    uint64 expires_on_seconds = 2;\n    canvaz.cache.EntityCanvazResponse.Canvaz canvas = 3;\n}\n\nmessage CanvasCacheFile {\n    repeated CanvasCacheEntry entries = 1;\n}\n"
  },
  {
    "path": "protocol/proto/canvaz-meta.proto",
    "content": "// Extracted from: Spotify 1.1.73.517 (macOS)\n\nsyntax = \"proto3\";\n\npackage spotify.canvaz;\n\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.canvaz.proto\";\n\nenum Type {\n    IMAGE = 0;\n    VIDEO = 1;\n    VIDEO_LOOPING = 2;\n    VIDEO_LOOPING_RANDOM = 3;\n    GIF = 4;\n}\n"
  },
  {
    "path": "protocol/proto/canvaz.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.canvaz.cache;\n\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.canvazcache.proto\";\n\nmessage Artist {\n    string uri = 1;\n    string name = 2;\n    string avatar = 3;\n}\n\nmessage EntityCanvazResponse {\n    message Canvaz {\n        string id = 1;\n        string url = 2;\n        string file_id = 3;\n        Type type = 4;\n        string entity_uri = 5;\n        Artist artist = 6;\n        bool explicit = 7;\n        string uploaded_by = 8;\n        string etag = 9;\n        string canvas_uri = 11;\n        string storylines_id = 12;\n    }\n\n}\n\nenum Type {\n    IMAGE = 0;\n    VIDEO = 1;\n    VIDEO_LOOPING = 2;\n    VIDEO_LOOPING_RANDOM = 3;\n    GIF = 4;\n}\n\n"
  },
  {
    "path": "protocol/proto/capping_data.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto3\";\n\npackage spotify.capper3;\n\noption java_multiple_files = true;\noption java_package = \"com.spotify.capper3.proto\";\n\nmessage ConsumeTokensRequest {\n    uint32 tokens = 1;\n}\n\nmessage CappingData {\n    uint32 remaining_tokens = 1;\n    uint32 capacity = 2;\n    uint32 seconds_until_next_refill = 3;\n    uint32 refill_amount = 4;\n}\n\nmessage ConsumeTokensResponse {\n    uint32 seconds_until_next_update = 1;\n    PlayCappingType capping_type = 2;\n    CappingData capping_data = 3;\n}\n\nenum PlayCappingType {\n    NONE = 0;\n    LINEAR = 1;\n}\n"
  },
  {
    "path": "protocol/proto/claas.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto3\";\n\npackage spotify.claas.v1;\n\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.claas.v1\";\n\nservice ClaasService {\n    rpc PostLogs(PostLogsRequest) returns (PostLogsResponse);\n    rpc Watch(WatchRequest) returns (stream WatchResponse);\n}\n\nmessage WatchRequest {\n    string user_id = 1;\n}\n\nmessage WatchResponse {\n    repeated string logs = 1;\n}\n\nmessage PostLogsRequest {\n    repeated string logs = 1;\n}\n\nmessage PostLogsResponse {\n    \n}\n"
  },
  {
    "path": "protocol/proto/client-tts.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.narration.proto;\n\nimport \"tts-resolve.proto\";\n\noption optimize_for = CODE_SIZE;\n\nservice ClientTtsService {\n    rpc GetTtsUrl(TtsRequest) returns (TtsResponse);\n}\n\nmessage TtsRequest {\n    ResolveRequest.AudioFormat audio_format = 3;\n    string language = 4;\n    ResolveRequest.TtsVoice tts_voice = 5;\n    ResolveRequest.TtsProvider tts_provider = 6;\n    int32 sample_rate_hz = 7;\n    oneof prompt {\n        string text = 1;\n        string ssml = 2;\n    }\n}\n\nmessage TtsResponse {\n    string url = 1;\n}\n"
  },
  {
    "path": "protocol/proto/client_config.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.extendedmetadata.config.v1;\n\noption optimize_for = CODE_SIZE;\n\nmessage ClientConfig {\n    uint32 log_sampling_rate = 1;\n    uint32 avg_log_messages_per_minute = 2;\n    uint32 log_messages_burst_size = 3;\n}\n"
  },
  {
    "path": "protocol/proto/client_update.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.desktopupdate.proto;\n\noption java_multiple_files = true;\noption java_outer_classname = \"ClientUpdateProto\";\noption java_package = \"com.spotify.desktopupdate.proto\";\n\nmessage UpgradeSignedPart {\n    uint32 platform = 1;\n    uint64 version_from_from = 2;\n    uint64 version_from_to = 3;\n    uint64 target_version = 4;\n    string http_prefix = 5;\n    bytes binary_hash = 6;\n    ClientUpgradeType type = 7;\n    bytes file_id = 8;\n    uint32 delay = 9;\n    uint32 flags = 10;\n}\n\nmessage UpgradeRequiredMessage {\n    bytes upgrade_signed_part = 10;\n    bytes signature = 20;\n    string http_suffix = 30;\n}\n\nmessage UpdateQueryResponse {\n    UpgradeRequiredMessage upgrade_message_payload = 1;\n    uint32 poll_interval = 2;\n}\n\nenum ClientUpgradeType {\n    INVALID = 0;\n    LOGIN_CRITICAL = 1;\n    NORMAL = 2;\n}\n"
  },
  {
    "path": "protocol/proto/clips_cover.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto3\";\n\npackage spotify.clips;\n\noption objc_class_prefix = \"SPT\";\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_outer_classname = \"ClipsCoverProto\";\noption java_package = \"com.spotify.clips.proto\";\n\nmessage ClipsCover {\n    string image_url = 1;\n    string video_source_id = 2;\n}\n"
  },
  {
    "path": "protocol/proto/collection/album_collection_state.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.cosmos_util.proto;\n\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.cosmos.util.proto\";\n\nmessage AlbumCollectionState {\n    optional string collection_link = 1;\n    optional uint32 num_tracks_in_collection = 2;\n    optional bool complete = 3;\n}\n"
  },
  {
    "path": "protocol/proto/collection/artist_collection_state.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.cosmos_util.proto;\n\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.cosmos.util.proto\";\n\nmessage ArtistCollectionState {\n    optional string collection_link = 1;\n    optional bool followed = 2;\n    optional uint32 num_tracks_in_collection = 3;\n    optional uint32 num_albums_in_collection = 4;\n    optional bool is_banned = 5;\n    optional bool can_ban = 6;\n    optional uint32 num_explicitly_liked_tracks = 7;\n}\n"
  },
  {
    "path": "protocol/proto/collection/episode_collection_state.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.cosmos_util.proto;\n\noption objc_class_prefix = \"SPTCosmosUtil\";\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.cosmos.util.proto\";\n\nmessage EpisodeCollectionState {\n    optional bool is_following_show = 1;\n    optional bool is_new = 2;\n    optional bool is_in_listen_later = 3;\n}\n"
  },
  {
    "path": "protocol/proto/collection/show_collection_state.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.cosmos_util.proto;\n\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.cosmos.util.proto\";\n\nmessage ShowCollectionState {\n    optional bool is_in_collection = 1;\n}\n"
  },
  {
    "path": "protocol/proto/collection/track_collection_state.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.cosmos_util.proto;\n\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.cosmos.util.proto\";\n\nmessage TrackCollectionState {\n    optional bool is_in_collection = 1;\n    optional bool can_add_to_collection = 2;\n    optional bool is_banned = 3;\n    optional bool can_ban = 4;\n}\n"
  },
  {
    "path": "protocol/proto/collection2v2.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.collection.proto.v2;\n\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.collection2.v2.proto\";\n\nmessage PageRequest {\n    string username = 1;\n    string set = 2;\n    string pagination_token = 3;\n    int32 limit = 4;\n}\n\nmessage CollectionItem {\n    string uri = 1;\n    int32 added_at = 2;\n    bool is_removed = 3;\n    optional string context_uri = 4;\n}\n\nmessage PageResponse {\n    repeated CollectionItem items = 1;\n    string next_page_token = 2;\n    string sync_token = 3;\n}\n\nmessage DeltaRequest {\n    string username = 1;\n    string set = 2;\n    string last_sync_token = 3;\n}\n\nmessage DeltaResponse {\n    bool delta_update_possible = 1;\n    repeated CollectionItem items = 2;\n    string sync_token = 3;\n}\n\nmessage WriteRequest {\n    string username = 1;\n    string set = 2;\n    repeated CollectionItem items = 3;\n    string client_update_id = 4;\n}\n\nmessage InitializedRequest {\n    string username = 1;\n    string set = 2;\n}\n\nmessage InitializedResponse {\n    bool initialized = 1;\n}\n"
  },
  {
    "path": "protocol/proto/collection_add_remove_items_request.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.collection_cosmos.proto;\n\nimport \"status.proto\";\n\noption java_package = \"spotify.collection.esperanto.proto\";\noption java_multiple_files = true;\noption objc_class_prefix = \"ESP\";\noption optimize_for = CODE_SIZE;\n\nmessage CollectionAddRemoveItemsRequest {\n  repeated string uri = 1;\n}\n\nmessage CollectionAddRemoveItemsResponse {\n  Status status = 1;\n}\n"
  },
  {
    "path": "protocol/proto/collection_ban_request.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.collection_cosmos.proto;\n\nimport \"status.proto\";\n\noption java_package = \"spotify.collection.esperanto.proto\";\noption java_multiple_files = true;\noption objc_class_prefix = \"ESP\";\noption optimize_for = CODE_SIZE;\n\nmessage CollectionBanRequest {\n    string context_source = 1;\n    repeated string uri = 2;\n}\n\nmessage CollectionBanResponse {\n    Status status = 1;\n    repeated bool success = 2;\n}\n"
  },
  {
    "path": "protocol/proto/collection_decoration_policy.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.collection_cosmos.proto;\n\nimport \"policy/artist_decoration_policy.proto\";\nimport \"policy/album_decoration_policy.proto\";\nimport \"policy/track_decoration_policy.proto\";\nimport \"policy/show_decoration_policy.proto\";\nimport \"policy/episode_decoration_policy.proto\";\n\noption java_package = \"spotify.collection.esperanto.proto\";\noption java_multiple_files = true;\noption objc_class_prefix = \"ESP\";\noption optimize_for = CODE_SIZE;\n\nmessage CollectionArtistDecorationPolicy {\n    cosmos_util.proto.ArtistCollectionDecorationPolicy collection_policy = 1;\n    cosmos_util.proto.ArtistSyncDecorationPolicy sync_policy = 2;\n    cosmos_util.proto.ArtistDecorationPolicy artist_policy = 3;\n    bool decorated = 4;\n}\n\nmessage CollectionAlbumDecorationPolicy {\n    bool decorated = 1;\n    bool album_type = 2;\n    CollectionArtistDecorationPolicy artist_policy = 3;\n    CollectionArtistDecorationPolicy artists_policy = 4;\n    cosmos_util.proto.AlbumCollectionDecorationPolicy collection_policy = 5;\n    cosmos_util.proto.AlbumSyncDecorationPolicy sync_policy = 6;\n    cosmos_util.proto.AlbumDecorationPolicy album_policy = 7;\n}\n\nmessage CollectionTrackDecorationPolicy {\n    cosmos_util.proto.TrackCollectionDecorationPolicy collection_policy = 1;\n    cosmos_util.proto.TrackSyncDecorationPolicy sync_policy = 2;\n    cosmos_util.proto.TrackDecorationPolicy track_policy = 3;\n    cosmos_util.proto.TrackPlayedStateDecorationPolicy played_state_policy = 4;\n    CollectionAlbumDecorationPolicy album_policy = 5;\n    cosmos_util.proto.ArtistDecorationPolicy artist_policy = 6;\n    bool decorated = 7;\n    cosmos_util.proto.ArtistCollectionDecorationPolicy artist_collection_policy = 8;\n}\n\nmessage CollectionShowDecorationPolicy {\n    cosmos_util.proto.ShowDecorationPolicy show_policy = 1;\n    cosmos_util.proto.ShowPlayedStateDecorationPolicy played_state_policy = 2;\n    cosmos_util.proto.ShowCollectionDecorationPolicy collection_policy = 3;\n    bool decorated = 4;\n}\n\nmessage CollectionEpisodeDecorationPolicy {\n    cosmos_util.proto.EpisodeDecorationPolicy episode_policy = 1;\n    cosmos_util.proto.EpisodeCollectionDecorationPolicy collection_policy = 2;\n    cosmos_util.proto.EpisodeSyncDecorationPolicy sync_policy = 3;\n    cosmos_util.proto.EpisodePlayedStateDecorationPolicy played_state_policy = 4;\n    CollectionShowDecorationPolicy show_policy = 5;\n    bool decorated = 6;\n}\n\n"
  },
  {
    "path": "protocol/proto/collection_get_bans_request.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.collection_cosmos.proto;\n\nimport \"collection_decoration_policy.proto\";\nimport \"collection_item.proto\";\nimport \"status.proto\";\n\noption java_multiple_files = true;\noption java_package = \"spotify.collection.esperanto.proto\";\noption objc_class_prefix = \"SPTCollectionCosmos\";\noption optimize_for = CODE_SIZE;\n\nmessage CollectionGetBansRequest {\n  CollectionTrackDecorationPolicy track_policy = 1;\n  CollectionArtistDecorationPolicy artist_policy = 2;\n    string sort = 3;\n    bool timestamp = 4;\n    uint32 update_throttling = 5;\n}\n\nmessage Item {\n    uint32 add_time = 1;\n  CollectionTrack track_metadata = 2;\n  CollectionArtist artist_metadata = 3;\n}\n\nmessage CollectionGetBansResponse {\n    Status status = 1;\n    repeated Item item = 2;\n}\n"
  },
  {
    "path": "protocol/proto/collection_index.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.collection.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage IndexRepairerState {\n    bytes last_checked_uri = 1;\n    int64 last_full_check_finished_at = 2;\n}\n\nmessage AddTime {\n    int64 timestamp = 1;\n}\n\nmessage CollectionTrackEntry {\n    string uri = 1;\n    string track_name = 2;\n    string album_uri = 3;\n    string album_name = 4;\n    int32 disc_number = 5;\n    int32 track_number = 6;\n    string artist_uri = 7;\n    repeated string artist_name = 8;\n    int64 add_time = 9;\n}\n\nmessage CollectionAlbumEntry {\n    string uri = 1;\n    string album_name = 2;\n    string artist_uri = 4;\n    string artist_name = 5;\n    int64 add_time = 6;\n    int64 last_played = 8;\n    int64 release_date = 9;\n}\n\nmessage CollectionShowEntry {\n    string uri = 1;\n    string show_name = 2;\n    string creator_name = 5;\n    int64 add_time = 6;\n    int64 publish_date = 7;\n    int64 last_played = 8;\n}\n\nmessage CollectionBookEntry {\n    string uri = 1;\n    string book_name = 2;\n    string author_name = 5;\n    int64 add_time = 6;\n    int64 last_played = 8;\n}\n\nmessage CollectionArtistEntry {\n    string uri = 1;\n    string artist_name = 2;\n    int64 add_time = 4;\n    int64 last_played = 8;\n}\n\nmessage CollectionAuthorEntry {\n    string uri = 1;\n    string author_name = 2;\n    int64 add_time = 4;\n}\n\nmessage CollectionEpisodeEntry {\n    string uri = 1;\n    string episode_name = 2;\n    string show_uri = 3;\n    string show_name = 4;\n    int64 add_time = 5;\n    int64 publish_time = 6;\n}\n\n"
  },
  {
    "path": "protocol/proto/collection_item.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.collection_cosmos.proto;\n\nimport \"metadata/album_metadata.proto\";\nimport \"metadata/artist_metadata.proto\";\nimport \"metadata/track_metadata.proto\";\nimport \"metadata/show_metadata.proto\";\nimport \"metadata/episode_metadata.proto\";\nimport \"collection/artist_collection_state.proto\";\nimport \"collection/album_collection_state.proto\";\nimport \"collection/track_collection_state.proto\";\nimport \"collection/show_collection_state.proto\";\nimport \"collection/episode_collection_state.proto\";\nimport \"sync/artist_sync_state.proto\";\nimport \"sync/album_sync_state.proto\";\nimport \"sync/track_sync_state.proto\";\nimport \"sync/episode_sync_state.proto\";\nimport \"played_state/track_played_state.proto\";\nimport \"played_state/show_played_state.proto\";\nimport \"played_state/episode_played_state.proto\";\n\noption java_package = \"spotify.collection.esperanto.proto\";\noption java_multiple_files = true;\noption objc_class_prefix = \"ESP\";\noption optimize_for = CODE_SIZE;\n\nmessage CollectionTrack {\n    uint32 index = 1;\n    uint32 add_time = 2;\n    cosmos_util.proto.TrackMetadata track_metadata = 3;\n    cosmos_util.proto.TrackCollectionState track_collection_state = 4;\n    cosmos_util.proto.TrackPlayState track_play_state = 5;\n    cosmos_util.proto.TrackSyncState track_sync_state = 6;\n    bool decorated = 7;\n    CollectionAlbum album = 8;\n    string cover = 9;\n    string link = 10;\n    repeated cosmos_util.proto.ArtistCollectionState artist_collection_state = 11;\n}\n\nmessage CollectionAlbum {\n    uint32 add_time = 1;\n    cosmos_util.proto.AlbumMetadata album_metadata = 2;\n    cosmos_util.proto.AlbumCollectionState album_collection_state = 3;\n    cosmos_util.proto.AlbumSyncState album_sync_state = 4;\n    bool decorated = 5;\n    string album_type = 6;\n    repeated CollectionTrack track = 7;\n    string link = 11;\n}\n\nmessage CollectionArtist {\n    cosmos_util.proto.ArtistMetadata artist_metadata = 1;\n    cosmos_util.proto.ArtistCollectionState artist_collection_state = 2;\n    cosmos_util.proto.ArtistSyncState artist_sync_state = 3;\n    bool decorated = 4;\n    repeated CollectionAlbum album = 5;\n    string link = 6;\n}\n\nmessage CollectionShow {\n    cosmos_util.proto.ShowMetadata show_metadata = 1;\n    cosmos_util.proto.ShowCollectionState show_collection_state = 2;\n    cosmos_util.proto.ShowPlayState show_play_state = 3;\n    uint32 add_time = 4;\n    string link = 5;\n}\n\nmessage CollectionEpisode {\n    cosmos_util.proto.EpisodeMetadata episode_metadata = 1;\n    cosmos_util.proto.EpisodeCollectionState episode_collection_state = 2;\n    cosmos_util.proto.EpisodeSyncState episode_offline_state = 3;\n    cosmos_util.proto.EpisodePlayState episode_play_state = 4;\n    CollectionShow show = 5;\n    string link = 6;\n}\n\n"
  },
  {
    "path": "protocol/proto/collection_platform_items.proto",
    "content": "syntax = \"proto3\";\n\npackage spotify.collection_platform.proto;\n\noption java_package = \"com.spotify.collection_platform.esperanto.proto\";\noption java_multiple_files = true;\noption objc_class_prefix = \"ESP\";\n\nmessage CollectionPlatformItem {\n  string uri = 1;\n  int64 add_time = 2;\n}\n\nmessage CollectionPlatformContextItem {\n  string uri = 1;\n  int64 add_time = 2;\n  string context_uri = 3;\n}\n\n"
  },
  {
    "path": "protocol/proto/collection_platform_requests.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.collection_platform.proto;\n\nimport \"collection_platform_items.proto\";\n\noption java_package = \"com.spotify.collection_platform.esperanto.proto\";\noption java_multiple_files = true;\noption objc_class_prefix = \"ESP\";\noption optimize_for = CODE_SIZE;\n\nmessage CollectionPlatformItemsRequest {\n    CollectionSet set = 1;\n    repeated string items = 2;\n}\n\nmessage CollectionPlatformContextItemsRequest {\n    CollectionSet set = 1;\n    repeated CollectionPlatformContextItem items = 2;\n}\n\nenum CollectionSet {\n    UNKNOWN = 0;\n    IGNOREINRECS = 4;\n    ENHANCED = 5;\n    BANNED_ARTISTS = 8;\n    CONCERTS = 10;\n    TAGS = 11;\n    PRERELEASE = 12;\n    MARKED_AS_FINISHED = 13;\n    NOT_INTERESTED = 14;\n    LOCAL_BANS = 15;\n}\n\n"
  },
  {
    "path": "protocol/proto/collection_platform_responses.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.collection_platform.proto;\n\nimport \"collection_platform_items.proto\";\n\noption java_package = \"com.spotify.collection_platform.esperanto.proto\";\noption java_multiple_files = true;\noption objc_class_prefix = \"ESP\";\noption optimize_for = CODE_SIZE;\n\nmessage CollectionPlatformSimpleResponse {\n    string error_msg = 1;\n}\n\nmessage CollectionPlatformItemsResponse {\n    repeated CollectionPlatformItem items = 1;\n}\n\nmessage CollectionPlatformContainsResponse {\n    repeated bool found = 1;\n}\n\nmessage Status {\n    int32 code = 1;\n    string reason = 2;\n}\n\nmessage CollectionPlatformEsperantoContainsResponse {\n    Status status = 1;\n    CollectionPlatformContainsResponse contains = 2;\n}\n\nmessage CollectionPlatformEsperantoItemsResponse {\n    Status status = 1;\n    repeated CollectionPlatformItem items = 2;\n}\n\nmessage CollectionPlatformContextItemsResponse {\n    Status status = 1;\n    repeated CollectionPlatformContextItem items = 2;\n}\n\nmessage CollectionPlatformContainsContextItemsResponse {\n    Status status = 1;\n    repeated bool found = 2;\n}\n\n"
  },
  {
    "path": "protocol/proto/concat_cosmos.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto3\";\n\npackage spotify.concat_cosmos.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage ConcatRequest {\n    string a = 1;\n    string b = 2;\n}\n\nmessage ConcatWithSeparatorRequest {\n    string a = 1;\n    string b = 2;\n    string separator = 3;\n}\n\nmessage ConcatResponse {\n    string concatenated = 1;\n}\n"
  },
  {
    "path": "protocol/proto/connect.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.connectstate;\n\nimport \"player.proto\";\nimport \"devices.proto\";\nimport \"media.proto\";\n\noption java_package = \"com.spotify.connectstate.model\";\noption optimize_for = CODE_SIZE;\n\nmessage ClusterUpdate {\n    Cluster cluster = 1;\n    ClusterUpdateReason update_reason = 2;\n    string ack_id = 3;\n    repeated string devices_that_changed = 4;\n}\n\nmessage Device {\n    DeviceInfo device_info = 1;\n    PlayerState player_state = 2;\n    PrivateDeviceInfo private_device_info = 3;\n    bytes transfer_data = 4;\n}\n\nmessage Cluster {\n    reserved 7;\n\n    int64 changed_timestamp_ms = 1;\n    string active_device_id = 2;\n    PlayerState player_state = 3;\n    map<string, DeviceInfo> device = 4;\n    bytes transfer_data = 5;\n    uint64 transfer_data_timestamp = 6;\n    bool need_full_player_state = 8;\n    int64 server_timestamp_ms = 9;\n    optional bool needs_state_updates = 10;\n    optional uint64 started_playing_at_timestamp = 11;\n}\n\nmessage PutStateRequest {\n    string callback_url = 1;\n    Device device = 2;\n    MemberType member_type = 3;\n    bool is_active = 4;\n    PutStateReason put_state_reason = 5;\n    uint32 message_id = 6;\n    string last_command_sent_by_device_id = 7;\n    uint32 last_command_message_id = 8;\n    uint64 started_playing_at = 9;\n    uint64 has_been_playing_for_ms = 11;\n    uint64 client_side_timestamp = 12;\n    bool only_write_player_state = 13;\n}\n\nmessage PrivateDeviceInfo {\n    string platform = 1;\n}\n\nmessage DeviceInfo {\n    reserved 5;\n\n    bool can_play = 1;\n    uint32 volume = 2;\n    string name = 3;\n    Capabilities capabilities = 4;\n    string device_software_version = 6;\n    devices.DeviceType device_type = 7;\n    string spirc_version = 9;\n    string device_id = 10;\n    bool is_private_session = 11;\n    bool is_social_connect = 12;\n    string client_id = 13;\n    string brand = 14;\n    string model = 15;\n    map<string, string> metadata_map = 16;\n    string product_id = 17;\n    string deduplication_id = 18;\n    uint32 selected_alias_id = 19;\n    map<string, devices.DeviceAlias> device_aliases = 20;\n    bool is_offline = 21;\n    string public_ip = 22;\n    string license = 23;\n    bool is_group = 25;\n    bool is_dynamic_device = 26;\n    repeated string disallow_playback_reasons = 27;\n    repeated string disallow_transfer_reasons = 28;\n    optional AudioOutputDeviceInfo audio_output_device_info = 24;\n}\n\nmessage AudioOutputDeviceInfo {\n    optional AudioOutputDeviceType audio_output_device_type = 1;\n    optional string device_name = 2;\n}\n\nmessage Capabilities {\n    reserved \"supported_contexts\";\n    reserved \"supports_lossless_audio\";\n    reserved 1;\n    reserved 4;\n    reserved 24;\n    bool can_be_player = 2;\n    bool restrict_to_local = 3;\n    bool gaia_eq_connect_id = 5;\n    bool supports_logout = 6;\n    bool is_observable = 7;\n    int32 volume_steps = 8;\n    repeated string supported_types = 9;\n    bool command_acks = 10;\n    bool supports_rename = 11;\n    bool hidden = 12;\n    bool disable_volume = 13;\n    bool connect_disabled = 14;\n    bool supports_playlist_v2 = 15;\n    bool is_controllable = 16;\n    bool supports_external_episodes = 17;\n    bool supports_set_backend_metadata = 18;\n    bool supports_transfer_command = 19;\n    bool supports_command_request = 20;\n    bool is_voice_enabled = 21;\n    bool needs_full_player_state = 22;\n    bool supports_gzip_pushes = 23;\n    bool supports_set_options_command = 25;\n    CapabilitySupportDetails supports_hifi = 26;\n    string connect_capabilities = 27;\n    bool supports_rooms = 28;\n    bool supports_dj = 29;\n    common.media.AudioQuality supported_audio_quality = 30;\n}\n\nmessage CapabilitySupportDetails {\n    bool fully_supported = 1;\n    bool user_eligible = 2;\n    bool device_supported = 3;\n}\n\nmessage ConnectCommandOptions {\n    int32 message_id = 1;\n    uint32 target_alias_id = 3;\n}\n\nmessage ConnectLoggingParams {\n    repeated string interaction_ids = 1;\n    repeated string page_instance_ids = 2;\n}\n\nmessage LogoutCommand {\n    ConnectCommandOptions command_options = 1;\n}\n\nmessage SetVolumeCommand {\n    int32 volume = 1;\n    ConnectCommandOptions command_options = 2;\n    ConnectLoggingParams logging_params = 3;\n    string connection_type = 4;\n}\n\nmessage RenameCommand {\n    string rename_to = 1;\n    ConnectCommandOptions command_options = 2;\n}\n\nmessage SetBackendMetadataCommand {\n    map<string, string> metadata = 1;\n}\n\nenum AudioOutputDeviceType {\n    UNKNOWN_AUDIO_OUTPUT_DEVICE_TYPE = 0;\n    BUILT_IN_SPEAKER = 1;\n    LINE_OUT = 2;\n    BLUETOOTH = 3;\n    AIRPLAY = 4;\n    AUTOMOTIVE = 5;\n    CAR_PROJECTED = 6;\n}\n\nenum PutStateReason {\n    UNKNOWN_PUT_STATE_REASON = 0;\n    SPIRC_HELLO = 1;\n    SPIRC_NOTIFY = 2;\n    NEW_DEVICE = 3;\n    PLAYER_STATE_CHANGED = 4;\n    VOLUME_CHANGED = 5;\n    PICKER_OPENED = 6;\n    BECAME_INACTIVE = 7;\n    ALIAS_CHANGED = 8;\n    NEW_CONNECTION = 9;\n    PULL_PLAYBACK = 10;\n    AUDIO_DRIVER_INFO_CHANGED = 11;\n    PUT_STATE_RATE_LIMITED = 12;\n    BACKEND_METADATA_APPLIED = 13;\n}\n\nenum MemberType {\n    SPIRC_V2 = 0;\n    SPIRC_V3 = 1;\n    CONNECT_STATE = 2;\n    CONNECT_STATE_EXTENDED = 5;\n    ACTIVE_DEVICE_TRACKER = 6;\n    PLAY_TOKEN = 7;\n}\n\nenum ClusterUpdateReason {\n    UNKNOWN_CLUSTER_UPDATE_REASON = 0;\n    DEVICES_DISAPPEARED = 1;\n    DEVICE_STATE_CHANGED = 2;\n    NEW_DEVICE_APPEARED = 3;\n    DEVICE_VOLUME_CHANGED = 4;\n    DEVICE_ALIAS_CHANGED = 5;\n    DEVICE_NEW_CONNECTION = 6;\n}\n\n"
  },
  {
    "path": "protocol/proto/connectivity.proto",
    "content": "syntax = \"proto3\";\n\npackage spotify.clienttoken.data.v0;\n\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"spotify.clienttoken.data.v0\";\n\nmessage ConnectivitySdkData {\n    PlatformSpecificData platform_specific_data = 1;\n    string device_id = 2;\n}\n\nmessage PlatformSpecificData {\n    oneof data {\n        NativeAndroidData android = 1;\n        NativeIOSData ios = 2;\n        NativeDesktopMacOSData desktop_macos = 3;\n        NativeDesktopWindowsData desktop_windows = 4;\n        NativeDesktopLinuxData desktop_linux = 5;\n    }\n}\n\nmessage NativeAndroidData {\n    Screen screen_dimensions = 1;\n    string android_version = 2;\n    int32 api_version = 3;\n    string device_name = 4;\n    string model_str = 5;\n    string vendor = 6;\n    string vendor_2 = 7;\n    int32 unknown_value_8 = 8;\n}\n\nmessage NativeIOSData {\n    // https://developer.apple.com/documentation/uikit/uiuserinterfaceidiom\n    int32 user_interface_idiom = 1;\n    bool target_iphone_simulator = 2;\n    string hw_machine = 3;\n    string system_version = 4;\n    string simulator_model_identifier = 5;\n}\n\nmessage NativeDesktopWindowsData {\n    int32 os_version = 1;\n    int32 os_build = 3;\n    // https://docs.microsoft.com/en-us/dotnet/api/system.platformid?view=net-6.0\n    int32 platform_id = 4;\n    int32 unknown_value_5 = 5;\n    int32 unknown_value_6 = 6;\n    // https://docs.microsoft.com/en-us/dotnet/api/system.reflection.imagefilemachine?view=net-6.0\n    int32 image_file_machine = 7;\n    // https://docs.microsoft.com/en-us/dotnet/api/system.reflection.portableexecutable.machine?view=net-6.0\n    int32 pe_machine = 8;\n    bool unknown_value_10 = 10;\n}\n\nmessage NativeDesktopLinuxData {\n    string system_name = 1;         //  uname -s\n    string system_release = 2;      //  -r\n    string system_version = 3;      //  -v\n    string hardware = 4;            //  -i\n}\n\nmessage NativeDesktopMacOSData {\n    string system_version = 1;\n    string hw_model = 2;\n    string compiled_cpu_type = 3;\n}\n\nmessage Screen {\n    int32 width = 1;\n    int32 height = 2;\n    int32 density = 3;\n    int32 unknown_value_4 = 4;\n    int32 unknown_value_5 = 5;\n}\n"
  },
  {
    "path": "protocol/proto/contains_request.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.playlist.cosmos.proto;\n\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.playlist.proto\";\n\nmessage ContainsRequest {\n    repeated string items = 1;\n}\n\nmessage ContainsResponse {\n    repeated bool found = 1;\n}\n"
  },
  {
    "path": "protocol/proto/content_access_token_cosmos.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto3\";\n\npackage spotify.contentaccesstoken.proto;\n\nimport \"google/protobuf/timestamp.proto\";\n\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.contentaccesstoken.proto\";\n\nmessage ContentAccessTokenResponse {\n    Error error = 1;\n    ContentAccessToken content_access_token = 2;\n}\n\nmessage ContentAccessToken {\n    string token = 1;\n    google.protobuf.Timestamp expires_at = 2;\n    google.protobuf.Timestamp refresh_at = 3;\n    repeated string domains = 4;\n}\n\nmessage ContentAccessRefreshToken {\n    string token = 1;\n}\n\nmessage IsEnabledResponse {\n    bool is_enabled = 1;\n}\n\nmessage Error {\n    int32 error_code = 1;\n    string error_description = 2;\n}\n"
  },
  {
    "path": "protocol/proto/context.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.player.proto;\n\nimport \"context_page.proto\";\nimport \"restrictions.proto\";\n\noption optimize_for = CODE_SIZE;\n\nmessage Context {\n    optional string uri = 1;\n    optional string url = 2;\n    map<string, string> metadata = 3;\n    optional Restrictions restrictions = 4;\n    repeated ContextPage pages = 5;\n    optional bool loading = 6;\n}\n"
  },
  {
    "path": "protocol/proto/context_application_desktop.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage ApplicationDesktop {\n    string version_string = 1;\n    int64 version_code = 2;\n    bytes session_id = 3;\n}\n"
  },
  {
    "path": "protocol/proto/context_client_id.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage ClientId {\n    bytes value = 1;\n}\n"
  },
  {
    "path": "protocol/proto/context_device_desktop.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage DeviceDesktop {\n    string platform_type = 1;\n    string device_manufacturer = 2;\n    string device_model = 3;\n    string device_id = 4;\n    string os_version = 5;\n}\n"
  },
  {
    "path": "protocol/proto/context_index.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.player.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage ContextIndex {\n    optional uint32 page = 1;\n    optional uint32 track = 2;\n}\n"
  },
  {
    "path": "protocol/proto/context_installation_id.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage InstallationId {\n    bytes value = 1;\n}\n"
  },
  {
    "path": "protocol/proto/context_monotonic_clock.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage MonotonicClock {\n    int64 id = 1;\n    int64 value = 2;\n}\n"
  },
  {
    "path": "protocol/proto/context_node.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.player.proto;\n\nimport \"context_processor.proto\";\nimport \"play_origin.proto\";\nimport \"prepare_play_options.proto\";\nimport \"track_instance.proto\";\nimport \"track_instantiator.proto\";\nimport \"context_track.proto\";\n\noption optimize_for = CODE_SIZE;\n\nmessage ContextNode {\n    optional TrackInstance current_track = 2;\n    optional TrackInstantiator instantiate = 3;\n    optional PreparePlayOptions prepare_options = 4;\n    optional PlayOrigin play_origin = 5;\n    optional ContextProcessor context_processor = 6;\n    optional string session_id = 7;\n    optional sint32 iteration = 8;\n    optional bool pending_pause = 9;\n    optional ContextTrack injected_connect_transfer_track = 10;\n}\n"
  },
  {
    "path": "protocol/proto/context_page.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.player.proto;\n\nimport \"context_track.proto\";\n\noption optimize_for = CODE_SIZE;\n\nmessage ContextPage {\n    optional string page_url = 1;\n    optional string next_page_url = 2;\n    map<string, string> metadata = 3;\n    repeated ContextTrack tracks = 4;\n    optional bool loading = 5;\n}\n"
  },
  {
    "path": "protocol/proto/context_player_options.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.player.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage ContextPlayerOptions {\n    optional bool shuffling_context = 1;\n    optional bool repeating_context = 2;\n    optional bool repeating_track = 3;\n    optional float playback_speed = 4;\n    map<string, string> modes = 5;\n}\n\nmessage ContextPlayerOptionOverrides {\n    optional bool shuffling_context = 1;\n    optional bool repeating_context = 2;\n    optional bool repeating_track = 3;\n    optional float playback_speed = 4;\n    map<string, string> modes = 5;\n}\n\n"
  },
  {
    "path": "protocol/proto/context_processor.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.player.proto;\n\nimport \"context.proto\";\nimport \"context_view.proto\";\nimport \"skip_to_track.proto\";\n\noption optimize_for = CODE_SIZE;\n\nmessage ContextProcessor {\n    optional Context context = 1;\n    optional context_view.proto.ContextView context_view = 2;\n    optional SkipToTrack pending_skip_to = 3;\n    optional string shuffle_seed = 4;\n    optional int32 index = 5;\n}\n"
  },
  {
    "path": "protocol/proto/context_sdk.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage Sdk {\n    string version_name = 1;\n    string type = 2;\n}\n"
  },
  {
    "path": "protocol/proto/context_time.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage Time {\n    int64 value = 1;\n}\n"
  },
  {
    "path": "protocol/proto/context_track.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.player.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage ContextTrack {\n    optional string uri = 1;\n    optional string uid = 2;\n    optional bytes gid = 3;\n    map<string, string> metadata = 4;\n}\n"
  },
  {
    "path": "protocol/proto/context_view.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.player.context_view.proto;\n\nimport \"context_track.proto\";\nimport \"context_view_cyclic_list.proto\";\n\noption optimize_for = CODE_SIZE;\n\nmessage ContextView {\n    map<string, player.proto.ContextTrack> patch_map = 1;\n    optional uint32 iteration_size = 2;\n    optional cyclic_list.proto.CyclicEntryKeyList cyclic_list = 3;\n}\n\n"
  },
  {
    "path": "protocol/proto/context_view_cyclic_list.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.player.context_view.cyclic_list.proto;\n\nimport \"context_view_entry_key.proto\";\n\noption optimize_for = CODE_SIZE;\n\nmessage Instance {\n    optional context_view.proto.EntryKey item = 1;\n    optional int32 iteration = 2;\n}\n\nmessage Patch {\n    optional int32 start = 1;\n    optional int32 end = 2;\n    repeated Instance instances = 3;\n}\n\nmessage CyclicEntryKeyList {\n    optional context_view.proto.EntryKey delimiter = 1;\n    repeated context_view.proto.EntryKey items = 2;\n    optional Patch patch = 3;\n}\n"
  },
  {
    "path": "protocol/proto/context_view_entry.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.player.context_view.proto;\n\nimport \"context_index.proto\";\nimport \"context_track.proto\";\n\noption optimize_for = CODE_SIZE;\n\nmessage Entry {\n    enum Type {\n        TRACK = 0;\n        DELIMITER = 1;\n        PAGE_PLACEHOLDER = 2;\n        CONTEXT_PLACEHOLDER = 3;\n    }\n\n    optional Type type = 1;\n    optional player.proto.ContextTrack track = 2;\n    optional player.proto.ContextIndex index = 3;\n    optional int32 page_index = 4;\n    optional int32 absolute_index = 5;\n}\n"
  },
  {
    "path": "protocol/proto/context_view_entry_key.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.player.context_view.proto;\n\nimport \"context_view_entry.proto\";\n\noption optimize_for = CODE_SIZE;\n\nmessage EntryKey {\n    optional Entry.Type type = 1;\n    optional string data = 2;\n}\n"
  },
  {
    "path": "protocol/proto/cosmos_changes_request.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.collection_cosmos.changes_request.proto;\n\noption objc_class_prefix = \"SPTCollectionCosmosChanges\";\noption optimize_for = CODE_SIZE;\n\nmessage Response {\n}\n"
  },
  {
    "path": "protocol/proto/cosmos_decorate_request.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.collection_cosmos.decorate_request.proto;\n\nimport \"collection/album_collection_state.proto\";\nimport \"collection/artist_collection_state.proto\";\nimport \"collection/episode_collection_state.proto\";\nimport \"collection/show_collection_state.proto\";\nimport \"collection/track_collection_state.proto\";\nimport \"played_state/episode_played_state.proto\";\nimport \"played_state/show_played_state.proto\";\nimport \"played_state/track_played_state.proto\";\nimport \"sync/album_sync_state.proto\";\nimport \"sync/artist_sync_state.proto\";\nimport \"sync/episode_sync_state.proto\";\nimport \"sync/track_sync_state.proto\";\nimport \"metadata/album_metadata.proto\";\nimport \"metadata/artist_metadata.proto\";\nimport \"metadata/episode_metadata.proto\";\nimport \"metadata/show_metadata.proto\";\nimport \"metadata/track_metadata.proto\";\n\noption objc_class_prefix = \"SPTCollectionCosmosDecorate\";\noption optimize_for = CODE_SIZE;\n\nmessage Album {\n    optional cosmos_util.proto.AlbumMetadata album_metadata = 1;\n    optional cosmos_util.proto.AlbumCollectionState album_collection_state = 2;\n    optional cosmos_util.proto.AlbumSyncState album_offline_state = 3;\n    optional string link = 4;\n}\n\nmessage Artist {\n    optional cosmos_util.proto.ArtistMetadata artist_metadata = 1;\n    optional cosmos_util.proto.ArtistCollectionState artist_collection_state = 2;\n    optional cosmos_util.proto.ArtistSyncState artist_offline_state = 3;\n    optional string link = 4;\n}\n\nmessage Episode {\n    optional cosmos_util.proto.EpisodeMetadata episode_metadata = 1;\n    optional cosmos_util.proto.EpisodeCollectionState episode_collection_state = 2;\n    optional cosmos_util.proto.EpisodeSyncState episode_offline_state = 3;\n    optional cosmos_util.proto.EpisodePlayState episode_play_state = 4;\n    optional string link = 5;\n}\n\nmessage Show {\n    optional cosmos_util.proto.ShowMetadata show_metadata = 1;\n    optional cosmos_util.proto.ShowCollectionState show_collection_state = 2;\n    optional cosmos_util.proto.ShowPlayState show_play_state = 3;\n    optional string link = 4;\n}\n\nmessage Track {\n    optional cosmos_util.proto.TrackMetadata track_metadata = 1;\n    optional cosmos_util.proto.TrackSyncState track_offline_state = 2;\n    optional cosmos_util.proto.TrackPlayState track_play_state = 3;\n    optional cosmos_util.proto.TrackCollectionState track_collection_state = 4;\n    optional string link = 5;\n}\n\nmessage Response {\n    repeated Show show = 1;\n    repeated Episode episode = 2;\n    repeated Album album = 3;\n    repeated Artist artist = 4;\n    repeated Track track = 5;\n}\n"
  },
  {
    "path": "protocol/proto/cosmos_get_album_list_request.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.collection_cosmos.album_list_request.proto;\n\nimport \"collection/album_collection_state.proto\";\nimport \"sync/album_sync_state.proto\";\nimport \"metadata/album_metadata.proto\";\n\noption objc_class_prefix = \"SPTCollectionCosmosAlbumList\";\noption optimize_for = CODE_SIZE;\n\nmessage Item {\n    optional string header_field = 1;\n    optional uint32 index = 2;\n    optional uint32 add_time = 3;\n    optional cosmos_util.proto.AlbumMetadata album_metadata = 4;\n    optional cosmos_util.proto.AlbumCollectionState album_collection_state = 5;\n    optional cosmos_util.proto.AlbumSyncState album_offline_state = 6;\n    optional string group_label = 7;\n}\n\nmessage GroupHeader {\n    optional string header_field = 1;\n    optional uint32 index = 2;\n    optional uint32 length = 3;\n}\n\nmessage Response {\n    repeated Item item = 1;\n    optional uint32 unfiltered_length = 2;\n    optional uint32 unranged_length = 3;\n    optional bool loading_contents = 4;\n    optional string offline = 5;\n    optional uint32 sync_progress = 6;\n}\n"
  },
  {
    "path": "protocol/proto/cosmos_get_artist_list_request.proto",
    "content": "// Extracted from: Spotify 1.1.73.517 (macOS)\n\nsyntax = \"proto2\";\n\npackage spotify.collection_cosmos.artist_list_request.proto;\n\nimport \"collection/artist_collection_state.proto\";\nimport \"sync/artist_sync_state.proto\";\nimport \"metadata/artist_metadata.proto\";\n\noption objc_class_prefix = \"SPTCollectionCosmosArtistList\";\noption optimize_for = CODE_SIZE;\n\nmessage Item {\n    optional string header_field = 1;\n    optional uint32 index = 2;\n    optional uint32 add_time = 3;\n    optional cosmos_util.proto.ArtistMetadata artist_metadata = 4;\n    optional cosmos_util.proto.ArtistCollectionState artist_collection_state = 5;\n    optional cosmos_util.proto.ArtistSyncState artist_offline_state = 6;\n    optional string group_label = 7;\n}\n\nmessage GroupHeader {\n    optional string header_field = 1;\n    optional uint32 index = 2;\n    optional uint32 length = 3;\n}\n\nmessage Response {\n    repeated Item item = 1;\n    optional uint32 unfiltered_length = 2;\n    optional uint32 unranged_length = 3;\n    optional bool loading_contents = 4;\n    optional string offline = 5;\n    optional uint32 sync_progress = 6;\n    repeated GroupHeader group_index = 7;\n}\n"
  },
  {
    "path": "protocol/proto/cosmos_get_episode_list_request.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.collection_cosmos.episode_list_request.proto;\n\nimport \"collection/episode_collection_state.proto\";\nimport \"played_state/episode_played_state.proto\";\nimport \"sync/episode_sync_state.proto\";\nimport \"metadata/episode_metadata.proto\";\n\noption objc_class_prefix = \"SPTCollectionCosmosEpisodeList\";\noption optimize_for = CODE_SIZE;\n\nmessage Item {\n    optional string header = 1;\n    optional cosmos_util.proto.EpisodeMetadata episode_metadata = 2;\n    optional cosmos_util.proto.EpisodeCollectionState episode_collection_state = 3;\n    optional cosmos_util.proto.EpisodeSyncState episode_offline_state = 4;\n    optional cosmos_util.proto.EpisodePlayState episode_play_state = 5;\n}\n\nmessage Response {\n    repeated Item item = 1;\n    optional uint32 unfiltered_length = 2;\n    optional uint32 unranged_length = 3;\n    optional bool loading_contents = 4;\n}\n"
  },
  {
    "path": "protocol/proto/cosmos_get_show_list_request.proto",
    "content": "// Extracted from: Spotify 1.1.73.517 (macOS)\n\nsyntax = \"proto2\";\n\npackage spotify.collection_cosmos.show_list_request.proto;\n\nimport \"collection/show_collection_state.proto\";\nimport \"played_state/show_played_state.proto\";\nimport \"metadata/show_metadata.proto\";\n\noption objc_class_prefix = \"SPTCollectionCosmosShowList\";\noption optimize_for = CODE_SIZE;\n\nmessage Item {\n    optional string header_field = 1;\n    optional cosmos_util.proto.ShowMetadata show_metadata = 2;\n    optional cosmos_util.proto.ShowCollectionState show_collection_state = 3;\n    optional cosmos_util.proto.ShowPlayState show_play_state = 4;\n    optional uint32 headerless_index = 5;\n    optional uint32 add_time = 6;\n    optional bool has_new_episodes = 7;\n    optional uint64 latest_published_episode_date = 8;\n}\n\nmessage Response {\n    repeated Item item = 1;\n    optional uint32 num_offlined_episodes = 2;\n    optional uint32 unfiltered_length = 3;\n    optional uint32 unranged_length = 4;\n    optional bool loading_contents = 5;\n}\n"
  },
  {
    "path": "protocol/proto/cosmos_get_tags_info_request.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.collection_cosmos.tags_info_request.proto;\n\noption objc_class_prefix = \"SPTCollectionCosmosTagsInfo\";\noption optimize_for = CODE_SIZE;\n\nmessage Request {\n}\n\nmessage Response {\n    bool is_synced = 1;\n}\n"
  },
  {
    "path": "protocol/proto/cosmos_get_track_list_metadata_request.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.collection_cosmos.proto;\n\noption objc_class_prefix = \"SPTCollectionCosmos\";\noption optimize_for = CODE_SIZE;\n\nmessage TrackListMetadata {\n    optional uint32 unfiltered_length = 1;\n    optional uint32 length = 2;\n    optional string offline = 3;\n    optional uint32 sync_progress = 4;\n}\n"
  },
  {
    "path": "protocol/proto/cosmos_get_track_list_request.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.collection_cosmos.track_list_request.proto;\n\nimport \"collection/artist_collection_state.proto\";\nimport \"collection/track_collection_state.proto\";\nimport \"played_state/track_played_state.proto\";\nimport \"sync/track_sync_state.proto\";\nimport \"metadata/track_metadata.proto\";\n\noption objc_class_prefix = \"SPTCollectionCosmosTrackList\";\noption optimize_for = CODE_SIZE;\n\nmessage Item {\n    optional string header_field = 1;\n    optional uint32 index = 2;\n    optional uint32 add_time = 3;\n    optional cosmos_util.proto.TrackMetadata track_metadata = 4;\n    optional cosmos_util.proto.TrackSyncState track_offline_state = 5;\n    optional cosmos_util.proto.TrackPlayState track_play_state = 6;\n    optional cosmos_util.proto.TrackCollectionState track_collection_state = 7;\n    optional string group_label = 8;\n    repeated cosmos_util.proto.ArtistCollectionState artist_collection_state = 9;\n}\n\nmessage GroupHeader {\n    optional string header_field = 1;\n    optional uint32 index = 2;\n    optional uint32 length = 3;\n}\n\nmessage Response {\n    repeated Item item = 1;\n    optional uint32 unfiltered_length = 2;\n    optional uint32 unranged_length = 3;\n    optional bool loading_contents = 4;\n    optional string offline = 5;\n    optional uint32 sync_progress = 6;\n}\n"
  },
  {
    "path": "protocol/proto/cosmos_get_unplayed_episodes_request.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.collection_cosmos.unplayed_request.proto;\n\nimport \"collection/episode_collection_state.proto\";\nimport \"played_state/episode_played_state.proto\";\nimport \"sync/episode_sync_state.proto\";\nimport \"metadata/episode_metadata.proto\";\n\noption objc_class_prefix = \"SPTCollectionCosmosUnplayedEpisodes\";\noption optimize_for = CODE_SIZE;\n\nmessage Item {\n    optional string header = 1;\n    optional cosmos_util.proto.EpisodeMetadata episode_metadata = 2;\n    optional cosmos_util.proto.EpisodeCollectionState episode_collection_state = 3;\n    optional cosmos_util.proto.EpisodeSyncState episode_offline_state = 4;\n    optional cosmos_util.proto.EpisodePlayState episode_play_state = 5;\n}\n\nmessage Response {\n    repeated Item item = 1;\n    optional uint32 unfiltered_length = 2;\n    optional uint32 unranged_length = 3;\n    optional bool loading_contents = 4;\n}\n"
  },
  {
    "path": "protocol/proto/cuepoints.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.automix.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage Cuepoint {\n    int64 position_ms = 1;\n    float tempo_bpm = 2;\n    Origin origin = 3;\n}\n\nmessage Cuepoints {\n    Cuepoint fade_in_cuepoint = 1;\n    Cuepoint fade_out_cuepoint = 2;\n}\n\nenum Origin {\n    HUMAN = 0;\n    ML = 1;\n}\n"
  },
  {
    "path": "protocol/proto/decorate_request.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.show_cosmos.decorate_request.proto;\n\nimport \"metadata/episode_metadata.proto\";\nimport \"metadata/show_metadata.proto\";\nimport \"played_state/episode_played_state.proto\";\nimport \"played_state/show_played_state.proto\";\nimport \"show_episode_state.proto\";\nimport \"show_show_state.proto\";\nimport \"show_offline_state.proto\";\n\noption objc_class_prefix = \"SPTShowCosmosDecorate\";\noption optimize_for = CODE_SIZE;\n\nmessage Show {\n    reserved 5;\n    reserved 6;\n    optional cosmos_util.proto.ShowMetadata show_metadata = 1;\n    optional show_cosmos.proto.ShowCollectionState show_collection_state = 2;\n    optional cosmos_util.proto.ShowPlayState show_play_state = 3;\n    optional string link = 4;\n    optional show_cosmos.proto.ShowOfflineState show_offline_state = 7;\n}\n\nmessage Episode {\n    reserved 6;\n    reserved 7;\n    reserved 8;\n    reserved 9;\n    reserved 10;\n    reserved 11;\n    reserved 12;\n    reserved 13;\n    optional cosmos_util.proto.EpisodeMetadata episode_metadata = 1;\n    optional show_cosmos.proto.EpisodeCollectionState episode_collection_state = 2;\n    optional show_cosmos.proto.EpisodeOfflineState episode_offline_state = 3;\n    optional cosmos_util.proto.EpisodePlayState episode_play_state = 4;\n    optional string link = 5;\n}\n\nmessage Response {\n    repeated Show show = 1;\n    repeated Episode episode = 2;\n}\n"
  },
  {
    "path": "protocol/proto/devices.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.connectstate.devices;\n\noption java_package = \"com.spotify.common.proto\";\n\nmessage DeviceAlias {\n    uint32 id = 1;\n    string display_name = 2;\n    bool is_group = 3;\n}\n\nenum DeviceType {\n    UNKNOWN = 0;\n    COMPUTER = 1;\n    TABLET = 2;\n    SMARTPHONE = 3;\n    SPEAKER = 4;\n    TV = 5;\n    AVR = 6;\n    STB = 7;\n    AUDIO_DONGLE = 8;\n    GAME_CONSOLE = 9;\n    CAST_VIDEO = 10;\n    CAST_AUDIO = 11;\n    AUTOMOBILE = 12;\n    SMARTWATCH = 13;\n    CHROMEBOOK = 14;\n    UNKNOWN_SPOTIFY = 100;\n    CAR_THING = 101;\n    OBSERVER = 102;\n    HOME_THING = 103;\n}\n"
  },
  {
    "path": "protocol/proto/display_segments.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto3\";\n\npackage spotify.podcast_segments.display;\n\nimport \"podcast_segments.proto\";\n\noption objc_class_prefix = \"SPT\";\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_outer_classname = \"DisplaySegmentsProto\";\noption java_package = \"com.spotify.podcastsegments.display.proto\";\n\nmessage DisplaySegments {\n    repeated DisplaySegment display_segments = 1;\n    bool can_upsell = 2;\n    string album_mosaic_uri = 3;\n    repeated string artists = 4;\n    int32 duration_ms = 5;\n}\n\nmessage DisplaySegment {\n    string uri = 1;\n    int32 absolute_start_ms = 2;\n    int32 absolute_stop_ms = 3;\n    \n    Source source = 4;\n    enum Source {\n        PLAYBACK = 0;\n        EMBEDDED = 1;\n    }\n    \n    SegmentType type = 5;\n    string title = 6;\n    string subtitle = 7;\n    string image_url = 8;\n    string action_url = 9;\n    bool is_abridged = 10;\n}\n"
  },
  {
    "path": "protocol/proto/display_segments_extension.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.displaysegments.v1;\n\noption objc_class_prefix = \"ESP\";\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_outer_classname = \"DisplaySegmentsExtensionProto\";\noption java_package = \"com.spotify.displaysegments.v1.proto\";\n\nmessage DisplaySegmentsExtension {\n    string episode_uri = 1;\n    repeated DisplaySegment segments = 2;\n    int32 duration_ms = 3;\n    oneof decoration {\n        MusicAndTalkDecoration music_and_talk_decoration = 4;\n        PodcastChaptersDecoration podcast_chapters_decoration = 5;\n    }\n}\n\nmessage DisplaySegment {\n    string uri = 1;\n    SegmentType type = 2;\n    int32 duration_ms = 3;\n    int32 seek_start_ms = 4;\n    int32 seek_stop_ms = 5;\n    optional string title = 6;\n    optional string subtitle = 7;\n    optional string image_url = 8;\n    optional bool is_preview = 9;\n}\n\nmessage MusicAndTalkDecoration {\n    bool can_upsell = 1;\n}\n\nmessage PodcastChaptersDecoration {\n    repeated string tags = 1;\n}\n\nenum SegmentType {\n    SEGMENT_TYPE_UNSPECIFIED = 0;\n    SEGMENT_TYPE_TALK = 1;\n    SEGMENT_TYPE_MUSIC = 2;\n}\n"
  },
  {
    "path": "protocol/proto/entity_extension_data.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.extendedmetadata;\n\nimport \"google/protobuf/any.proto\";\n\noption cc_enable_arenas = true;\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.extendedmetadata.proto\";\n\nmessage EntityExtensionDataHeader {\n    int32 status_code = 1;\n    string etag = 2;\n    string locale = 3;\n    int64 cache_ttl_in_seconds = 4;\n    int64 offline_ttl_in_seconds = 5;\n}\n\nmessage EntityExtensionData {\n    EntityExtensionDataHeader header = 1;\n    string entity_uri = 2;\n    google.protobuf.Any extension_data = 3;\n}\n\nmessage PlainListAssoc {\n    repeated string entity_uri = 1;\n}\n\nmessage AssocHeader {\n}\n\nmessage Assoc {\n    AssocHeader header = 1;\n    PlainListAssoc plain_list = 2;\n}\n"
  },
  {
    "path": "protocol/proto/es_add_to_queue_request.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.player.esperanto.proto;\n\nimport \"es_command_options.proto\";\nimport \"es_context_track.proto\";\nimport \"es_logging_params.proto\";\n\noption objc_class_prefix = \"ESP\";\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.player.esperanto.proto\";\n\nmessage AddToQueueRequest {\n    ContextTrack track = 1;\n    CommandOptions options = 2;\n    LoggingParams logging_params = 3;\n}\n"
  },
  {
    "path": "protocol/proto/es_command_options.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.player.esperanto.proto;\n\noption objc_class_prefix = \"ESP\";\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.player.esperanto.proto\";\n\nmessage CommandOptions {\n    bool override_restrictions = 1;\n    bool only_for_local_device = 2;\n    bool system_initiated = 3;\n    bytes only_for_playback_id = 4;\n}\n"
  },
  {
    "path": "protocol/proto/es_context.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.player.esperanto.proto;\n\nimport \"es_context_page.proto\";\nimport \"es_restrictions.proto\";\n\noption objc_class_prefix = \"ESP\";\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.player.esperanto.proto\";\n\nmessage Context {\n    repeated ContextPage pages = 1;\n    map<string, string> metadata = 2;\n    string uri = 3;\n    string url = 4;\n    bool is_loading = 5;\n    Restrictions restrictions = 6;\n}\n"
  },
  {
    "path": "protocol/proto/es_context_page.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.player.esperanto.proto;\n\nimport \"es_context_track.proto\";\n\noption objc_class_prefix = \"ESP\";\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.player.esperanto.proto\";\n\nmessage ContextPage {\n    repeated ContextTrack tracks = 1;\n    map<string, string> metadata = 2;\n    string page_url = 3;\n    string next_page_url = 4;\n    bool is_loading = 5;\n}\n"
  },
  {
    "path": "protocol/proto/es_context_player_error.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.player.esperanto.proto;\n\noption objc_class_prefix = \"ESP\";\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.player.esperanto.proto\";\n\nmessage ContextPlayerError {\n    enum ErrorCode {\n        SUCCESS = 0;\n        PLAYBACK_STUCK = 1;\n        PLAYBACK_ERROR = 2;\n        LICENSE_CHANGE = 3;\n        PLAY_RESTRICTED = 4;\n        STOP_RESTRICTED = 5;\n        UPDATE_RESTRICTED = 6;\n        PAUSE_RESTRICTED = 7;\n        RESUME_RESTRICTED = 8;\n        SKIP_TO_PREV_RESTRICTED = 9;\n        SKIP_TO_NEXT_RESTRICTED = 10;\n        SKIP_TO_NON_EXISTENT_TRACK = 11;\n        SEEK_TO_RESTRICTED = 12;\n        TOGGLE_REPEAT_CONTEXT_RESTRICTED = 13;\n        TOGGLE_REPEAT_TRACK_RESTRICTED = 14;\n        SET_OPTIONS_RESTRICTED = 15;\n        TOGGLE_SHUFFLE_RESTRICTED = 16;\n        SET_QUEUE_RESTRICTED = 17;\n        INTERRUPT_PLAYBACK_RESTRICTED = 18;\n        ONE_TRACK_UNPLAYABLE = 19;\n        ONE_TRACK_UNPLAYABLE_AUTO_STOPPED = 20;\n        ALL_TRACKS_UNPLAYABLE_AUTO_STOPPED = 21;\n        SKIP_TO_NON_EXISTENT_TRACK_AUTO_STOPPED = 22;\n        QUEUE_REVISION_MISMATCH = 23;\n        VIDEO_PLAYBACK_ERROR = 24;\n        VIDEO_GEOGRAPHICALLY_RESTRICTED = 25;\n        VIDEO_UNSUPPORTED_PLATFORM_VERSION = 26;\n        VIDEO_UNSUPPORTED_CLIENT_VERSION = 27;\n        VIDEO_UNSUPPORTED_KEY_SYSTEM = 28;\n        VIDEO_MANIFEST_DELETED = 29;\n        VIDEO_COUNTRY_RESTRICTED = 30;\n        VIDEO_UNAVAILABLE = 31;\n        VIDEO_CATALOGUE_RESTRICTED = 32;\n        INVALID = 33;\n        TIMEOUT = 34;\n        PLAYBACK_REPORTING_ERROR = 35;\n        UNKNOWN = 36;\n        ADD_TO_QUEUE_RESTRICTED = 37;\n        PICK_AND_SHUFFLE_CAPPED = 38;\n        PICK_AND_SHUFFLE_CONNECT_RESTRICTED = 39;\n        CONTEXT_LOADING_FAILED = 40;\n        AUDIOBOOK_NOT_PLAYABLE = 41;\n        SIGNAL_NOT_AVAILABLE = 42;\n    }\n\n    ErrorCode code = 1;\n    string message = 2;\n    map<string, string> data = 3;\n}\n"
  },
  {
    "path": "protocol/proto/es_context_player_options.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.player.esperanto.proto;\n\nimport \"es_optional.proto\";\n\noption objc_class_prefix = \"ESP\";\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.player.esperanto.proto\";\n\nmessage ContextPlayerOptions {\n    bool shuffling_context = 1;\n    bool repeating_context = 2;\n    bool repeating_track = 3;\n    map<string, string> modes = 5;\n    optional float playback_speed = 4;\n}\n\nmessage ContextPlayerOptionOverrides {\n    OptionalBoolean shuffling_context = 1;\n    OptionalBoolean repeating_context = 2;\n    OptionalBoolean repeating_track = 3;\n    map<string, string> modes = 5;\n    optional float playback_speed = 4;\n}\n"
  },
  {
    "path": "protocol/proto/es_context_player_state.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.player.esperanto.proto;\n\nimport \"es_restrictions.proto\";\nimport \"es_play_origin.proto\";\nimport \"es_optional.proto\";\nimport \"es_provided_track.proto\";\nimport \"es_context_player_options.proto\";\nimport \"es_prepare_play_options.proto\";\n\noption objc_class_prefix = \"ESP\";\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.player.esperanto.proto\";\n\nmessage ContextIndex {\n    uint64 page = 1;\n    uint64 track = 2;\n}\n\nmessage PlaybackQuality {\n    enum BitrateLevel {\n        UNKNOWN = 0;\n        LOW = 1;\n        NORMAL = 2;\n        HIGH = 3;\n        VERY_HIGH = 4;\n        HIFI = 5;\n        HIFI24 = 6;\n    }\n\n    enum BitrateStrategy {\n        UNKNOWN_STRATEGY = 0;\n        BEST_MATCHING = 1;\n        BACKEND_ADVISED = 2;\n        OFFLINED_FILE = 3;\n        CACHED_FILE = 4;\n        LOCAL_FILE = 5;\n    }\n\n    enum HiFiStatus {\n        NONE = 0;\n        OFF = 1;\n        ON = 2;\n    }\n\n    BitrateLevel bitrate_level = 1;\n    BitrateStrategy strategy = 2;\n    BitrateLevel target_bitrate_level = 3;\n    bool target_bitrate_available = 4;\n    HiFiStatus hifi_status = 5;\n}\n\nmessage ContextPlayerState {\n    uint64 timestamp = 1;\n    string context_uri = 2;\n    string context_url = 3;\n    Restrictions context_restrictions = 4;\n    PlayOrigin play_origin = 5;\n    ContextIndex index = 6;\n    ProvidedTrack track = 7;\n    bytes playback_id = 8;\n    PlaybackQuality playback_quality = 9;\n    OptionalDouble playback_speed = 10;\n    OptionalInt64 position_as_of_timestamp = 11;\n    OptionalInt64 duration = 12;\n    bool is_playing = 13;\n    bool is_paused = 14;\n    bool is_buffering = 15;\n    bool is_system_initiated = 16;\n    ContextPlayerOptions options = 17;\n    Restrictions restrictions = 18;\n    repeated string suppressions = 19;\n    repeated ProvidedTrack prev_tracks = 20;\n    repeated ProvidedTrack next_tracks = 21;\n    map<string, string> context_metadata = 22;\n    map<string, string> page_metadata = 23;\n    string session_id = 24;\n    uint64 queue_revision = 25;\n    PreparePlayOptions.AudioStream audio_stream = 26;\n    repeated string signals = 27;\n    string session_command_id = 28;\n}\n"
  },
  {
    "path": "protocol/proto/es_context_track.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.player.esperanto.proto;\n\noption objc_class_prefix = \"ESP\";\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.player.esperanto.proto\";\n\nmessage ContextTrack {\n    string uri = 1;\n    string uid = 2;\n    map<string, string> metadata = 3;\n}\n"
  },
  {
    "path": "protocol/proto/es_delete_session.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.player.esperanto.proto;\n\noption objc_class_prefix = \"ESP\";\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.player.esperanto.proto\";\n\nmessage DeleteSessionRequest {\n    string session_id = 1;\n}\n\nmessage DeleteSessionResponse {\n}\n"
  },
  {
    "path": "protocol/proto/es_get_error_request.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.player.esperanto.proto;\n\noption objc_class_prefix = \"ESP\";\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.player.esperanto.proto\";\n\nmessage GetErrorRequest {\n}\n"
  },
  {
    "path": "protocol/proto/es_get_play_history.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.player.esperanto.proto;\n\nimport \"es_context_track.proto\";\n\noption objc_class_prefix = \"ESP\";\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.player.esperanto.proto\";\n\nmessage GetPlayHistoryRequest {\n}\n\nmessage GetPlayHistoryResponse {\n    repeated ContextTrack tracks = 1;\n}\n"
  },
  {
    "path": "protocol/proto/es_get_position_state.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.player.esperanto.proto;\n\noption objc_class_prefix = \"ESP\";\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.player.esperanto.proto\";\n\nmessage GetPositionStateRequest {\n}\n\nmessage GetPositionStateResponse {\n    enum Error {\n        OK = 0;\n        NOT_FOUND = 1;\n    }\n\n    Error error = 1;\n    uint64 timestamp = 2;\n    uint64 position = 3;\n    double playback_speed = 4;\n}\n"
  },
  {
    "path": "protocol/proto/es_get_queue_request.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.player.esperanto.proto;\n\noption objc_class_prefix = \"ESP\";\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.player.esperanto.proto\";\n\nmessage GetQueueRequest {\n}\n"
  },
  {
    "path": "protocol/proto/es_get_state_request.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.player.esperanto.proto;\n\nimport \"es_optional.proto\";\n\noption objc_class_prefix = \"ESP\";\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.player.esperanto.proto\";\n\nmessage GetStateRequest {\n    OptionalInt64 prev_tracks_cap = 1;\n    OptionalInt64 next_tracks_cap = 2;\n}\n"
  },
  {
    "path": "protocol/proto/es_ident.proto",
    "content": "// Extracted from: Spotify 1.1.73.517 (macOS)\n\nsyntax = \"proto3\";\n\npackage spotify.connectivity.pubsub.esperanto.proto;\n\noption java_package = \"com.spotify.connectivity.pubsub.esperanto.proto\";\n\nmessage Ident {\n    string Ident = 1;\n}\n"
  },
  {
    "path": "protocol/proto/es_ident_filter.proto",
    "content": "// Extracted from: Spotify 1.1.73.517 (macOS)\n\nsyntax = \"proto3\";\n\npackage spotify.connectivity.pubsub.esperanto.proto;\n\noption java_package = \"com.spotify.connectivity.pubsub.esperanto.proto\";\n\nmessage IdentFilter {\n    string Prefix = 1;\n}\n"
  },
  {
    "path": "protocol/proto/es_logging_params.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto3\";\n\npackage spotify.player.esperanto.proto;\n\nimport \"es_optional.proto\";\n\noption objc_class_prefix = \"ESP\";\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.player.esperanto.proto\";\n\nmessage LoggingParams {\n    OptionalInt64 command_initiated_time = 1;\n    OptionalInt64 command_received_time = 2;\n    repeated string page_instance_ids = 3;\n    repeated string interaction_ids = 4;\n}\n"
  },
  {
    "path": "protocol/proto/es_optional.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.player.esperanto.proto;\n\noption objc_class_prefix = \"ESP\";\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.player.esperanto.proto\";\n\nmessage OptionalInt64 {\n    int64 value = 1;\n}\n\nmessage OptionalDouble {\n    double value = 1;\n}\n\nmessage OptionalBoolean {\n    bool value = 1;\n}\n"
  },
  {
    "path": "protocol/proto/es_pause.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.player.esperanto.proto;\n\nimport \"es_command_options.proto\";\nimport \"es_logging_params.proto\";\nimport \"es_pauseresume_origin.proto\";\n\noption objc_class_prefix = \"ESP\";\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.player.esperanto.proto\";\n\nmessage PauseRequest {\n    CommandOptions options = 1;\n    LoggingParams logging_params = 2;\n    PauseResumeOrigin pause_origin = 3;\n}\n"
  },
  {
    "path": "protocol/proto/es_pauseresume_origin.proto",
    "content": "syntax = \"proto3\";\n\npackage spotify.player.esperanto.proto;\n\noption java_package = \"com.spotify.player.esperanto.proto\";\noption objc_class_prefix = \"ESP\";\n\nmessage PauseResumeOrigin {\n  string feature_identifier = 1;\n}\n\n"
  },
  {
    "path": "protocol/proto/es_play.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.player.esperanto.proto;\n\nimport \"es_command_options.proto\";\nimport \"es_logging_params.proto\";\nimport \"es_play_options.proto\";\nimport \"es_prepare_play.proto\";\n\noption objc_class_prefix = \"ESP\";\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.player.esperanto.proto\";\n\nmessage PlayRequest {\n    PreparePlayRequest prepare_play_request = 1;\n    PlayOptions play_options = 2;\n    CommandOptions options = 3;\n    LoggingParams logging_params = 4;\n}\n\nmessage PlayPreparedRequest {\n    string session_id = 1;\n    PlayOptions play_options = 2;\n    CommandOptions options = 3;\n    LoggingParams logging_params = 4;\n}\n"
  },
  {
    "path": "protocol/proto/es_play_options.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto3\";\n\npackage spotify.player.esperanto.proto;\n\noption objc_class_prefix = \"ESP\";\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.player.esperanto.proto\";\n\nmessage PlayOptions {\n    Reason reason = 1;\n    enum Reason {\n        INTERACTIVE = 0;\n        REMOTE_TRANSFER = 1;\n        LICENSE_CHANGE = 2;\n    }\n    \n    Operation operation = 2;\n    enum Operation {\n        REPLACE = 0;\n        ENQUEUE = 1;\n        PUSH = 2;\n    }\n    \n    Trigger trigger = 3;\n    enum Trigger {\n        IMMEDIATELY = 0;\n        ADVANCED_PAST_TRACK = 1;\n        ADVANCED_PAST_CONTEXT = 2;\n    }\n}\n"
  },
  {
    "path": "protocol/proto/es_play_origin.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.player.esperanto.proto;\n\noption objc_class_prefix = \"ESP\";\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.player.esperanto.proto\";\n\nmessage PlayOrigin {\n    string feature_identifier = 1;\n    string feature_version = 2;\n    string view_uri = 3;\n    string external_referrer = 4;\n    string referrer_identifier = 5;\n    string device_identifier = 6;\n    repeated string feature_classes = 7;\n    string restriction_identifier = 8;\n}\n"
  },
  {
    "path": "protocol/proto/es_prefs.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.prefs.esperanto.proto;\n\noption objc_class_prefix = \"ESP\";\noption java_package = \"com.spotify.prefs.esperanto.proto\";\n\nservice Prefs {\n    rpc Get(GetParams) returns (PrefValues);\n    rpc Sub(SubParams) returns (stream PrefValues);\n    rpc GetAll(GetAllParams) returns (PrefValues);\n    rpc SubAll(SubAllParams) returns (stream PrefValues);\n    rpc Set(SetParams) returns (PrefValues);\n    rpc Create(CreateParams) returns (PrefValues);\n}\n\nmessage GetParams {\n    string key = 1;\n}\n\nmessage SubParams {\n    string key = 1;\n}\n\nmessage GetAllParams {\n}\n\nmessage SubAllParams {\n}\n\nmessage Value {\n    oneof value {\n        int64 number = 1;\n        bool bool = 2;\n        string string = 3;\n    }\n}\n\nmessage SetParams {\n    map<string, Value> entries = 1;\n}\n\nmessage CreateParams {\n    map<string, Value> entries = 1;\n}\n\nmessage PrefValues {\n    map<string, Value> entries = 1;\n}\n"
  },
  {
    "path": "protocol/proto/es_prepare_play.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.player.esperanto.proto;\n\nimport \"es_context.proto\";\nimport \"es_play_origin.proto\";\nimport \"es_prepare_play_options.proto\";\n\noption objc_class_prefix = \"ESP\";\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.player.esperanto.proto\";\n\nmessage PreparePlayRequest {\n    Context context = 1;\n    PreparePlayOptions options = 2;\n    PlayOrigin play_origin = 3;\n}\n"
  },
  {
    "path": "protocol/proto/es_prepare_play_options.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.player.esperanto.proto;\n\nimport \"es_context_player_options.proto\";\nimport \"es_optional.proto\";\nimport \"es_skip_to_track.proto\";\n\noption objc_class_prefix = \"ESP\";\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.player.esperanto.proto\";\n\nmessage PreparePlayOptions {\n    bytes playback_id = 1;\n    bool always_play_something = 2;\n    SkipToTrack skip_to = 3;\n    OptionalInt64 seek_to = 4;\n    bool initially_paused = 5;\n    bool system_initiated = 6;\n    ContextPlayerOptionOverrides player_options_override = 7;\n    repeated string suppressions = 8;\n\n    PrefetchLevel prefetch_level = 9;\n    enum PrefetchLevel {\n        NONE = 0;\n        MEDIA = 1;\n    }\n\n    AudioStream audio_stream = 10;\n    enum AudioStream {\n        DEFAULT = 0;\n        ALARM = 1;\n    }\n\n    string session_id = 11;\n    string license = 12;\n    map<string, string> configuration_override = 13;\n}\n"
  },
  {
    "path": "protocol/proto/es_provided_track.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.player.esperanto.proto;\n\nimport \"es_context_track.proto\";\n\noption objc_class_prefix = \"ESP\";\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.player.esperanto.proto\";\n\nmessage ProvidedTrack {\n    ContextTrack context_track = 1;\n    repeated string removed = 2;\n    repeated string blocked = 3;\n    string provider = 4;\n}\n"
  },
  {
    "path": "protocol/proto/es_pushed_message.proto",
    "content": "// Extracted from: Spotify 1.1.73.517 (macOS)\n\nsyntax = \"proto3\";\n\npackage spotify.connectivity.pubsub.esperanto.proto;\n\nimport \"es_ident.proto\";\n\noption java_package = \"com.spotify.connectivity.pubsub.esperanto.proto\";\n\nmessage PushedMessage {\n    Ident Ident = 1;\n    repeated string Payloads = 2;\n    map<string, string> Attributes = 3;\n}\n"
  },
  {
    "path": "protocol/proto/es_queue.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.player.esperanto.proto;\n\nimport \"es_provided_track.proto\";\n\noption objc_class_prefix = \"ESP\";\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.player.esperanto.proto\";\n\nmessage Queue {\n    uint64 queue_revision = 1;\n    ProvidedTrack track = 2;\n    repeated ProvidedTrack next_tracks = 3;\n    repeated ProvidedTrack prev_tracks = 4;\n}\n"
  },
  {
    "path": "protocol/proto/es_remote_config.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.remote_config.esperanto.proto;\n\nimport \"esperanto_options.proto\";\n\noption objc_class_prefix = \"ESP\";\noption java_package = \"com.spotify.remoteconfig.esperanto.proto\";\n\nservice RemoteConfig {\n    rpc lookupBool(LookupRequest) returns (.spotify.remote_config.esperanto.proto.BoolResponse) {}\n    rpc lookupInt(LookupRequest) returns (.spotify.remote_config.esperanto.proto.IntResponse) {}\n    rpc lookupEnum(LookupRequest) returns (.spotify.remote_config.esperanto.proto.EnumResponse) {}\n}\n\nmessage LookupRequest {\n    string scope = 1;\n    string name = 2;\n}\n\nmessage BoolResponse {\n    optional bool value = 1;\n}\n\nmessage IntResponse {\n    optional int32 value = 1;\n}\n\nmessage EnumResponse {\n    optional string value = 1;\n}\n"
  },
  {
    "path": "protocol/proto/es_request_info.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.connectivity.netstat.esperanto.proto;\n\noption java_package = \"com.spotify.connectivity.netstat.esperanto.proto\";\n\nmessage RepeatedRequestInfo {\n    repeated RequestInfo infos = 1;\n}\n\nmessage RequestInfo {\n    string uri = 1;\n    string verb = 2;\n    string source_identifier = 3;\n    int32 downloaded = 4;\n    int32 uploaded = 5;\n    int32 payload_size = 6;\n    bool connection_reuse = 7;\n    int64 event_started = 8;\n    int64 event_connected = 9;\n    int64 event_request_sent = 10;\n    int64 event_first_byte_received = 11;\n    int64 event_last_byte_received = 12;\n    int64 event_ended = 13;\n    string protocol = 14;\n    int64 event_redirects_done = 15;\n}\n"
  },
  {
    "path": "protocol/proto/es_response_with_reasons.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.player.esperanto.proto;\n\noption objc_class_prefix = \"ESP\";\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.player.esperanto.proto\";\n\nmessage ResponseWithReasons {\n    enum Error {\n        OK = 0;\n        FORBIDDEN = 1;\n        NOT_FOUND = 2;\n        CONFLICT = 3;\n    }\n\n    Error error = 1;\n    string reasons = 2;\n}\n"
  },
  {
    "path": "protocol/proto/es_restrictions.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.player.esperanto.proto;\n\noption objc_class_prefix = \"ESP\";\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.player.esperanto.proto\";\n\nmessage ModeRestrictions {\n    map<string, RestrictionReasons> values = 1;\n}\n\nmessage RestrictionReasons {\n    repeated string reasons = 1;\n}\n\nmessage Restrictions {\n    repeated string disallow_pausing_reasons = 1;\n    repeated string disallow_resuming_reasons = 2;\n    repeated string disallow_seeking_reasons = 3;\n    repeated string disallow_peeking_prev_reasons = 4;\n    repeated string disallow_peeking_next_reasons = 5;\n    repeated string disallow_skipping_prev_reasons = 6;\n    repeated string disallow_skipping_next_reasons = 7;\n    repeated string disallow_toggling_repeat_context_reasons = 8;\n    repeated string disallow_toggling_repeat_track_reasons = 9;\n    repeated string disallow_toggling_shuffle_reasons = 10;\n    repeated string disallow_set_queue_reasons = 11;\n    repeated string disallow_interrupting_playback_reasons = 12;\n    repeated string disallow_transferring_playback_reasons = 13;\n    repeated string disallow_remote_control_reasons = 14;\n    repeated string disallow_inserting_into_next_tracks_reasons = 15;\n    repeated string disallow_inserting_into_context_tracks_reasons = 16;\n    repeated string disallow_reordering_in_next_tracks_reasons = 17;\n    repeated string disallow_reordering_in_context_tracks_reasons = 18;\n    repeated string disallow_removing_from_next_tracks_reasons = 19;\n    repeated string disallow_removing_from_context_tracks_reasons = 20;\n    repeated string disallow_updating_context_reasons = 21;\n    repeated string disallow_add_to_queue_reasons = 22;\n    repeated string disallow_setting_playback_speed_reasons = 23;\n    map<string, ModeRestrictions> disallow_setting_modes = 25;\n    map<string, RestrictionReasons> disallow_signals = 26;\n}\n"
  },
  {
    "path": "protocol/proto/es_resume.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.player.esperanto.proto;\n\noption objc_class_prefix = \"ESP\";\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.player.esperanto.proto\";\n\nimport \"es_command_options.proto\";\nimport \"es_logging_params.proto\";\nimport \"es_pauseresume_origin.proto\";\n\nmessage ResumeRequest {\n    CommandOptions options = 1;\n    LoggingParams logging_params = 2;\n    PauseResumeOrigin resume_origin = 3;\n}\n"
  },
  {
    "path": "protocol/proto/es_seek_to.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.player.esperanto.proto;\n\nimport \"es_command_options.proto\";\nimport \"es_logging_params.proto\";\n\noption objc_class_prefix = \"ESP\";\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.player.esperanto.proto\";\n\nmessage SeekToRequest {\n    enum Relative {\n        BEGINNING = 0;\n        END = 1;\n        CURRENT = 2;\n    }\n\n    CommandOptions options = 1;\n    LoggingParams logging_params = 2;\n    int64 position = 3;\n    Relative relative = 4;\n}\n\n"
  },
  {
    "path": "protocol/proto/es_session_response.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.player.esperanto.proto;\n\noption objc_class_prefix = \"ESP\";\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.player.esperanto.proto\";\n\nmessage SessionResponse {\n    string session_id = 1;\n}\n"
  },
  {
    "path": "protocol/proto/es_set_options.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.player.esperanto.proto;\n\nimport \"es_command_options.proto\";\nimport \"es_logging_params.proto\";\nimport \"es_optional.proto\";\n\noption objc_class_prefix = \"ESP\";\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.player.esperanto.proto\";\n\nmessage SetOptionsRequest {\n    OptionalBoolean repeating_track = 1;\n    OptionalBoolean repeating_context = 2;\n    OptionalBoolean shuffling_context = 3;\n    CommandOptions options = 4;\n    LoggingParams logging_params = 5;\n    map<string, string> modes = 7;\n    optional float playback_speed = 6;\n}\n"
  },
  {
    "path": "protocol/proto/es_set_queue_request.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.player.esperanto.proto;\n\nimport \"es_command_options.proto\";\nimport \"es_provided_track.proto\";\nimport \"es_logging_params.proto\";\n\noption objc_class_prefix = \"ESP\";\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.player.esperanto.proto\";\n\nmessage SetQueueRequest {\n    repeated ProvidedTrack next_tracks = 1;\n    repeated ProvidedTrack prev_tracks = 2;\n    uint64 queue_revision = 3;\n    CommandOptions options = 4;\n    LoggingParams logging_params = 5;\n}\n"
  },
  {
    "path": "protocol/proto/es_set_repeating_context.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.player.esperanto.proto;\n\nimport \"es_command_options.proto\";\nimport \"es_logging_params.proto\";\n\noption objc_class_prefix = \"ESP\";\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.player.esperanto.proto\";\n\nmessage SetRepeatingContextRequest {\n    bool repeating_context = 1;\n    CommandOptions options = 2;\n    LoggingParams logging_params = 3;\n}\n"
  },
  {
    "path": "protocol/proto/es_set_repeating_track.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.player.esperanto.proto;\n\nimport \"es_command_options.proto\";\nimport \"es_logging_params.proto\";\n\noption objc_class_prefix = \"ESP\";\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.player.esperanto.proto\";\n\nmessage SetRepeatingTrackRequest {\n    bool repeating_track = 1;\n    CommandOptions options = 2;\n    LoggingParams logging_params = 3;\n}\n"
  },
  {
    "path": "protocol/proto/es_set_shuffling_context.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.player.esperanto.proto;\n\nimport \"es_command_options.proto\";\nimport \"es_logging_params.proto\";\n\noption objc_class_prefix = \"ESP\";\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.player.esperanto.proto\";\n\nmessage SetShufflingContextRequest {\n    bool shuffling_context = 1;\n    CommandOptions options = 2;\n    LoggingParams logging_params = 3;\n}\n"
  },
  {
    "path": "protocol/proto/es_skip_next.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.player.esperanto.proto;\n\nimport \"es_command_options.proto\";\nimport \"es_logging_params.proto\";\nimport \"es_context_track.proto\";\n\noption objc_class_prefix = \"ESP\";\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.player.esperanto.proto\";\n\nmessage SkipNextRequest {\n    CommandOptions options = 1;\n    LoggingParams logging_params = 2;\n    ContextTrack track = 3;\n}\n"
  },
  {
    "path": "protocol/proto/es_skip_prev.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.player.esperanto.proto;\n\nimport \"es_command_options.proto\";\nimport \"es_logging_params.proto\";\nimport \"es_context_track.proto\";\n\noption objc_class_prefix = \"ESP\";\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.player.esperanto.proto\";\n\nmessage SkipPrevRequest {\n    CommandOptions options = 1;\n    bool allow_seeking = 2;\n    LoggingParams logging_params = 3;\n    ContextTrack track = 4;\n}\n"
  },
  {
    "path": "protocol/proto/es_skip_to_track.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.player.esperanto.proto;\n\nimport \"es_optional.proto\";\n\noption objc_class_prefix = \"ESP\";\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.player.esperanto.proto\";\n\nmessage SkipToTrack {\n    string page_url = 1;\n    OptionalInt64 page_index = 2;\n    string track_uid = 3;\n    string track_uri = 4;\n    OptionalInt64 track_index = 5;\n}\n"
  },
  {
    "path": "protocol/proto/es_stop.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.player.esperanto.proto;\n\nimport \"es_command_options.proto\";\nimport \"es_logging_params.proto\";\n\noption objc_class_prefix = \"ESP\";\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.player.esperanto.proto\";\n\nmessage StopRequest {\n    CommandOptions options = 1;\n\n    StopRequest.Reason reason = 2;\n    enum Reason {\n        INTERACTIVE = 0;\n        REMOTE_TRANSFER = 1;\n        SHUTDOWN = 2;\n    }\n\n    LoggingParams logging_params = 3;\n}\n"
  },
  {
    "path": "protocol/proto/es_storage.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.storage.esperanto.proto;\n\nimport \"google/protobuf/empty.proto\";\n\noption java_package = \"com.spotify.storage.esperanto.proto\";\noption objc_class_prefix = \"ESP\";\n\nservice Storage {\n    rpc GetCacheSizeLimit(GetCacheSizeLimitParams) returns (CacheSizeLimit);\n    rpc SetCacheSizeLimit(SetCacheSizeLimitParams) returns (google.protobuf.Empty);\n    rpc DeleteExpiredItems(DeleteExpiredItemsParams) returns (google.protobuf.Empty);\n    rpc DeleteUnlockedItems(DeleteUnlockedItemsParams) returns (google.protobuf.Empty);\n    rpc GetStats(GetStatsParams) returns (Stats);\n    rpc GetFileRanges(GetFileRangesParams) returns (FileRanges);\n}\n\nmessage CacheSizeLimit {\n    int64 size = 1;\n}\n\nmessage GetCacheSizeLimitParams {\n}\n\nmessage SetCacheSizeLimitParams {\n    CacheSizeLimit limit = 1;\n}\n\nmessage DeleteExpiredItemsParams {\n}\n\nmessage DeleteUnlockedItemsParams {\n}\n\nmessage RealmStats {\n    Realm realm = 1;\n    int64 size = 2;\n    int64 num_entries = 3;\n    int64 num_complete_entries = 4;\n}\n\nmessage Stats {\n    string cache_id = 1;\n    int64 creation_date_sec = 2;\n    int64 max_cache_size = 3;\n    int64 current_size = 4;\n    int64 current_locked_size = 5;\n    int64 free_space = 6;\n    int64 total_space = 7;\n    int64 current_numfiles = 8;\n    repeated RealmStats realm_stats = 9;\n}\n\nmessage GetStatsParams {\n}\n\nmessage FileRanges {\n    message Range {\n        uint64 from_byte = 1;\n        uint64 to_byte = 2;\n    }\n\n    bool byte_size_known = 1;\n    uint64 byte_size = 2;\n    repeated Range ranges = 3;\n}\n\nmessage GetFileRangesParams {\n    Realm realm = 1;\n    string file_id = 2;\n}\n\nenum Realm {\n    STREAM = 0;\n    COVER_ART = 1;\n    PLAYLIST = 4;\n    AUDIO_SHOW = 5;\n    HEAD_FILES = 7;\n    EXTERNAL_AUDIO_SHOW = 8;\n    KARAOKE_MASK = 9;\n}\n"
  },
  {
    "path": "protocol/proto/es_update.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.player.esperanto.proto;\n\nimport \"es_context.proto\";\nimport \"es_context_page.proto\";\nimport \"es_context_track.proto\";\nimport \"es_logging_params.proto\";\n\noption objc_class_prefix = \"ESP\";\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.player.esperanto.proto\";\n\nmessage UpdateContextRequest {\n    string session_id = 1;\n    Context context = 2;\n    LoggingParams logging_params = 3;\n}\n\nmessage UpdateContextPageRequest {\n    string session_id = 1;\n    ContextPage context_page = 2;\n    LoggingParams logging_params = 3;\n}\n\nmessage UpdateContextTrackRequest {\n    string session_id = 1;\n    ContextTrack context_track = 2;\n    LoggingParams logging_params = 3;\n}\n\nmessage UpdateViewUriRequest {\n    string session_id = 1;\n    string view_uri = 2;\n    LoggingParams logging_params = 3;\n}\n"
  },
  {
    "path": "protocol/proto/esperanto_options.proto",
    "content": "syntax = \"proto3\";\n\npackage spotify.esperanto;\n\nimport \"google/protobuf/descriptor.proto\";\n\n"
  },
  {
    "path": "protocol/proto/event_entity.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage EventEntity {\n    uint32 file_format_version = 1;\n    string event_name = 2;\n    bytes sequence_id = 3;\n    uint64 sequence_number = 4;\n    bytes payload = 5;\n    string owner = 6;\n    bool authenticated = 7;\n    uint64 record_id = 8;\n}\n"
  },
  {
    "path": "protocol/proto/explicit_content_pubsub.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.explicit_content.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage UserAttributesUpdate {\n    map<string, string> pairs = 1;\n}\n"
  },
  {
    "path": "protocol/proto/extended_metadata.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.extendedmetadata;\n\nimport \"extension_kind.proto\";\nimport \"entity_extension_data.proto\";\n\noption cc_enable_arenas = true;\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.extendedmetadata.proto\";\n\nmessage ExtensionQuery {\n    ExtensionKind extension_kind = 1;\n    string etag = 2;\n}\n\nmessage EntityRequest {\n    string entity_uri = 1;\n    repeated ExtensionQuery query = 2;\n}\n\nmessage BatchedEntityRequestHeader {\n    string country = 1;\n    string catalogue = 2;\n    bytes task_id = 3;\n}\n\nmessage BatchedEntityRequest {\n    BatchedEntityRequestHeader header = 1;\n    repeated EntityRequest entity_request = 2;\n}\n\nmessage EntityExtensionDataArrayHeader {\n    int32 provider_error_status = 1;\n    int64 cache_ttl_in_seconds = 2;\n    int64 offline_ttl_in_seconds = 3;\n    ExtensionType extension_type = 4;\n}\n\nmessage EntityExtensionDataArray {\n    EntityExtensionDataArrayHeader header = 1;\n    ExtensionKind extension_kind = 2;\n    repeated EntityExtensionData extension_data = 3;\n}\n\nmessage BatchedExtensionResponseHeader {\n}\n\nmessage BatchedExtensionResponse {\n    BatchedExtensionResponseHeader header = 1;\n    repeated EntityExtensionDataArray extended_metadata = 2;\n}\n\nenum ExtensionType {\n    UNKNOWN = 0;\n    GENERIC = 1;\n    ASSOC = 2;\n}\n"
  },
  {
    "path": "protocol/proto/extension_descriptor_type.proto",
    "content": "// Extracted from: Spotify 1.1.73.517 (macOS)\n\nsyntax = \"proto3\";\n\npackage spotify.descriptorextension;\n\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.descriptorextension.proto\";\n\nmessage ExtensionDescriptor {\n    string text = 1;\n    float weight = 2;\n    repeated ExtensionDescriptorType types = 3;\n}\n\nmessage ExtensionDescriptorData {\n    repeated ExtensionDescriptor descriptors = 1;\n}\n\nenum ExtensionDescriptorType {\n    UNKNOWN = 0;\n    GENRE = 1;\n    MOOD = 2;\n    ACTIVITY = 3;\n    INSTRUMENT = 4;\n    TIME = 5;\n    ERA = 6;\n    AESTHETIC = 7;\n}\n"
  },
  {
    "path": "protocol/proto/extension_kind.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.extendedmetadata;\n\noption objc_class_prefix = \"SPTExtendedMetadata\";\noption cc_enable_arenas = true;\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.extendedmetadata.proto\";\n\nenum ExtensionKind {\n    UNKNOWN_EXTENSION = 0;\n    CANVAZ = 1;\n    STORYLINES = 2;\n    PODCAST_TOPICS = 3;\n    PODCAST_SEGMENTS = 4;\n    AUDIO_FILES = 5;\n    TRACK_DESCRIPTOR = 6;\n    PODCAST_COUNTER = 7;\n    ARTIST_V4 = 8;\n    ALBUM_V4 = 9;\n    TRACK_V4 = 10;\n    SHOW_V4 = 11;\n    EPISODE_V4 = 12;\n    PODCAST_HTML_DESCRIPTION = 13;\n    PODCAST_QUOTES = 14;\n    USER_PROFILE = 15;\n    CANVAS_V1 = 16;\n    SHOW_V4_BASE = 17;\n    SHOW_V4_EPISODES_ASSOC = 18;\n    TRACK_DESCRIPTOR_SIGNATURES = 19;\n    PODCAST_AD_SEGMENTS = 20;\n    EPISODE_TRANSCRIPTS = 21;\n    PODCAST_SUBSCRIPTIONS = 22;\n    EXTRACTED_COLOR = 23;\n    PODCAST_VIRALITY = 24;\n    IMAGE_SPARKLES_HACK = 25;\n    PODCAST_POPULARITY_HACK = 26;\n    AUTOMIX_MODE = 27;\n    CUEPOINTS = 28;\n    PODCAST_POLL = 29;\n    EPISODE_ACCESS = 30;\n    SHOW_ACCESS = 31;\n    PODCAST_QNA = 32;\n    CLIPS = 33;\n    SHOW_V5 = 34;\n    EPISODE_V5 = 35;\n    PODCAST_CTA_CARDS = 36;\n    PODCAST_RATING = 37;\n    DISPLAY_SEGMENTS = 38;\n    GREENROOM = 39;\n    USER_CREATED = 40;\n    SHOW_DESCRIPTION = 41;\n    SHOW_HTML_DESCRIPTION = 42;\n    SHOW_PLAYABILITY = 43;\n    EPISODE_DESCRIPTION = 44;\n    EPISODE_HTML_DESCRIPTION = 45;\n    EPISODE_PLAYABILITY = 46;\n    SHOW_EPISODES_ASSOC = 47;\n    CLIENT_CONFIG = 48;\n    PLAYLISTABILITY = 49;\n    AUDIOBOOK_V5 = 50;\n    CHAPTER_V5 = 51;\n    AUDIOBOOK_SPECIFICS = 52;\n    EPISODE_RANKING = 53;\n    HTML_DESCRIPTION = 54;\n    CREATOR_CHANNEL = 55;\n    AUDIOBOOK_PROVIDERS = 56;\n    PLAY_TRAIT = 57;\n    CONTENT_WARNING = 58;\n    IMAGE_CUE = 59;\n    STREAM_COUNT = 60;\n    AUDIO_ATTRIBUTES = 61;\n    NAVIGABLE_TRAIT = 62;\n    NEXT_BEST_EPISODE = 63;\n    AUDIOBOOK_PRICE = 64;\n    EXPRESSIVE_PLAYLISTS = 65;\n    DYNAMIC_SHOW_EPISODE = 66;\n    LIVE = 67;\n    SKIP_PLAYED = 68;\n    AD_BREAK_FREE_PODCASTS = 69;\n    ASSOCIATIONS = 70;\n    PLAYLIST_EVALUATION = 71;\n    CACHE_INVALIDATIONS = 72;\n    LIVESTREAM_ENTITY = 73;\n    SINGLE_TAP_REACTIONS = 74;\n    USER_COMMENTS = 75;\n    CLIENT_RESTRICTIONS = 76;\n    PODCAST_GUEST = 77;\n    PLAYABILITY = 78;\n    COVER_IMAGE = 79;\n    SHARE_TRAIT = 80;\n    INSTANCE_SHARING = 81;\n    ARTIST_TOUR = 82;\n    AUDIOBOOK_GENRE = 83;\n    CONCEPT = 84;\n    ORIGINAL_VIDEO = 85;\n    SMART_SHUFFLE = 86;\n    LIVE_EVENTS = 87;\n    AUDIOBOOK_RELATIONS = 88;\n    HOME_POC_BASECARD = 89;\n    AUDIOBOOK_SUPPLEMENTS = 90;\n    PAID_PODCAST_BANNER = 91;\n    FEWER_ADS = 92;\n    WATCH_FEED_SHOW_EXPLORER = 93;\n    TRACK_EXTRA_DESCRIPTORS = 94;\n    TRACK_EXTRA_AUDIO_ATTRIBUTES = 95;\n    TRACK_EXTENDED_CREDITS = 96;\n    SIMPLE_TRAIT = 97;\n    AUDIO_ASSOCIATIONS = 98;\n    VIDEO_ASSOCIATIONS = 99;\n    PLAYLIST_TUNER = 100;\n    ARTIST_VIDEOS_ENTRYPOINT = 101;\n    ALBUM_PRERELEASE = 102;\n    CONTENT_ALTERNATIVES = 103;\n    SNAPSHOT_SHARING = 105;\n    DISPLAY_SEGMENTS_COUNT = 106;\n    PODCAST_FEATURED_EPISODE = 107;\n    PODCAST_SPONSORED_CONTENT = 108;\n    PODCAST_EPISODE_TOPICS_LLM = 109;\n    PODCAST_EPISODE_TOPICS_KG = 110;\n    EPISODE_RANKING_POPULARITY = 111;\n    MERCH = 112;\n    COMPANION_CONTENT = 113;\n    WATCH_FEED_ENTITY_EXPLORER = 114;\n    ANCHOR_CARD_TRAIT = 115;\n    AUDIO_PREVIEW_PLAYBACK_TRAIT = 116;\n    VIDEO_PREVIEW_STILL_TRAIT = 117;\n    PREVIEW_CARD_TRAIT = 118;\n    SHORTCUTS_CARD_TRAIT = 119;\n    VIDEO_PREVIEW_PLAYBACK_TRAIT = 120;\n    COURSE_SPECIFICS = 121;\n    CONCERT = 122;\n    CONCERT_LOCATION = 123;\n    CONCERT_MARKETING = 124;\n    CONCERT_PERFORMERS = 125;\n    TRACK_PAIR_TRANSITION = 126;\n    CONTENT_TYPE_TRAIT = 127;\n    NAME_TRAIT = 128;\n    ARTWORK_TRAIT = 129;\n    RELEASE_DATE_TRAIT = 130;\n    CREDITS_TRAIT = 131;\n    RELEASE_URI_TRAIT = 132;\n    ENTITY_CAPPING = 133;\n    LESSON_SPECIFICS = 134;\n    CONCERT_OFFERS = 135;\n    TRANSITION_MAPS = 136;\n    ARTIST_HAS_CONCERTS = 137;\n    PRERELEASE = 138;\n    PLAYLIST_ATTRIBUTES_V2 = 139;\n    LIST_ATTRIBUTES_V2 = 140;\n    LIST_METADATA = 141;\n    LIST_TUNER_AUDIO_ANALYSIS = 142;\n    LIST_TUNER_CUEPOINTS = 143;\n    CONTENT_RATING_TRAIT = 144;\n    COPYRIGHT_TRAIT = 145;\n    SUPPORTED_BADGES = 146;\n    BADGES = 147;\n    PREVIEW_TRAIT = 148;\n    ROOTLISTABILITY_TRAIT = 149;\n    LOCAL_CONCERTS = 150;\n    RECOMMENDED_PLAYLISTS = 151;\n    POPULAR_RELEASES = 152;\n    RELATED_RELEASES = 153;\n    SHARE_RESTRICTIONS = 154;\n    CONCERT_OFFER = 155;\n    CONCERT_OFFER_PROVIDER = 156;\n    ENTITY_BOOKMARKS = 157;\n    PRIVACY_TRAIT = 158;\n    DUPLICATE_ITEMS_TRAIT = 159;\n    REORDERING_TRAIT = 160;\n    PODCAST_RESUMPTION_SEGMENTS = 161;\n    ARTIST_EXPRESSION_VIDEO = 162;\n    PRERELEASE_VIDEO = 163;\n    GATED_ENTITY_RELATIONS = 164;\n    RELATED_CREATORS_SECTION = 165;\n    CREATORS_APPEARS_ON_SECTION = 166;\n    PROMO_V1_TRAIT = 167;\n    SPEECHLESS_SHARE_CARD = 168;\n    TOP_PLAYABLES_SECTION = 169;\n    AUTO_LENS = 170;\n    PROMO_V3_TRAIT = 171;\n    TRACK_CONTENT_FILTER = 172;\n    HIGHLIGHTABILITY = 173;\n    LINK_CARD_WITH_IMAGE_TRAIT = 174;\n    TRACK_CLOUD_SECTION = 175;\n    EPISODE_TOPICS = 176;\n    VIDEO_THUMBNAIL = 177;\n    IDENTITY_TRAIT = 178;\n    VISUAL_IDENTITY_TRAIT = 179;\n    CONTENT_TYPE_V2_TRAIT = 180;\n    PREVIEW_PLAYBACK_TRAIT = 181;\n    CONSUMPTION_EXPERIENCE_TRAIT = 182;\n    PUBLISHING_METADATA_TRAIT = 183;\n    DETAILED_EVALUATION_TRAIT = 184;\n    ON_PLATFORM_REPUTATION_TRAIT = 185;\n    CREDITS_V2_TRAIT = 186;\n    HIGHLIGHT_PLAYABILITY_TRAIT = 187;\n    SHOW_EPISODE_LIST = 188;\n    AVAILABLE_RELEASES = 189;\n    PLAYLIST_DESCRIPTORS = 190;\n    LINK_CARD_WITH_ANIMATIONS_TRAIT = 191;\n    RECAP = 192;\n    AUDIOBOOK_COMPANION_CONTENT = 193;\n    THREE_OH_THREE_PLAY_TRAIT = 194;\n    ARTIST_WRAPPED_2024_VIDEO = 195;\n}\n\n"
  },
  {
    "path": "protocol/proto/extracted_colors.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.context_track_color;\n\nmessage ColorResult {\n    Color color_raw = 1;\n    Color color_light = 2;\n    Color color_dark = 3;\n    Status status = 5;\n}\n\nmessage Color {\n    int32 rgb = 1;\n    bool is_fallback = 2;\n}\n\nenum Status {\n    OK = 0;\n    IN_PROGRESS = 1;\n    INVALID_URL = 2;\n    INTERNAL = 3;\n}\n"
  },
  {
    "path": "protocol/proto/follow_request.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.socialgraph_esperanto.proto;\n\nimport \"socialgraph_response_status.proto\";\n\noption objc_class_prefix = \"ESP\";\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"spotify.socialgraph.esperanto.proto\";\n\nmessage FollowRequest {\n    repeated string username = 1;\n    bool follow = 2;\n}\n\nmessage FollowRequestV4 {\n    string username = 1;\n    bool follow = 2;\n}\n\nmessage FollowResponse {\n    ResponseStatus status = 1;\n}\n"
  },
  {
    "path": "protocol/proto/followed_users_request.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.socialgraph_esperanto.proto;\n\nimport \"socialgraph_response_status.proto\";\n\noption objc_class_prefix = \"ESP\";\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"spotify.socialgraph.esperanto.proto\";\n\nmessage FollowedUsersRequest {\n    bool force_reload = 1;\n}\n\nmessage FollowedUsersResponse {\n    ResponseStatus status = 1;\n    repeated string users = 2;\n}\n"
  },
  {
    "path": "protocol/proto/frecency.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.frecency.v1;\n\nimport \"google/protobuf/timestamp.proto\";\n\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_outer_classname = \"FrecencyProto\";\noption java_package = \"com.spotify.frecency.v1\";\n\nmessage FrecencyResponse {\n    repeated PlayContext play_contexts = 1;\n}\n\nmessage PlayContext {\n    string uri = 1;\n    Frecency frecency = 2;\n}\n\nmessage Frecency {\n    double ln_frecency = 1;\n    int32 event_count = 2;\n    google.protobuf.Timestamp last_event_time = 3;\n}\n"
  },
  {
    "path": "protocol/proto/frecency_storage.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.frecency.proto.storage;\n\noption cc_enable_arenas = true;\noption optimize_for = CODE_SIZE;\n\nmessage Frecency {\n    optional double ln_frecency = 1;\n    optional uint64 event_count = 2;\n    optional uint32 event_kind = 3;\n    optional uint64 last_event_time = 4;\n}\n\nmessage ContextFrecencyInfo {\n    optional string context_uri = 1;\n    repeated Frecency context_frecencies = 2;\n}\n\nmessage ContextFrecencyFile {\n    repeated ContextFrecencyInfo contexts = 1;\n    optional uint64 frecency_version = 2;\n}\n"
  },
  {
    "path": "protocol/proto/gabito.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage EventEnvelope {\n    string event_name = 2;\n\n    repeated EventFragment event_fragment = 3;\n    message EventFragment {\n        string name = 1;\n        bytes data = 2;\n    }\n\n    bytes sequence_id = 4;\n    int64 sequence_number = 5;\n\n    reserved 1;\n}\n\nmessage PublishEventsRequest {\n    repeated EventEnvelope event = 1;\n    bool suppress_persist = 2;\n}\n\nmessage PublishEventsResponse {\n    message EventError {\n        int32 index = 1;\n        bool transient = 2;\n        int32 reason = 3;\n    }\n\n    repeated EventError error = 1;\n}\n"
  },
  {
    "path": "protocol/proto/global_node.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.player.proto;\n\nimport \"context_player_options.proto\";\nimport \"pause_resume_origin.proto\";\nimport \"player_license.proto\";\n\noption optimize_for = CODE_SIZE;\n\nmessage GlobalNode {\n    optional ContextPlayerOptions options = 1;\n    optional PlayerLicense license = 2;\n    map<string, string> configuration = 3;\n    optional PauseResumeOrigin pause_resume_origin = 4;\n    optional bool is_paused = 5;\n}\n"
  },
  {
    "path": "protocol/proto/google/protobuf/any.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage google.protobuf;\n\noption csharp_namespace = \"Google.Protobuf.WellKnownTypes\";\noption objc_class_prefix = \"GPB\";\noption go_package = \"google.golang.org/protobuf/types/known/anypb\";\noption java_multiple_files = true;\noption java_outer_classname = \"AnyProto\";\noption java_package = \"com.google.protobuf\";\n\nmessage Any {\n    string type_url = 1;\n    bytes value = 2;\n}\n"
  },
  {
    "path": "protocol/proto/google/protobuf/descriptor.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage google.protobuf;\n\noption csharp_namespace = \"Google.Protobuf.Reflection\";\noption objc_class_prefix = \"GPB\";\noption cc_enable_arenas = true;\noption go_package = \"google.golang.org/protobuf/types/descriptorpb\";\noption optimize_for = SPEED;\noption java_outer_classname = \"DescriptorProtos\";\noption java_package = \"com.google.protobuf\";\n\nmessage FileDescriptorSet {\n    repeated FileDescriptorProto file = 1;\n}\n\nmessage FileDescriptorProto {\n    optional string name = 1;\n    optional string package = 2;\n    repeated string dependency = 3;\n    repeated int32 public_dependency = 10;\n    repeated int32 weak_dependency = 11;\n    repeated DescriptorProto message_type = 4;\n    repeated EnumDescriptorProto enum_type = 5;\n    repeated ServiceDescriptorProto service = 6;\n    repeated FieldDescriptorProto extension = 7;\n    optional FileOptions options = 8;\n    optional SourceCodeInfo source_code_info = 9;\n    optional string syntax = 12;\n    optional Edition edition = 14;\n}\n\nmessage DescriptorProto {\n    optional string name = 1;\n    repeated FieldDescriptorProto field = 2;\n    repeated FieldDescriptorProto extension = 6;\n    repeated DescriptorProto nested_type = 3;\n    repeated EnumDescriptorProto enum_type = 4;\n\n    repeated ExtensionRange extension_range = 5;\n    message ExtensionRange {\n        optional int32 start = 1;\n        optional int32 end = 2;\n        optional ExtensionRangeOptions options = 3;\n    }\n\n    repeated OneofDescriptorProto oneof_decl = 8;\n    optional MessageOptions options = 7;\n\n    repeated ReservedRange reserved_range = 9;\n    message ReservedRange {\n        optional int32 start = 1;\n        optional int32 end = 2;\n    }\n\n    repeated string reserved_name = 10;\n}\n\nmessage ExtensionRangeOptions {\n    message Declaration {\n        reserved 4;\n        optional int32 number = 1;\n        optional string full_name = 2;\n        optional string type = 3;\n        optional bool reserved = 5;\n        optional bool repeated = 6;\n    }\n\n    enum VerificationState {\n        DECLARATION = 0;\n        UNVERIFIED = 1;\n    }\n\n    repeated UninterpretedOption uninterpreted_option = 999;\n    repeated Declaration declaration = 2;\n    optional FeatureSet features = 50;\n    optional VerificationState verification = 3 [default = UNVERIFIED];\n}\n\nmessage FieldDescriptorProto {\n    optional string name = 1;\n    optional int32 number = 3;\n\n    optional Label label = 4;\n    enum Label {\n        LABEL_OPTIONAL = 1;\n        LABEL_REPEATED = 3;\n        LABEL_REQUIRED = 2;\n    }\n\n    optional Type type = 5;\n    enum Type {\n        TYPE_DOUBLE = 1;\n        TYPE_FLOAT = 2;\n        TYPE_INT64 = 3;\n        TYPE_UINT64 = 4;\n        TYPE_INT32 = 5;\n        TYPE_FIXED64 = 6;\n        TYPE_FIXED32 = 7;\n        TYPE_BOOL = 8;\n        TYPE_STRING = 9;\n        TYPE_GROUP = 10;\n        TYPE_MESSAGE = 11;\n        TYPE_BYTES = 12;\n        TYPE_UINT32 = 13;\n        TYPE_ENUM = 14;\n        TYPE_SFIXED32 = 15;\n        TYPE_SFIXED64 = 16;\n        TYPE_SINT32 = 17;\n        TYPE_SINT64 = 18;\n    }\n\n    optional string type_name = 6;\n    optional string extendee = 2;\n    optional string default_value = 7;\n    optional int32 oneof_index = 9;\n    optional string json_name = 10;\n    optional FieldOptions options = 8;\n    optional bool proto3_optional = 17;\n}\n\nmessage OneofDescriptorProto {\n    optional string name = 1;\n    optional OneofOptions options = 2;\n}\n\nmessage EnumDescriptorProto {\n   optional string name = 1;\n    repeated EnumValueDescriptorProto value = 2;\n    optional EnumOptions options = 3;\n\n    repeated EnumReservedRange reserved_range = 4;\n    message EnumReservedRange {\n        optional int32 start = 1;\n        optional int32 end = 2;\n    }\n\n    repeated string reserved_name = 5;\n}\n\nmessage EnumValueDescriptorProto {\n    optional string name = 1;\n    optional int32 number = 2;\n    optional EnumValueOptions options = 3;\n}\n\nmessage ServiceDescriptorProto {\n    optional string name = 1;\n    repeated MethodDescriptorProto method = 2;\n    optional ServiceOptions options = 3;\n}\n\nmessage MethodDescriptorProto {\n    optional string name = 1;\n    optional string input_type = 2;\n    optional string output_type = 3;\n    optional MethodOptions options = 4;\n    optional bool client_streaming = 5 [default = false];\n    optional bool server_streaming = 6 [default = false];\n}\n\nmessage FileOptions {\n    optional string java_package = 1;\n    optional string java_outer_classname = 8;\n    optional bool java_multiple_files = 10 [default = false];\n    optional bool java_generate_equals_and_hash = 20;\n    optional bool java_string_check_utf8 = 27 [default = false];\n\n    optional OptimizeMode optimize_for = 9 [default = SPEED];\n    enum OptimizeMode {\n        SPEED = 1;\n        CODE_SIZE = 2;\n        LITE_RUNTIME = 3;\n    }\n\n    optional string go_package = 11;\n    optional bool cc_generic_services = 16 [default = false];\n    optional bool java_generic_services = 17 [default = false];\n    optional bool py_generic_services = 18 [default = false];\n    optional bool php_generic_services = 42 [default = false];\n    optional bool deprecated = 23 [default = false];\n    optional bool cc_enable_arenas = 31 [default = true];\n    optional string objc_class_prefix = 36;\n    optional string csharp_namespace = 37;\n    optional string swift_prefix = 39;\n    optional string php_class_prefix = 40;\n    optional string php_namespace = 41;\n    optional string php_metadata_namespace = 44;\n    optional string ruby_package = 45;\n    optional FeatureSet features = 50;\n    repeated UninterpretedOption uninterpreted_option = 999;\n\n    reserved 38;\n}\n\nmessage MessageOptions {\n    optional bool message_set_wire_format = 1 [default = false];\n    optional bool no_standard_descriptor_accessor = 2 [default = false];\n    optional bool deprecated = 3 [default = false];\n    optional bool map_entry = 7;\n    optional bool deprecated_legacy_json_field_conflicts = 11;\n    optional FeatureSet features = 12;\n    repeated UninterpretedOption uninterpreted_option = 999;\n\n    reserved 4, 5, 6, 8, 9;\n}\n\nmessage FieldOptions {\n    optional CType ctype = 1 [default = STRING];\n    enum CType {\n        STRING = 0;\n        CORD = 1;\n        STRING_PIECE = 2;\n    }\n\n    optional bool packed = 2;\n\n    optional JSType jstype = 6 [default = JS_NORMAL];\n    enum JSType {\n        JS_NORMAL = 0;\n        JS_STRING = 1;\n        JS_NUMBER = 2;\n    }\n\n    optional bool lazy = 5 [default = false];\n    optional bool unverified_lazy = 15 [default = false];\n    optional bool deprecated = 3 [default = false];\n    optional bool weak = 10 [default = false];\n    optional bool debug_redact = 16 [default = false];\n\n    optional OptionRetention retention = 17;\n    enum OptionRetention {\n        RETENTION_UNKNOWN = 0;\n        RETENTION_RUNTIME = 1;\n        RETENTION_SOURCE = 2;\n    }\n\n    repeated OptionTargetType targets = 19;\n    enum OptionTargetType {\n        TARGET_TYPE_UNKNOWN = 0;\n        TARGET_TYPE_FILE = 1;\n        TARGET_TYPE_EXTENSION_RANGE = 2;\n        TARGET_TYPE_MESSAGE = 3;\n        TARGET_TYPE_FIELD = 4;\n        TARGET_TYPE_ONEOF = 5;\n        TARGET_TYPE_ENUM = 6;\n        TARGET_TYPE_ENUM_ENTRY = 7;\n        TARGET_TYPE_SERVICE = 8;\n        TARGET_TYPE_METHOD = 9;\n    }\n\n\n    repeated EditionDefault edition_defaults = 20;\n    message EditionDefault {\n        optional Edition edition = 3;\n        optional string value = 2;\n    }\n\n    optional FeatureSet features = 21;\n    repeated UninterpretedOption uninterpreted_option = 999;\n\n    reserved 4, 18;\n}\n\nmessage OneofOptions {\n    optional FeatureSet features = 1;\n    repeated UninterpretedOption uninterpreted_option = 999;\n}\n\nmessage EnumOptions {\n    optional bool allow_alias = 2;\n    optional bool deprecated = 3 [default = false];\n    optional bool deprecated_legacy_json_field_conflicts = 6;\n    optional FeatureSet features = 7;\n    repeated UninterpretedOption uninterpreted_option = 999;\n\n    reserved 5;\n}\n\nmessage EnumValueOptions {\n    optional bool deprecated = 1 [default = false];\n    optional FeatureSet features = 2;\n    optional bool debug_redact = 3 [default = false];\n    repeated UninterpretedOption uninterpreted_option = 999;\n}\n\nmessage ServiceOptions {\n    optional FeatureSet features = 34;\n    optional bool deprecated = 33 [default = false];\n    repeated UninterpretedOption uninterpreted_option = 999;\n}\n\nmessage MethodOptions {\n    optional bool deprecated = 33 [default = false];\n\n    optional IdempotencyLevel idempotency_level = 34 [default = IDEMPOTENCY_UNKNOWN];\n    enum IdempotencyLevel {\n        IDEMPOTENCY_UNKNOWN = 0;\n        NO_SIDE_EFFECTS = 1;\n        IDEMPOTENT = 2;\n    }\n\n    optional FeatureSet features = 35;\n    repeated UninterpretedOption uninterpreted_option = 999;\n}\n\nmessage UninterpretedOption {\n    message NamePart {\n        required string name_part = 1;\n        required bool is_extension = 2;\n    }\n\n    repeated UninterpretedOption.NamePart name = 2;\n    optional string identifier_value = 3;\n    optional uint64 positive_int_value = 4;\n    optional int64 negative_int_value = 5;\n    optional double double_value = 6;\n    optional bytes string_value = 7;\n    optional string aggregate_value = 8;\n}\n\nmessage FeatureSet {\n    reserved 999;\n    enum FieldPresence {\n        FIELD_PRESENCE_UNKNOWN = 0;\n        EXPLICIT = 1;\n        IMPLICIT = 2;\n        LEGACY_REQUIRED = 3;\n    }\n\n    enum EnumType {\n        ENUM_TYPE_UNKNOWN = 0;\n        OPEN = 1;\n        CLOSED = 2;\n    }\n\n    enum RepeatedFieldEncoding {\n        REPEATED_FIELD_ENCODING_UNKNOWN = 0;\n        PACKED = 1;\n        EXPANDED = 2;\n    }\n\n    enum Utf8Validation {\n        UTF8_VALIDATION_UNKNOWN = 0;\n        NONE = 1;\n        VERIFY = 2;\n    }\n\n    enum MessageEncoding {\n        MESSAGE_ENCODING_UNKNOWN = 0;\n        LENGTH_PREFIXED = 1;\n        DELIMITED = 2;\n    }\n\n    enum JsonFormat {\n        JSON_FORMAT_UNKNOWN = 0;\n        ALLOW = 1;\n        LEGACY_BEST_EFFORT = 2;\n    }\n\n    optional FieldPresence field_presence = 1;\n    optional EnumType enum_type = 2;\n    optional RepeatedFieldEncoding repeated_field_encoding = 3;\n    optional Utf8Validation utf8_validation = 4;\n    optional MessageEncoding message_encoding = 5;\n    optional JsonFormat json_format = 6;\n}\n\nmessage FeatureSetDefaults {\n    message FeatureSetEditionDefault {\n        optional Edition edition = 3;\n        optional FeatureSet features = 2;\n    }\n\n    repeated FeatureSetDefaults.FeatureSetEditionDefault defaults = 1;\n    optional Edition minimum_edition = 4;\n    optional Edition maximum_edition = 5;\n}\n\nmessage SourceCodeInfo {\n    message Location {\n        repeated int32 path = 1;\n        repeated int32 span = 2;\n        optional string leading_comments = 3;\n        optional string trailing_comments = 4;\n        repeated string leading_detached_comments = 6;\n    }\n\n    repeated Location location = 1;\n}\n\nmessage GeneratedCodeInfo {\n    message Annotation {\n        enum Semantic {\n            NONE = 0;\n            SET = 1;\n            ALIAS = 2;\n        }\n\n        repeated int32 path = 1;\n        optional string source_file = 2;\n        optional int32 begin = 3;\n        optional int32 end = 4;\n        optional Annotation.Semantic semantic = 5;\n    }\n\n    repeated Annotation annotation = 1;\n}\n\nenum Edition {\n    EDITION_UNKNOWN = 0;\n    EDITION_PROTO2 = 998;\n    EDITION_PROTO3 = 999;\n    EDITION_2023 = 1000;\n    EDITION_1_TEST_ONLY = 1;\n    EDITION_2_TEST_ONLY = 2;\n    EDITION_99997_TEST_ONLY = 99997;\n    EDITION_99998_TEST_ONLY = 99998;\n    EDITION_99999_TEST_ONLY = 99999;\n}\n\n"
  },
  {
    "path": "protocol/proto/google/protobuf/duration.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage google.protobuf;\n\noption csharp_namespace = \"Google.Protobuf.WellKnownTypes\";\noption objc_class_prefix = \"GPB\";\noption cc_enable_arenas = true;\noption go_package = \"google.golang.org/protobuf/types/known/durationpb\";\noption java_multiple_files = true;\noption java_outer_classname = \"DurationProto\";\noption java_package = \"com.google.protobuf\";\n\nmessage Duration {\n    int64 seconds = 1;\n    int32 nanos = 2;\n}\n"
  },
  {
    "path": "protocol/proto/google/protobuf/empty.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage google.protobuf;\n\noption csharp_namespace = \"Google.Protobuf.WellKnownTypes\";\noption objc_class_prefix = \"GPB\";\noption cc_enable_arenas = true;\noption go_package = \"google.golang.org/protobuf/types/known/emptypb\";\noption java_multiple_files = true;\noption java_outer_classname = \"EmptyProto\";\noption java_package = \"com.google.protobuf\";\n\nmessage Empty {\n\n}\n"
  },
  {
    "path": "protocol/proto/google/protobuf/field_mask.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage google.protobuf;\n\noption csharp_namespace = \"Google.Protobuf.WellKnownTypes\";\noption objc_class_prefix = \"GPB\";\noption cc_enable_arenas = true;\noption go_package = \"google.golang.org/protobuf/types/known/fieldmaskpb\";\noption java_multiple_files = true;\noption java_outer_classname = \"FieldMaskProto\";\noption java_package = \"com.google.protobuf\";\n\nmessage FieldMask {\n    repeated string paths = 1;\n}\n"
  },
  {
    "path": "protocol/proto/google/protobuf/source_context.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage google.protobuf;\n\noption csharp_namespace = \"Google.Protobuf.WellKnownTypes\";\noption objc_class_prefix = \"GPB\";\noption go_package = \"google.golang.org/protobuf/types/known/sourcecontextpb\";\noption java_multiple_files = true;\noption java_outer_classname = \"SourceContextProto\";\noption java_package = \"com.google.protobuf\";\n\nmessage SourceContext {\n    string file_name = 1;\n}\n"
  },
  {
    "path": "protocol/proto/google/protobuf/timestamp.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage google.protobuf;\n\noption csharp_namespace = \"Google.Protobuf.WellKnownTypes\";\noption objc_class_prefix = \"GPB\";\noption cc_enable_arenas = true;\noption go_package = \"google.golang.org/protobuf/types/known/timestamppb\";\noption java_multiple_files = true;\noption java_outer_classname = \"TimestampProto\";\noption java_package = \"com.google.protobuf\";\n\nmessage Timestamp {\n    int64 seconds = 1;\n    int32 nanos = 2;\n}\n"
  },
  {
    "path": "protocol/proto/google/protobuf/type.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage google.protobuf;\n\nimport \"google/protobuf/any.proto\";\nimport \"google/protobuf/source_context.proto\";\n\noption csharp_namespace = \"Google.Protobuf.WellKnownTypes\";\noption objc_class_prefix = \"GPB\";\noption cc_enable_arenas = true;\noption go_package = \"google.golang.org/protobuf/types/known/typepb\";\noption java_multiple_files = true;\noption java_outer_classname = \"TypeProto\";\noption java_package = \"com.google.protobuf\";\n\nmessage Type {\n    string name = 1;\n    repeated Field fields = 2;\n    repeated string oneofs = 3;\n    repeated Option options = 4;\n    SourceContext source_context = 5;\n    Syntax syntax = 6;\n    string edition = 7;\n}\n\nmessage Field {\n    enum Kind {\n        TYPE_UNKNOWN = 0;\n        TYPE_DOUBLE = 1;\n        TYPE_FLOAT = 2;\n        TYPE_INT64 = 3;\n        TYPE_UINT64 = 4;\n        TYPE_INT32 = 5;\n        TYPE_FIXED64 = 6;\n        TYPE_FIXED32 = 7;\n        TYPE_BOOL = 8;\n        TYPE_STRING = 9;\n        TYPE_GROUP = 10;\n        TYPE_MESSAGE = 11;\n        TYPE_BYTES = 12;\n        TYPE_UINT32 = 13;\n        TYPE_ENUM = 14;\n        TYPE_SFIXED32 = 15;\n        TYPE_SFIXED64 = 16;\n        TYPE_SINT32 = 17;\n        TYPE_SINT64 = 18;\n    }\n\n    enum Cardinality {\n        CARDINALITY_UNKNOWN = 0;\n        CARDINALITY_OPTIONAL = 1;\n        CARDINALITY_REQUIRED = 2;\n        CARDINALITY_REPEATED = 3;\n    }\n\n    Kind kind = 1;\n    Cardinality cardinality = 2;\n    int32 number = 3;\n    string name = 4;\n    string type_url = 6;\n    int32 oneof_index = 7;\n    bool packed = 8;\n    repeated Option options = 9;\n    string json_name = 10;\n    string default_value = 11;\n}\n\nmessage Enum {\n    string name = 1;\n    repeated EnumValue enumvalue = 2;\n    repeated Option options = 3;\n    SourceContext source_context = 4;\n    Syntax syntax = 5;\n    string edition = 6;\n}\n\nmessage EnumValue {\n    string name = 1;\n    int32 number = 2;\n    repeated Option options = 3;\n}\n\nmessage Option {\n    string name = 1;\n    Any value = 2;\n}\n\nenum Syntax {\n    SYNTAX_PROTO2 = 0;\n    SYNTAX_PROTO3 = 1;\n    SYNTAX_EDITIONS = 2;\n}\n"
  },
  {
    "path": "protocol/proto/google/protobuf/wrappers.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage google.protobuf;\n\noption csharp_namespace = \"Google.Protobuf.WellKnownTypes\";\noption objc_class_prefix = \"GPB\";\noption cc_enable_arenas = true;\noption go_package = \"google.golang.org/protobuf/types/known/wrapperspb\";\noption java_multiple_files = true;\noption java_outer_classname = \"WrappersProto\";\noption java_package = \"com.google.protobuf\";\n\nmessage DoubleValue {\n    double value = 1;\n}\n\nmessage FloatValue {\n    float value = 1;\n}\n\nmessage Int64Value {\n    int64 value = 1;\n}\n\nmessage UInt64Value {\n    uint64 value = 1;\n}\n\nmessage Int32Value {\n    int32 value = 1;\n}\n\nmessage UInt32Value {\n    uint32 value = 1;\n}\n\nmessage BoolValue {\n    bool value = 1;\n}\n\nmessage StringValue {\n    string value = 1;\n}\n\nmessage BytesValue {\n    bytes value = 1;\n}\n"
  },
  {
    "path": "protocol/proto/greenroom_extension.proto",
    "content": "// Extracted from: Spotify 1.1.73.517 (macOS)\n\nsyntax = \"proto3\";\n\npackage spotify.greenroom.api.extendedmetadata.v1;\n\noption objc_class_prefix = \"SPT\";\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_outer_classname = \"GreenroomMetadataProto\";\noption java_package = \"com.spotify.greenroom.api.extendedmetadata.v1.proto\";\n\nmessage GreenroomSection {\n    repeated GreenroomItem items = 1;\n}\n\nmessage GreenroomItem {\n    string title = 1;\n    string description = 2;\n    repeated GreenroomHost hosts = 3;\n    int64 start_timestamp = 4;\n    string deeplink_url = 5;\n    bool live = 6;\n}\n\nmessage GreenroomHost {\n    string name = 1;\n    string image_url = 2;\n}\n"
  },
  {
    "path": "protocol/proto/identity.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.identity.v3;\n\nimport \"google/protobuf/field_mask.proto\";\nimport \"google/protobuf/wrappers.proto\";\n\noption optimize_for = CODE_SIZE;\noption java_outer_classname = \"IdentityV3\";\noption java_package = \"com.spotify.identity.proto.v3\";\n\nmessage Image {\n    int32 max_width = 1;\n    int32 max_height = 2;\n    string url = 3;\n}\n\nmessage UserProfile {\n    google.protobuf.StringValue username = 1;\n    google.protobuf.StringValue name = 2;\n    repeated Image images = 3;\n    google.protobuf.BoolValue verified = 4;\n    google.protobuf.BoolValue edit_profile_disabled = 5;\n    google.protobuf.BoolValue report_abuse_disabled = 6;\n    google.protobuf.BoolValue abuse_reported_name = 7;\n    google.protobuf.BoolValue abuse_reported_image = 8;\n    google.protobuf.BoolValue has_spotify_name = 9;\n    google.protobuf.BoolValue has_spotify_image = 10;\n    google.protobuf.Int32Value color = 11;\n    google.protobuf.BoolValue is_private = 12;\n    google.protobuf.StringValue pronouns = 13;\n    google.protobuf.StringValue location = 14;\n    google.protobuf.StringValue bio = 15;\n    google.protobuf.BoolValue abuse_reported_bio = 17;\n    google.protobuf.BoolValue edit_name_disabled = 18;\n    google.protobuf.BoolValue edit_image_disabled = 19;\n    google.protobuf.BoolValue edit_bio_disabled = 20;\n    google.protobuf.BoolValue is_kid = 21;\n}\n\nmessage UserProfileUpdateRequest {\n    google.protobuf.FieldMask mask = 1;\n    UserProfile user_profile = 2;\n    bool skip_emit_events = 3;\n}\n\nmessage UserProfileChangedEvent {\n    string userid = 1;\n    UserProfile user_profile = 2;\n}\n\n"
  },
  {
    "path": "protocol/proto/image-resolve.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto3\";\n\npackage spotify.imageresolve.proto;\n\noption java_multiple_files = true;\noption java_outer_classname = \"ImageResolveProtos\";\noption java_package = \"com.spotify.imageresolve.proto\";\n\nmessage Collection {\n    bytes id = 1;\n    \n    repeated Projection projections = 2;\n    message Projection {\n        bytes id = 2;\n        int32 metadata_index = 3;\n        int32 url_template_index = 4;\n    }\n}\n\nmessage ProjectionMetadata {\n    int32 width = 2;\n    int32 height = 3;\n    bool fetch_online = 4;\n    bool download_for_offline = 5;\n}\n\nmessage ProjectionMap {\n    repeated string url_templates = 1;\n    repeated ProjectionMetadata projection_metas = 2;\n    repeated Collection collections = 3;\n}\n"
  },
  {
    "path": "protocol/proto/installation_data.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage InstallationEntity {\n    int32 file_format_version = 1;\n    bytes encrypted_part = 2;\n}\n\nmessage InstallationData {\n    bytes installation_id = 1;\n    bytes last_seen_device_id = 2;\n    int64 monotonic_clock_id = 3;\n}\n"
  },
  {
    "path": "protocol/proto/instrumentation_params.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.player.proto.transfer;\n\noption optimize_for = CODE_SIZE;\n\nmessage InstrumentationParams {\n    repeated string interaction_ids = 6;\n    repeated string page_instance_ids = 7;\n}\n"
  },
  {
    "path": "protocol/proto/keyexchange.proto",
    "content": "syntax = \"proto2\";\n\nmessage ClientHello {\n    required BuildInfo build_info = 0xa; \n    repeated Fingerprint fingerprints_supported = 0x14; \n    repeated Cryptosuite cryptosuites_supported = 0x1e; \n    repeated Powscheme powschemes_supported = 0x28; \n    required LoginCryptoHelloUnion login_crypto_hello = 0x32; \n    required bytes client_nonce = 0x3c; \n    optional bytes padding = 0x46; \n    optional FeatureSet feature_set = 0x50; \n}\n\n\nmessage BuildInfo {\n    required Product product = 0xa; \n    repeated ProductFlags product_flags = 0x14; \n    required Platform platform = 0x1e; \n    required uint64 version = 0x28; \n}\n\nenum Product {\n    PRODUCT_CLIENT = 0x0;\n    PRODUCT_LIBSPOTIFY= 0x1;\n    PRODUCT_MOBILE = 0x2;\n    PRODUCT_PARTNER = 0x3;\n    PRODUCT_LIBSPOTIFY_EMBEDDED = 0x5;\n}\n\nenum ProductFlags {\n    PRODUCT_FLAG_NONE = 0x0;\n    PRODUCT_FLAG_DEV_BUILD = 0x1;\n}\n\nenum Platform {\n    PLATFORM_WIN32_X86 = 0x0;\n    PLATFORM_OSX_X86 = 0x1;\n    PLATFORM_LINUX_X86 = 0x2;\n    PLATFORM_IPHONE_ARM = 0x3;\n    PLATFORM_S60_ARM = 0x4;\n    PLATFORM_OSX_PPC = 0x5;\n    PLATFORM_ANDROID_ARM = 0x6;\n    PLATFORM_WINDOWS_CE_ARM = 0x7;\n    PLATFORM_LINUX_X86_64 = 0x8;\n    PLATFORM_OSX_X86_64 = 0x9;\n    PLATFORM_PALM_ARM = 0xa;\n    PLATFORM_LINUX_SH = 0xb;\n    PLATFORM_FREEBSD_X86 = 0xc;\n    PLATFORM_FREEBSD_X86_64 = 0xd;\n    PLATFORM_BLACKBERRY_ARM = 0xe;\n    PLATFORM_SONOS = 0xf;\n    PLATFORM_LINUX_MIPS = 0x10;\n    PLATFORM_LINUX_ARM = 0x11;\n    PLATFORM_LOGITECH_ARM = 0x12;\n    PLATFORM_LINUX_BLACKFIN = 0x13;\n    PLATFORM_WP7_ARM = 0x14;\n    PLATFORM_ONKYO_ARM = 0x15;\n    PLATFORM_QNXNTO_ARM = 0x16;\n    PLATFORM_BCO_ARM = 0x17;\n    PLATFORM_WEBPLAYER = 0x18;\n    PLATFORM_WP8_ARM = 0x19;\n    PLATFORM_WP8_X86 = 0x1a;\n    PLATFORM_WINRT_ARM = 0x1b;\n    PLATFORM_WINRT_X86 = 0x1c;\n    PLATFORM_WINRT_X86_64 = 0x1d;\n    PLATFORM_FRONTIER = 0x1e;\n    PLATFORM_AMIGA_PPC = 0x1f;\n    PLATFORM_NANRADIO_NRX901 = 0x20;\n    PLATFORM_HARMAN_ARM = 0x21;\n    PLATFORM_SONY_PS3 = 0x22;\n    PLATFORM_SONY_PS4 = 0x23;\n    PLATFORM_IPHONE_ARM64 = 0x24;\n    PLATFORM_RTEMS_PPC = 0x25;\n    PLATFORM_GENERIC_PARTNER = 0x26;\n    PLATFORM_WIN32_X86_64 = 0x27;\n    PLATFORM_WATCHOS = 0x28;\n}\n\nenum Fingerprint {\n    FINGERPRINT_GRAIN = 0x0;\n    FINGERPRINT_HMAC_RIPEMD = 0x1;\n}\n\nenum Cryptosuite {\n    CRYPTO_SUITE_SHANNON = 0x0;\n    CRYPTO_SUITE_RC4_SHA1_HMAC = 0x1;\n}\n\nenum Powscheme {\n    POW_HASH_CASH = 0x0;\n}\n\n\nmessage LoginCryptoHelloUnion {\n    optional LoginCryptoDiffieHellmanHello diffie_hellman = 0xa; \n}\n\n\nmessage LoginCryptoDiffieHellmanHello {\n    required bytes gc = 0xa; \n    required uint32 server_keys_known = 0x14; \n}\n\n\nmessage FeatureSet {\n    optional bool autoupdate2 = 0x1; \n    optional bool current_location = 0x2; \n}\n\n\nmessage APResponseMessage {\n    optional APChallenge challenge = 0xa; \n    optional UpgradeRequiredMessage upgrade = 0x14; \n    optional APLoginFailed login_failed = 0x1e; \n}\n\nmessage APChallenge {\n    required LoginCryptoChallengeUnion login_crypto_challenge = 0xa; \n    required FingerprintChallengeUnion fingerprint_challenge = 0x14; \n    required PoWChallengeUnion pow_challenge = 0x1e; \n    required CryptoChallengeUnion crypto_challenge = 0x28; \n    required bytes server_nonce = 0x32; \n    optional bytes padding = 0x3c; \n}\n\nmessage LoginCryptoChallengeUnion {\n    optional LoginCryptoDiffieHellmanChallenge diffie_hellman = 0xa; \n}\n\nmessage LoginCryptoDiffieHellmanChallenge {\n    required bytes gs = 0xa; \n    required int32 server_signature_key = 0x14; \n    required bytes gs_signature = 0x1e; \n}\n\nmessage FingerprintChallengeUnion {\n    optional FingerprintGrainChallenge grain = 0xa; \n    optional FingerprintHmacRipemdChallenge hmac_ripemd = 0x14; \n}\n\n\nmessage FingerprintGrainChallenge {\n    required bytes kek = 0xa; \n}\n\n\nmessage FingerprintHmacRipemdChallenge {\n    required bytes challenge = 0xa; \n}\n\n\nmessage PoWChallengeUnion {\n    optional PoWHashCashChallenge hash_cash = 0xa; \n}\n\nmessage PoWHashCashChallenge {\n    optional bytes prefix = 0xa; \n    optional int32 length = 0x14; \n    optional int32 target = 0x1e; \n}\n\n\nmessage CryptoChallengeUnion {\n    optional CryptoShannonChallenge shannon = 0xa; \n    optional CryptoRc4Sha1HmacChallenge rc4_sha1_hmac = 0x14; \n}\n\n\nmessage CryptoShannonChallenge {\n}\n\n\nmessage CryptoRc4Sha1HmacChallenge {\n}\n\n\nmessage UpgradeRequiredMessage {\n    required bytes upgrade_signed_part = 0xa; \n    required bytes signature = 0x14; \n    optional string http_suffix = 0x1e; \n}\n\nmessage APLoginFailed {\n    required ErrorCode error_code = 0xa; \n    optional int32 retry_delay = 0x14; \n    optional int32 expiry = 0x1e; \n    optional string error_description = 0x28; \n}\n\nenum ErrorCode {\n    ProtocolError = 0x0;\n    TryAnotherAP = 0x2;\n    BadConnectionId = 0x5;\n    TravelRestriction = 0x9;\n    PremiumAccountRequired = 0xb;\n    BadCredentials = 0xc;\n    CouldNotValidateCredentials = 0xd;\n    AccountExists = 0xe;\n    ExtraVerificationRequired = 0xf;\n    InvalidAppKey = 0x10;\n    ApplicationBanned = 0x11;\n}\n\nmessage ClientResponsePlaintext {\n    required LoginCryptoResponseUnion login_crypto_response = 0xa; \n    required PoWResponseUnion pow_response = 0x14; \n    required CryptoResponseUnion crypto_response = 0x1e; \n}\n\n\nmessage LoginCryptoResponseUnion {\n    optional LoginCryptoDiffieHellmanResponse diffie_hellman = 0xa; \n}\n\n\nmessage LoginCryptoDiffieHellmanResponse {\n    required bytes hmac = 0xa; \n}\n\n\nmessage PoWResponseUnion {\n    optional PoWHashCashResponse hash_cash = 0xa; \n}\n\n\nmessage PoWHashCashResponse {\n    required bytes hash_suffix = 0xa; \n}\n\n\nmessage CryptoResponseUnion {\n    optional CryptoShannonResponse shannon = 0xa; \n    optional CryptoRc4Sha1HmacResponse rc4_sha1_hmac = 0x14; \n}\n\n\nmessage CryptoShannonResponse {\n    optional int32 dummy = 0x1; \n}\n\n\nmessage CryptoRc4Sha1HmacResponse {\n    optional int32 dummy = 0x1; \n}\n\n"
  },
  {
    "path": "protocol/proto/lens-model.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.lens.model.proto;\n\noption java_package = \"com.spotify.lens.model.proto\";\noption java_outer_classname = \"LensModelProto\";\noption optimize_for = CODE_SIZE;\n\nmessage Lens {\n  string identifier = 1;\n}\n\nmessage LensState {\n  string identifier = 1;\n  bytes revision = 2;\n}\n\n"
  },
  {
    "path": "protocol/proto/lfs_secret_provider.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.lfssecretprovider.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage GetSecretResponse {\n    bytes secret = 1;\n}\n"
  },
  {
    "path": "protocol/proto/liked_songs_tags_sync_state.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.collection.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage TagsSyncState {\n    string uri = 1;\n    bool sync_is_complete = 2;\n}\n"
  },
  {
    "path": "protocol/proto/listen_later_cosmos_response.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.listen_later_cosmos.proto;\n\n\nimport \"collection/episode_collection_state.proto\";\nimport \"metadata/episode_metadata.proto\";\nimport \"played_state/episode_played_state.proto\";\nimport \"sync/episode_sync_state.proto\";\n\noption java_package = \"spotify.listen_later_cosmos.proto\";\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\n\nmessage Episode {\n    optional string header = 1;\n    optional cosmos_util.proto.EpisodeMetadata episode_metadata = 2;\n    optional cosmos_util.proto.EpisodeCollectionState episode_collection_state = 3;\n    optional cosmos_util.proto.EpisodeSyncState episode_offline_state = 4;\n    optional cosmos_util.proto.EpisodePlayState episode_played_state = 5;\n}\n\nmessage EpisodesResponse {\n    optional uint32 unfiltered_length = 1;\n    optional uint32 unranged_length = 2;\n    repeated Episode episode = 3;\n    optional string offline_availability = 5;\n    optional uint32 offline_progress = 6;\n    optional uint32 status_code = 98;\n    optional string error = 99;\n}\n"
  },
  {
    "path": "protocol/proto/local_bans_storage.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.collection.proto.storage;\n\noption optimize_for = CODE_SIZE;\n\nmessage BanItem {\n    required string item_uri = 1;\n    required string context_uri = 2;\n    required int64 timestamp = 3;\n}\n\nmessage LocalBansTimestamp {\n    required int64 timestamp = 1;\n}\n\nmessage Bans {\n    repeated BanItem items = 1;\n}\n"
  },
  {
    "path": "protocol/proto/local_sync_cosmos.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto3\";\n\npackage spotify.local_sync_cosmos.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage GetDevicesResponse {\n    repeated Device devices = 1;\n    message Device {\n        string name = 1;\n        string id = 2;\n        string endpoint = 3;\n    }\n}\n"
  },
  {
    "path": "protocol/proto/local_sync_state.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.local_sync_state.proto;\n\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.local_sync_state.proto\";\n\nmessage State {\n    string safe_secret = 1;\n}\n"
  },
  {
    "path": "protocol/proto/logging_params.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.player.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage LoggingParams {\n    optional int64 command_initiated_time = 1;\n    optional int64 command_received_time = 2;\n    repeated string page_instance_ids = 3;\n    repeated string interaction_ids = 4;\n    optional string device_identifier = 5;\n    optional string command_id = 6;\n}\n"
  },
  {
    "path": "protocol/proto/mdata.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.mdata.proto;\n\nimport \"extension_kind.proto\";\nimport \"google/protobuf/any.proto\";\n\noption cc_enable_arenas = true;\noption optimize_for = CODE_SIZE;\n\nmessage LocalExtensionQuery {\n    extendedmetadata.ExtensionKind extension_kind = 1;\n    repeated string entity_uri = 2;\n}\n\nmessage LocalBatchedEntityRequest {\n    repeated LocalExtensionQuery extension_query = 1;\n}\n\nmessage LocalBatchedExtensionResponse {\n    message ExtensionHeader {\n        bool cache_valid = 1;\n        bool offline_valid = 2;\n        int32 status_code = 3;\n        bool is_empty = 4;\n        int64 cache_expiry_timestamp = 5;\n        int64 offline_expiry_timestamp = 6;\n        string etag = 7;\n    }\n\n    message EntityExtension {\n        string entity_uri = 1;\n        ExtensionHeader header = 2;\n        google.protobuf.Any extension_data = 3;\n    }\n\n    message Extension {\n        extendedmetadata.ExtensionKind extension_kind = 1;\n        repeated EntityExtension entity_extension = 2;\n    }\n\n    repeated Extension extension = 1;\n}\n\n"
  },
  {
    "path": "protocol/proto/mdata_cosmos.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.mdata_cosmos.proto;\n\nimport \"extension_kind.proto\";\n\noption cc_enable_arenas = true;\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.mdata.cosmos.proto\";\n\nmessage InvalidateCacheRequest {\n    extendedmetadata.ExtensionKind extension_kind = 1;\n    repeated string entity_uri = 2;\n}\n\nmessage InvalidateCacheResponse {\n}\n"
  },
  {
    "path": "protocol/proto/mdata_storage.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.mdata.proto.storage;\n\nimport \"extension_kind.proto\";\nimport \"google/protobuf/any.proto\";\n\noption cc_enable_arenas = true;\noption optimize_for = CODE_SIZE;\n\nmessage CacheEntry {\n    extendedmetadata.ExtensionKind kind = 1;\n    google.protobuf.Any extension_data = 2;\n}\n\nmessage CacheInfo {\n    int32 status_code = 1;\n    bool is_empty = 2;\n    uint64 cache_expiry = 3;\n    uint64 offline_expiry = 4;\n    string etag = 5;\n    fixed64 cache_checksum_lo = 6;\n    fixed64 cache_checksum_hi = 7;\n    uint64 last_modified = 8;\n}\n\nmessage OfflineLock {\n    uint64 lock_expiry = 1;\n}\n\nmessage AudioFiles {\n    string file_id = 1;\n}\n\nmessage TrackDescriptor {\n    int32 track_id = 1;\n}\n"
  },
  {
    "path": "protocol/proto/media.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.common.media;\n\noption java_package = \"com.spotify.common.proto\";\noption optimize_for = CODE_SIZE;\n\nenum AudioQuality {\n  DEFAULT = 0;\n  LOW = 1;\n  NORMAL = 2;\n  HIGH = 3;\n  VERY_HIGH = 4;\n  HIFI = 5;\n  HIFI_24 = 6;\n}\n\n"
  },
  {
    "path": "protocol/proto/media_format.proto",
    "content": "// Extracted from: Spotify 1.1.73.517 (macOS)\n\nsyntax = \"proto3\";\n\npackage spotify.stream_reporting_esperanto.proto;\n\noption objc_class_prefix = \"ESP\";\noption java_package = \"com.spotify.stream_reporting_esperanto.proto\";\n\nenum MediaFormat {\n    FORMAT_UNKNOWN = 0;\n    FORMAT_OGG_VORBIS_96 = 1;\n    FORMAT_OGG_VORBIS_160 = 2;\n    FORMAT_OGG_VORBIS_320 = 3;\n    FORMAT_MP3_256 = 4;\n    FORMAT_MP3_320 = 5;\n    FORMAT_MP3_160 = 6;\n    FORMAT_MP3_96 = 7;\n    FORMAT_MP3_160_ENCRYPTED = 8;\n    FORMAT_AAC_24 = 9;\n    FORMAT_AAC_48 = 10;\n    FORMAT_MP4_128 = 11;\n    FORMAT_MP4_128_DUAL = 12;\n    FORMAT_MP4_128_CBCS = 13;\n    FORMAT_MP4_256 = 14;\n    FORMAT_MP4_256_DUAL = 15;\n    FORMAT_MP4_256_CBCS = 16;\n    FORMAT_FLAC_FLAC = 17;\n    FORMAT_MP4_FLAC = 18;\n    FORMAT_MP4_Unknown = 19;\n    FORMAT_MP3_Unknown = 20;\n}\n"
  },
  {
    "path": "protocol/proto/media_manifest.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.media_manifest.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage AudioFile {\n    enum Format {\n        OGG_VORBIS_96 = 0;\n        OGG_VORBIS_160 = 1;\n        OGG_VORBIS_320 = 2;\n        MP3_256 = 3;\n        MP3_320 = 4;\n        MP3_160 = 5;\n        MP3_96 = 6;\n        MP3_160_ENC = 7;\n        AAC_24 = 8;\n        AAC_48 = 9;\n        FLAC_FLAC = 16;\n    }\n}\n\nmessage File {\n    message ExternalFile {\n        string method = 1;\n        bytes body = 4;\n        oneof endpoint {\n            string url = 2;\n            string service = 3;\n        }\n        optional bool disable_range_requests = 5;\n    }\n\n    message FileIdFile {\n        string file_id_hex = 1;\n        AudioFile.Format download_format = 2;\n        EncryptionType encryption = 3;\n    }\n\n    message NormalizationParams {\n        float loudness_db = 1;\n        float true_peak_db = 2;\n    }\n\n    int32 bitrate = 3;\n    string mime_type = 4;\n    oneof file {\n        ExternalFile external_file = 1;\n        FileIdFile file_id_file = 2;\n    }\n    optional NormalizationParams normalization_params = 5;\n}\n\nmessage Files {\n    repeated File files = 1;\n}\n\nenum EncryptionType {\n    NONE = 0;\n    AES = 1;\n}\n"
  },
  {
    "path": "protocol/proto/media_type.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.stream_reporting_esperanto.proto;\n\noption objc_class_prefix = \"ESP\";\noption java_package = \"com.spotify.stream_reporting_esperanto.proto\";\n\nenum MediaType {\n    AUDIO = 0;\n    VIDEO = 1;\n    MEDIA_TYPE_AUDIO = 0;\n    MEDIA_TYPE_VIDEO = 1;\n    MEDIA_TYPE_UNKNOWN = 2;\n}\n"
  },
  {
    "path": "protocol/proto/media_type_node.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.player.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage MediaTypeNode {\n    optional string current_uri = 1;\n    optional string media_type = 2;\n    optional string media_manifest_id = 3;\n}\n"
  },
  {
    "path": "protocol/proto/members_request.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.playlist.cosmos.proto;\n\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.playlist.proto\";\n\nmessage OptionalLimit {\n    uint32 value = 1;\n}\n\nmessage PlaylistMembersRequest {\n    string uri = 1;\n    OptionalLimit limit = 2;\n}\n"
  },
  {
    "path": "protocol/proto/members_response.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.playlist.cosmos.proto;\n\nimport \"playlist_permission.proto\";\nimport \"playlist_user_state.proto\";\n\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.playlist.proto\";\n\nmessage Member {\n    optional User user = 1;\n    optional bool is_owner = 2;\n    optional uint32 num_tracks = 3;\n    optional uint32 num_episodes = 4;\n    optional FollowState follow_state = 5;\n    optional playlist_permission.proto.PermissionLevel permission_level = 6;\n}\n\nmessage PlaylistMembersResponse {\n    optional string title = 1;\n    optional uint32 num_total_members = 2;\n    optional playlist_permission.proto.Capabilities capabilities = 3;\n    optional playlist_permission.proto.PermissionLevel base_permission_level = 4;\n    repeated Member members = 5;\n}\n\nenum FollowState {\n    NONE = 0;\n    CAN_BE_FOLLOWED = 1;\n    CAN_BE_UNFOLLOWED = 2;\n}\n"
  },
  {
    "path": "protocol/proto/mercury.proto",
    "content": "syntax = \"proto2\";\n\nmessage MercuryMultiGetRequest {\n    repeated MercuryRequest request = 0x1;\n}\n\nmessage MercuryMultiGetReply {\n    repeated MercuryReply reply = 0x1;\n}\n\nmessage MercuryRequest {\n    optional string uri = 0x1;\n    optional string content_type = 0x2;\n    optional bytes body = 0x3;\n    optional bytes etag = 0x4;\n}\n\nmessage MercuryReply {\n    optional sint32 status_code = 0x1;\n    optional string status_message = 0x2;\n    optional CachePolicy cache_policy = 0x3;\n    enum CachePolicy {\n        CACHE_NO = 0x1;\n        CACHE_PRIVATE = 0x2;\n        CACHE_PUBLIC = 0x3;\n    }\n    optional sint32 ttl = 0x4;\n    optional bytes etag = 0x5;\n    optional string content_type = 0x6;\n    optional bytes body = 0x7;\n}\n\n\nmessage Header {\n    optional string uri = 0x01;\n    optional string content_type = 0x02;\n    optional string method = 0x03;\n    optional sint32 status_code = 0x04;\n    repeated UserField user_fields = 0x06;\n}\n\nmessage UserField {\n    optional string key = 0x01;\n    optional bytes value = 0x02;\n}\n\n"
  },
  {
    "path": "protocol/proto/messages/discovery/force_discover.proto",
    "content": "// Extracted from: Spotify 1.1.73.517 (macOS)\n\nsyntax = \"proto3\";\n\npackage spotify.connect.esperanto.proto;\n\noption java_package = \"com.spotify.connect.esperanto.proto\";\n\nmessage ForceDiscoverRequest {\n    \n}\n\nmessage ForceDiscoverResponse {\n    \n}\n"
  },
  {
    "path": "protocol/proto/messages/discovery/start_discovery.proto",
    "content": "// Extracted from: Spotify 1.1.73.517 (macOS)\n\nsyntax = \"proto3\";\n\npackage spotify.connect.esperanto.proto;\n\noption java_package = \"com.spotify.connect.esperanto.proto\";\n\nmessage StartDiscoveryRequest {\n    \n}\n\nmessage StartDiscoveryResponse {\n    \n}\n"
  },
  {
    "path": "protocol/proto/metadata/album_metadata.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.cosmos_util.proto;\n\nimport \"metadata/image_group.proto\";\n\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.cosmos.util.proto\";\n\nmessage AlbumArtistMetadata {\n    optional string link = 1;\n    optional string name = 2;\n}\n\nmessage AlbumMetadata {\n    repeated AlbumArtistMetadata artists = 1;\n    optional string link = 2;\n    optional string name = 3;\n    repeated string copyright = 4;\n    optional ImageGroup covers = 5;\n    optional uint32 year = 6;\n    optional uint32 num_discs = 7;\n    optional uint32 num_tracks = 8;\n    optional bool playability = 9;\n    optional bool is_premium_only = 10;\n}\n"
  },
  {
    "path": "protocol/proto/metadata/artist_metadata.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.cosmos_util.proto;\n\nimport \"metadata/image_group.proto\";\n\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.cosmos.util.proto\";\n\nmessage ArtistMetadata {\n    optional string link = 1;\n    optional string name = 2;\n    optional bool is_various_artists = 3;\n    optional ImageGroup portraits = 4;\n}\n"
  },
  {
    "path": "protocol/proto/metadata/episode_metadata.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.cosmos_util.proto;\n\nimport \"metadata/extension.proto\";\nimport \"metadata/image_group.proto\";\n\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.cosmos.util.proto\";\n\nmessage EpisodeShowMetadata {\n    optional string link = 1;\n    optional string name = 2;\n    optional string publisher = 3;\n    optional ImageGroup covers = 4;\n}\n\nmessage EpisodeMetadata {\n    reserved 20;\n    reserved 21;\n\n    optional EpisodeShowMetadata show = 1;\n    optional string link = 2;\n    optional string name = 3;\n    optional uint32 length = 4;\n    optional ImageGroup covers = 5;\n    optional string manifest_id = 6;\n    optional string description = 7;\n    optional int64 publish_date = 8;\n    optional ImageGroup freeze_frames = 9;\n    optional string language = 10;\n    optional bool available = 11;\n\n    optional MediaType media_type_enum = 12;\n    enum MediaType {\n        VODCAST = 0;\n        AUDIO = 1;\n        VIDEO = 2;\n    }\n\n    optional int32 number = 13;\n    optional bool backgroundable = 14;\n    optional string preview_manifest_id = 15;\n    optional bool is_explicit = 16;\n    optional string preview_id = 17;\n\n    optional EpisodeType episode_type = 18;\n    enum EpisodeType {\n        UNKNOWN = 0;\n        FULL = 1;\n        TRAILER = 2;\n        BONUS = 3;\n    }\n\n    optional bool is_music_and_talk = 19;\n    repeated Extension extension = 22;\n    optional bool is_19_plus_only = 23;\n    optional bool is_book_chapter = 24;\n    optional bool is_podcast_short = 25;\n    optional bool is_curated = 26;\n}\n\n"
  },
  {
    "path": "protocol/proto/metadata/extension.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.cosmos_util.proto;\n\nimport \"extension_kind.proto\";\n\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.cosmos.util.proto\";\n\nmessage Extension {\n    optional extendedmetadata.ExtensionKind extension_kind = 1;\n    optional bytes data = 2;\n}\n"
  },
  {
    "path": "protocol/proto/metadata/image_group.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.cosmos_util.proto;\n\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.cosmos.util.proto\";\n\nmessage ImageGroup {\n    optional string standard_link = 1;\n    optional string small_link = 2;\n    optional string large_link = 3;\n    optional string xlarge_link = 4;\n}\n"
  },
  {
    "path": "protocol/proto/metadata/show_metadata.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.cosmos_util.proto;\n\nimport \"metadata/extension.proto\";\nimport \"metadata/image_group.proto\";\n\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.cosmos.util.proto\";\n\nmessage ShowMetadata {\n    optional string link = 1;\n    optional string name = 2;\n    optional string description = 3;\n    optional uint32 popularity = 4;\n    optional string publisher = 5;\n    optional string language = 6;\n    optional bool is_explicit = 7;\n    optional ImageGroup covers = 8;\n    optional uint32 num_episodes = 9;\n    optional string consumption_order = 10;\n    optional int32 media_type_enum = 11;\n    repeated string copyright = 12;\n    optional string trailer_uri = 13;\n    optional bool is_music_and_talk = 14;\n    repeated Extension extension = 15;\n    optional bool is_book = 16;\n    optional bool is_creator_channel = 17;\n}\n"
  },
  {
    "path": "protocol/proto/metadata/track_metadata.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.cosmos_util.proto;\n\nimport \"metadata/extension.proto\";\nimport \"metadata/image_group.proto\";\n\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.cosmos.util.proto\";\n\nmessage TrackAlbumArtistMetadata {\n    optional string link = 1;\n    optional string name = 2;\n}\n\nmessage TrackAlbumMetadata {\n    optional TrackAlbumArtistMetadata artist = 1;\n    optional string link = 2;\n    optional string name = 3;\n    optional ImageGroup covers = 4;\n}\n\nmessage TrackArtistMetadata {\n    optional string link = 1;\n    optional string name = 2;\n    optional ImageGroup portraits = 3;\n}\n\nmessage TrackDescriptor {\n    optional string name = 1;\n    optional float weight = 2;\n}\n\nmessage TrackMetadata {\n    optional TrackAlbumMetadata album = 1;\n    repeated TrackArtistMetadata artist = 2;\n    optional string link = 3;\n    optional string name = 4;\n    optional uint32 length = 5;\n    optional bool playable = 6;\n    optional uint32 disc_number = 7;\n    optional uint32 track_number = 8;\n    optional bool is_explicit = 9;\n    optional string preview_id = 10;\n    optional bool is_local = 11;\n    optional bool playable_local_track = 12;\n    optional bool has_lyrics = 13;\n    optional bool is_premium_only = 14;\n    optional bool locally_playable = 15;\n    optional string playable_track_link = 16;\n    optional uint32 popularity = 17;\n    optional bool is_19_plus_only = 18;\n    repeated TrackDescriptor track_descriptors = 19;\n    repeated Extension extension = 20;\n    optional bool is_curated = 21;\n    optional bool to_be_obfuscated = 22;\n}\n"
  },
  {
    "path": "protocol/proto/metadata.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.metadata;\n\noption optimize_for = CODE_SIZE;\noption java_outer_classname = \"Metadata\";\noption java_package = \"com.spotify.metadata.proto\";\n\nmessage Artist {\n    reserved 9;\n    optional bytes gid = 1;\n    optional string name = 2;\n    optional sint32 popularity = 3;\n    repeated TopTracks top_track = 4;\n    repeated AlbumGroup album_group = 5;\n    repeated AlbumGroup single_group = 6;\n    repeated AlbumGroup compilation_group = 7;\n    repeated AlbumGroup appears_on_group = 8;\n    repeated ExternalId external_id = 10;\n    repeated Image portrait = 11;\n    repeated Biography biography = 12;\n    repeated ActivityPeriod activity_period = 13;\n    repeated Restriction restriction = 14;\n    repeated Artist related = 15;\n    optional bool is_portrait_album_cover = 16;\n    optional ImageGroup portrait_group = 17;\n    repeated SalePeriod sale_period = 18;\n    repeated Availability availability = 20;\n}\n\nmessage Album {\n    reserved 8;\n\n    optional bytes gid = 1;\n    optional string name = 2;\n    repeated Artist artist = 3;\n\n    optional Type type = 4;\n    enum Type {\n        ALBUM = 1;\n        SINGLE = 2;\n        COMPILATION = 3;\n        EP = 4;\n        AUDIOBOOK = 5;\n        PODCAST = 6;\n    }\n\n    optional string label = 5;\n    optional Date date = 6;\n    optional sint32 popularity = 7;\n    repeated Image cover = 9;\n    repeated ExternalId external_id = 10;\n    repeated Disc disc = 11;\n    repeated string review = 12;\n    repeated Copyright copyright = 13;\n    repeated Restriction restriction = 14;\n    repeated Album related = 15;\n    repeated SalePeriod sale_period = 16;\n    optional ImageGroup cover_group = 17;\n    optional string original_title = 18;\n    optional string version_title = 19;\n    optional string type_str = 20;\n    repeated Availability availability = 23;\n}\n\nmessage Track {\n    optional bytes gid = 1;\n    optional string name = 2;\n    optional Album album = 3;\n    repeated Artist artist = 4;\n    optional sint32 number = 5;\n    optional sint32 disc_number = 6;\n    optional sint32 duration = 7;\n    optional sint32 popularity = 8;\n    optional bool explicit = 9;\n    repeated ExternalId external_id = 10;\n    repeated Restriction restriction = 11;\n    repeated AudioFile file = 12;\n    repeated Track alternative = 13;\n    repeated SalePeriod sale_period = 14;\n    repeated AudioFile preview = 15;\n    repeated string tags = 16;\n    optional int64 earliest_live_timestamp = 17;\n    optional bool has_lyrics = 18;\n    repeated Availability availability = 19;\n    optional Licensor licensor = 21;\n    repeated string language_of_performance = 22;\n    optional Audio original_audio = 24;\n    repeated ContentRating content_rating = 25;\n    optional string original_title = 27;\n    optional string version_title = 28;\n    repeated ArtistWithRole artist_with_role = 32;\n    optional string canonical_uri = 36;\n    repeated Video original_video = 38;\n}\n\nmessage ArtistWithRole {\n    enum ArtistRole {\n        ARTIST_ROLE_UNKNOWN = 0;\n        ARTIST_ROLE_MAIN_ARTIST = 1;\n        ARTIST_ROLE_FEATURED_ARTIST = 2;\n        ARTIST_ROLE_REMIXER = 3;\n        ARTIST_ROLE_ACTOR = 4;\n        ARTIST_ROLE_COMPOSER = 5;\n        ARTIST_ROLE_CONDUCTOR = 6;\n        ARTIST_ROLE_ORCHESTRA = 7;\n    }\n\n    optional bytes artist_gid = 1;\n    optional string artist_name = 2;\n    optional ArtistRole role = 3;\n}\n\nmessage Show {\n    optional bytes gid = 1;\n    optional string name = 2;\n    optional string description = 64;\n    optional sint32 deprecated_popularity = 65;\n    optional string publisher = 66;\n    optional string language = 67;\n    optional bool explicit = 68;\n    optional ImageGroup cover_image = 69;\n    repeated Episode episode = 70;\n    repeated Copyright copyright = 71;\n    repeated Restriction restriction = 72;\n    repeated string keyword = 73;\n\n    optional MediaType media_type = 74;\n    enum MediaType {\n        MIXED = 0;\n        AUDIO = 1;\n        VIDEO = 2;\n    }\n\n    optional ConsumptionOrder consumption_order = 75;\n    enum ConsumptionOrder {\n        SEQUENTIAL = 1;\n        EPISODIC = 2;\n        RECENT = 3;\n    }\n\n    repeated Availability availability = 78;\n    optional string trailer_uri = 83;\n    optional bool music_and_talk = 85;\n    optional bool is_audiobook = 89;\n    optional bool is_creator_channel = 90;\n}\n\nmessage Episode {\n    optional bytes gid = 1;\n    optional string name = 2;\n    optional sint32 duration = 7;\n    repeated AudioFile audio = 12;\n    optional string description = 64;\n    optional sint32 number = 65;\n    optional Date publish_time = 66;\n    optional sint32 deprecated_popularity = 67;\n    optional ImageGroup cover_image = 68;\n    optional string language = 69;\n    optional bool explicit = 70;\n    optional Show show = 71;\n    repeated VideoFile video = 72;\n    repeated VideoFile video_preview = 73;\n    repeated AudioFile audio_preview = 74;\n    repeated Restriction restriction = 75;\n    optional ImageGroup freeze_frame = 76;\n    repeated string keyword = 77;\n    optional bool allow_background_playback = 81;\n    repeated Availability availability = 82;\n    optional string external_url = 83;\n    optional Audio original_audio = 84;\n\n    optional Episode.EpisodeType type = 87;\n    enum EpisodeType {\n        FULL = 0;\n        TRAILER = 1;\n        BONUS = 2;\n    }\n\n    optional bool music_and_talk = 91;\n    repeated ContentRating content_rating = 95;\n    optional bool is_audiobook_chapter = 96;\n    optional bool is_podcast_short = 97;\n}\n\nmessage Licensor {\n    optional bytes uuid = 1;\n}\n\nmessage Audio {\n    optional bytes uuid = 1;\n}\n\nmessage TopTracks {\n    optional string country = 1;\n    repeated Track track = 2;\n}\n\nmessage ActivityPeriod {\n    optional sint32 start_year = 1;\n    optional sint32 end_year = 2;\n    optional sint32 decade = 3;\n}\n\nmessage AlbumGroup {\n    repeated Album album = 1;\n}\n\nmessage Date {\n    optional sint32 year = 1;\n    optional sint32 month = 2;\n    optional sint32 day = 3;\n    optional sint32 hour = 4;\n    optional sint32 minute = 5;\n}\n\nmessage Image {\n    enum Size {\n        DEFAULT = 0;\n        SMALL = 1;\n        LARGE = 2;\n        XLARGE = 3;\n    }\n\n    optional bytes file_id = 1;\n    optional Size size = 2;\n    optional sint32 width = 3;\n    optional sint32 height = 4;\n}\n\nmessage ImageGroup {\n    repeated Image image = 1;\n}\n\nmessage Biography {\n    optional string text = 1;\n    repeated Image portrait = 2;\n    repeated ImageGroup portrait_group = 3;\n}\n\nmessage Disc {\n    optional sint32 number = 1;\n    optional string name = 2;\n    repeated Track track = 3;\n}\n\nmessage Copyright {\n    enum Type {\n        P = 0;\n        C = 1;\n    }\n\n    optional Type type = 1;\n    optional string text = 2;\n}\n\nmessage Restriction {\n    enum Catalogue {\n        AD = 0;\n        SUBSCRIPTION = 1;\n        CATALOGUE_ALL = 2;\n        SHUFFLE = 3;\n        COMMERCIAL = 4;\n    }\n\n    enum Type {\n        STREAMING = 0;\n    }\n\n    repeated Catalogue catalogue = 1;\n    optional Type type = 4;\n    repeated string catalogue_str = 5;\n    oneof country_restriction {\n        string countries_allowed = 2;\n        string countries_forbidden = 3;\n    }\n}\n\nmessage Availability {\n    repeated string catalogue_str = 1;\n    optional Date start = 2;\n}\n\nmessage SalePeriod {\n    repeated Restriction restriction = 1;\n    optional Date start = 2;\n    optional Date end = 3;\n}\n\nmessage ExternalId {\n    optional string type = 1;\n    optional string id = 2;\n}\n\nmessage AudioFile {\n    enum Format {\n        OGG_VORBIS_96 = 0;\n        OGG_VORBIS_160 = 1;\n        OGG_VORBIS_320 = 2;\n        MP3_256 = 3;\n        MP3_320 = 4;\n        MP3_160 = 5;\n        MP3_96 = 6;\n        MP3_160_ENC = 7;\n        AAC_24 = 8;\n        AAC_48 = 9;\n        FLAC_FLAC = 16;\n        XHE_AAC_24 = 18;\n        XHE_AAC_16 = 19;\n        XHE_AAC_12 = 20;\n        FLAC_FLAC_24BIT = 22;\n    }\n\n    optional bytes file_id = 1;\n    optional Format format = 2;\n}\n\nmessage Video {\n    optional bytes gid = 1;\n}\n\nmessage VideoFile {\n    optional bytes file_id = 1;\n}\n\nmessage ContentRating {\n    optional string country = 1;\n    repeated string tag = 2;\n}\n"
  },
  {
    "path": "protocol/proto/metadata_cosmos.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.metadata_cosmos.proto;\n\nimport \"metadata.proto\";\n\noption optimize_for = CODE_SIZE;\noption java_outer_classname = \"MetadataCosmos\";\noption java_package = \"com.spotify.metadata.cosmos.proto\";\n\nmessage MetadataItem {\n    oneof item {\n        sint32 error = 1;\n        metadata.Artist artist = 2;\n        metadata.Album album = 3;\n        metadata.Track track = 4;\n        metadata.Show show = 5;\n        metadata.Episode episode = 6;\n    }\n}\n\nmessage MultiResponse {\n    repeated MetadataItem items = 1;\n}\n\nmessage MultiRequest {\n    repeated string uris = 1;\n}\n"
  },
  {
    "path": "protocol/proto/metadata_esperanto.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.metadata_esperanto.proto;\n\nimport \"metadata_cosmos.proto\";\n\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.metadata.esperanto.proto\";\n\nservice ClassicMetadataService {\n    rpc GetEntity(GetEntityRequest) returns (GetEntityResponse);\n    rpc MultigetEntity(metadata_cosmos.proto.MultiRequest) returns (metadata_cosmos.proto.MultiResponse);\n}\n\nmessage GetEntityRequest {\n    string uri = 1;\n}\n\nmessage GetEntityResponse {\n    metadata_cosmos.proto.MetadataItem item = 1;\n}\n"
  },
  {
    "path": "protocol/proto/mod.rs",
    "content": "// generated protobuf files will be included here. See build.rs for details\ninclude!(env!(\"PROTO_MOD_RS\"));\n"
  },
  {
    "path": "protocol/proto/modification_request.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.playlist.cosmos.proto;\n\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.playlist.proto\";\n\nmessage ModificationRequest {\n    optional string operation = 1;\n    optional string before = 2;\n    optional string after = 3;\n    optional string name = 4;\n    optional bool playlist = 5;\n\n    optional Attributes attributes = 6;\n    message Attributes {\n        optional bool published = 1;\n        optional bool collaborative = 2;\n        optional string name = 3;\n        optional string description = 4;\n        optional string imageUri = 5;\n        optional string picture = 6;\n        optional string ai_curation_reference_id = 7;\n        optional PublishedState published_state = 8;\n    }\n\n    repeated string uris = 7;\n    repeated string rows = 8;\n    optional bool contents = 9;\n    optional string item_id = 10;\n    repeated ListAttributeKind attributes_to_clear = 11;\n    optional CreateItemKind create_item_kind = 12;\n}\n\nmessage ModificationResponse {\n    optional bool success = 1;\n    optional string uri = 2;\n}\n\nenum ListAttributeKind {\n    LIST_UNKNOWN = 0;\n    LIST_NAME = 1;\n    LIST_DESCRIPTION = 2;\n    LIST_PICTURE = 3;\n    LIST_COLLABORATIVE = 4;\n    LIST_PL3_VERSION = 5;\n    LIST_DELETED_BY_OWNER = 6;\n    LIST_CLIENT_ID = 10;\n    LIST_FORMAT = 11;\n    LIST_FORMAT_ATTRIBUTES = 12;\n    LIST_PICTURE_SIZE = 13;\n    LIST_SEQUENCE_CONTEXT_TEMPLATE = 14;\n    LIST_AI_CURATION_REFERENCE_ID = 15;\n}\n\nenum PublishedState {\n    PUBLISHED_STATE_UNSPECIFIED = 0;\n    PUBLISHED_STATE_NOT_PUBLISHED = 1;\n    PUBLISHED_STATE_PUBLISHED = 2;\n}\n\nenum CreateItemKind {\n    CREATE_ITEM_KIND_UNSPECIFIED = 0;\n    CREATE_ITEM_KIND_PLAYLIST = 1;\n    CREATE_ITEM_KIND_FOLDER = 2;\n}\n\n"
  },
  {
    "path": "protocol/proto/net-fortune.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.netfortune.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage NetFortuneResponse {\n    int32 advised_audio_bitrate = 1;\n}\n\nmessage NetFortuneV2Response {\n    string predict_id = 1;\n    int32 estimated_max_bitrate = 2;\n    optional int32 advised_prefetch_bitrate_metered = 3;\n    optional int32 advised_prefetch_bitrate_non_metered = 4;\n}\n"
  },
  {
    "path": "protocol/proto/offline.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.offline.proto;\n\nimport \"google/protobuf/duration.proto\";\nimport \"google/protobuf/timestamp.proto\";\n\noption optimize_for = CODE_SIZE;\n\nmessage Capacity {\n    double total_space = 1;\n    double free_space = 2;\n    double offline_space = 3;\n    uint64 track_count = 4;\n    uint64 episode_count = 5;\n}\n\nmessage Capabilities {\n    bool remote_downloads_enabled = 1;\n}\n\nmessage Device {\n    string device_id = 1;\n    string cache_id = 2;\n    string name = 3;\n    int32 type = 4;\n    int32 platform = 5;\n    bool offline_enabled = 6;\n    Capacity capacity = 7;\n    Capabilities capabilities = 8;\n    google.protobuf.Timestamp updated_at = 9;\n    google.protobuf.Timestamp last_seen_at = 10;\n    bool removal_pending = 11;\n    string client_id = 12;\n}\n\nmessage Restrictions {\n    google.protobuf.Duration allowed_duration_tracks = 1;\n    uint64 max_tracks = 2;\n    google.protobuf.Duration allowed_duration_episodes = 3;\n    uint64 max_episodes = 4;\n    google.protobuf.Duration allowed_duration_abp_chapters = 5;\n}\n\nmessage Resource {\n    string uri = 1;\n    ResourceState state = 2;\n    int32 progress = 3;\n    google.protobuf.Timestamp updated_at = 4;\n    string failure_message = 5;\n}\n\nmessage DeviceKey {\n    string user_id = 1;\n    string device_id = 2;\n    string cache_id = 3;\n}\n\nmessage ResourceForDevice {\n    string device_id = 1;\n    string cache_id = 2;\n    Resource resource = 3;\n}\n\nmessage ResourceOperation {\n    enum Operation {\n        INVALID = 0;\n        ADD = 1;\n        REMOVE = 2;\n    }\n\n    Operation operation = 2;\n    string uri = 3;\n}\n\nmessage ResourceHistoryItem {\n    repeated ResourceOperation operations = 1;\n    google.protobuf.Timestamp server_time = 2;\n}\n\nenum ResourceState {\n    UNSPECIFIED = 0;\n    REQUESTED = 1;\n    PENDING = 2;\n    DOWNLOADING = 3;\n    DOWNLOADED = 4;\n    FAILURE = 5;\n}\n"
  },
  {
    "path": "protocol/proto/offline_playlists_containing.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.playlist.cosmos.proto;\n\noption objc_class_prefix = \"SPTPlaylist\";\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.playlist.proto\";\n\nmessage OfflinePlaylistContainingItem {\n    required string playlist_link = 1;\n    optional string playlist_name = 2;\n}\n\nmessage OfflinePlaylistsContainingItemResponse {\n    repeated OfflinePlaylistContainingItem playlists = 1;\n}\n"
  },
  {
    "path": "protocol/proto/on_demand_in_free_reason.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.on_demand_set.proto;\n\noption optimize_for = CODE_SIZE;\n\nenum OnDemandInFreeReason {\n    UNKNOWN = 0;\n    NOT_ON_DEMAND = 1;\n    ON_DEMAND = 2;\n    ON_DEMAND_EPISODES_ONLY = 3;\n    ON_DEMAND_NON_MUSIC_ONLY = 4;\n}\n"
  },
  {
    "path": "protocol/proto/on_demand_set_cosmos_request.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.on_demand_set_cosmos.proto;\n\noption objc_class_prefix = \"SPT\";\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.on_demand_set.proto\";\n\nmessage Set {\n    repeated string uris = 1;\n}\n\nmessage Temporary {\n    optional string uri = 1;\n    optional int64 valid_for_in_seconds = 2;\n}\n"
  },
  {
    "path": "protocol/proto/on_demand_set_cosmos_response.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.on_demand_set_cosmos.proto;\n\noption objc_class_prefix = \"SPT\";\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.on_demand_set.proto\";\n\nmessage Response {\n    optional bool success = 1;\n}\n"
  },
  {
    "path": "protocol/proto/on_demand_set_response.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.on_demand_set_esperanto.proto;\n\noption objc_class_prefix = \"ESP\";\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.on_demand_set.proto\";\n\nmessage ResponseStatus {\n    int32 status_code = 1;\n    string reason = 2;\n}\n"
  },
  {
    "path": "protocol/proto/pause_resume_origin.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.player.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage PauseResumeOrigin {\n  optional string feature_identifier = 1;\n}\n\n"
  },
  {
    "path": "protocol/proto/pending_event_entity.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.pending_events.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage PendingEventEntity {\n    string event_name = 1;\n    bytes payload = 2;\n    string username = 3;\n}\n"
  },
  {
    "path": "protocol/proto/perf_metrics_service.proto",
    "content": "// Extracted from: Spotify 1.1.73.517 (macOS)\n\nsyntax = \"proto3\";\n\npackage spotify.perf_metrics.esperanto.proto;\n\noption java_package = \"com.spotify.perf_metrics.esperanto.proto\";\n\nservice PerfMetricsService {\n    rpc TerminateState(PerfMetricsRequest) returns (PerfMetricsResponse);\n}\n\nmessage PerfMetricsRequest {\n    string terminal_state = 1;\n    bool foreground_startup = 2;\n}\n\nmessage PerfMetricsResponse {\n    bool success = 1;\n}\n"
  },
  {
    "path": "protocol/proto/pin_request.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.your_library.proto;\n\noption java_package = \"spotify.your_library.esperanto.proto\";\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\n\nmessage PinRequest {\n    string uri = 1;\n    oneof position {\n        string after_uri = 2;\n        string before_uri = 3;\n        bool first = 4;\n    }\n}\n\nmessage MovePinRequest {\n    string move_uri = 1;\n    oneof position {\n        string after_uri = 2;\n        string before_uri = 3;\n        bool first = 4;\n    }\n}\n\nmessage PinResponse {\n    enum PinStatus {\n        UNKNOWN = 0;\n        PINNED = 1;\n        NOT_PINNED = 2;\n    }\n\n    PinStatus status = 1;\n    bool has_maximum_pinned_items = 2;\n    int32 maximum_pinned_items = 3;\n    uint32 status_code = 98;\n    string error = 99;\n}\n\nmessage PinItem {\n    string uri = 1;\n    bool in_library = 2;\n}\n\n"
  },
  {
    "path": "protocol/proto/play_history.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.player.proto.transfer;\n\noption optimize_for = CODE_SIZE;\n\nmessage PlayHistory {\n  message Item {\n    optional string context_id = 1;\n    optional string uid = 2;\n    optional bool disliked = 3;\n    repeated transfer.PlayHistory.Item children = 4;\n  }\n\n  repeated transfer.PlayHistory.Item backward_items = 1;\n  repeated transfer.PlayHistory.Item forward_items = 2;\n}\n\n"
  },
  {
    "path": "protocol/proto/play_origin.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.player.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage PlayOrigin {\n    optional string feature_identifier = 1;\n    optional string feature_version = 2;\n    optional string view_uri = 3;\n    optional string external_referrer = 4;\n    optional string referrer_identifier = 5;\n    optional string device_identifier = 6;\n    repeated string feature_classes = 7;\n    optional string restriction_identifier = 8;\n}\n"
  },
  {
    "path": "protocol/proto/play_queue_node.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.player.proto;\n\nimport \"context_track.proto\";\nimport \"track_instance.proto\";\nimport \"track_instantiator.proto\";\n\noption optimize_for = CODE_SIZE;\n\nmessage PlayQueueNode {\n    repeated ContextTrack queue = 1;\n    optional TrackInstance instance = 2;\n    optional TrackInstantiator instantiator = 3;\n    optional uint32 next_uid = 4;\n    optional sint32 iteration = 5;\n    optional bool delay_enqueued_tracks = 6;\n}\n"
  },
  {
    "path": "protocol/proto/play_reason.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.stream_reporting_esperanto.proto;\n\noption objc_class_prefix = \"ESP\";\noption java_package = \"com.spotify.stream_reporting_esperanto.proto\";\n\nenum PlayReason {\n    PLAY_REASON_UNKNOWN = 0;\n    PLAY_REASON_APP_LOAD = 1;\n    PLAY_REASON_BACK_BTN = 2;\n    PLAY_REASON_CLICK_ROW = 3;\n    PLAY_REASON_CLICK_SIDE = 4;\n    PLAY_REASON_END_PLAY = 5;\n    PLAY_REASON_FWD_BTN = 6;\n    PLAY_REASON_INTERRUPTED = 7;\n    PLAY_REASON_LOGOUT = 8;\n    PLAY_REASON_PLAY_BTN = 9;\n    PLAY_REASON_POPUP = 10;\n    PLAY_REASON_REMOTE = 11;\n    PLAY_REASON_SONG_DONE = 12;\n    PLAY_REASON_TRACK_DONE = 13;\n    PLAY_REASON_TRACK_ERROR = 14;\n    PLAY_REASON_PREVIEW = 15;\n    PLAY_REASON_URI_OPEN = 16;\n    PLAY_REASON_BACKGROUNDED = 17;\n    PLAY_REASON_OFFLINE = 18;\n    PLAY_REASON_UNEXPECTED_EXIT = 19;\n    PLAY_REASON_UNEXPECTED_EXIT_WHILE_PAUSED = 20;\n    PLAY_REASON_SWITCHED_TO_AUDIO = 21;\n    PLAY_REASON_SWITCHED_TO_VIDEO = 22;\n}\n"
  },
  {
    "path": "protocol/proto/playback.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.player.proto.transfer;\n\nimport \"context_track.proto\";\n\noption optimize_for = CODE_SIZE;\n\nmessage Playback {\n    optional int64 timestamp = 1;\n    optional int32 position_as_of_timestamp = 2;\n    optional double playback_speed = 3;\n    optional bool is_paused = 4;\n    optional ContextTrack current_track = 5;\n    optional ContextTrack associated_current_track = 6;\n    optional int32 associated_position_as_of_timestamp = 7;\n}\n"
  },
  {
    "path": "protocol/proto/playback_cosmos.proto",
    "content": "// Extracted from: Spotify 1.1.73.517 (macOS)\n\nsyntax = \"proto3\";\n\npackage spotify.playback_cosmos.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage VolumeRequest {\n    oneof source_or_system {\n        VolumeChangeSource source = 1;\n        bool system_initiated = 4;\n    }\n    \n    oneof action {\n        double volume = 2;\n        Step step = 3;\n    }\n    \n    enum Step {\n        option allow_alias = true;\n        up = 0;\n        UP = 0;\n        down = 1;\n        DOWN = 1;\n    }\n}\n\nmessage VolumeResponse {\n    double volume = 1;\n}\n\nmessage VolumeSubResponse {\n    double volume = 1;\n    VolumeChangeSource source = 2;\n    bool system_initiated = 3;\n}\n\nmessage PositionResponseV1 {\n    int32 position = 1;\n}\n\nmessage PositionResponseV2 {\n    int64 position = 1;\n}\n\nmessage InfoResponse {\n    bool has_info = 1;\n    uint64 length_ms = 2;\n    uint64 position_ms = 3;\n    bool playing = 4;\n    bool buffering = 5;\n    int32 error = 6;\n    string file_id = 7;\n    string file_type = 8;\n    string resolved_content_url = 9;\n    int32 file_bitrate = 10;\n    string codec_name = 11;\n    double playback_speed = 12;\n    float gain_adjustment = 13;\n    bool has_loudness = 14;\n    float loudness = 15;\n    string strategy = 17;\n    int32 target_bitrate = 18;\n    int32 advised_bitrate = 19;\n    bool target_file_available = 20;\n    \n    reserved 16;\n}\n\nmessage FormatsResponse {\n    repeated Format formats = 1;\n    message Format {\n        string enum_key = 1;\n        uint32 enum_value = 2;\n        bool supported = 3;\n        uint32 bitrate = 4;\n        string mime_type = 5;\n    }\n}\n\nmessage GetFilesResponse {\n    repeated File files = 1;\n    message File {\n        string file_id = 1;\n        string format = 2;\n        uint32 bitrate = 3;\n        uint32 format_enum = 4;\n    }\n}\n\nmessage DuckRequest {\n    Action action = 2;\n    enum Action {\n        START = 0;\n        STOP = 1;\n    }\n    \n    double volume = 3;\n    uint32 fade_duration_ms = 4;\n}\n\nenum VolumeChangeSource {\n    USER = 0;\n    SYSTEM = 1;\n}\n"
  },
  {
    "path": "protocol/proto/playback_esperanto.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.playback_esperanto.proto;\n\noption objc_class_prefix = \"ESP\";\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.playback_esperanto.proto\";\n\nmessage ConnectLoggingParams {\n    repeated string interaction_ids = 1;\n    repeated string page_instance_ids = 2;\n}\n\nmessage GetVolumeResponse {\n    Status status = 1;\n    double volume = 2;\n}\n\nmessage GetRawVolumeResponse {\n    Status status = 1;\n    int32 volume = 2;\n}\n\nmessage SubVolumeResponse {\n    Status status = 1;\n    double volume = 2;\n    VolumeChangeSource source = 3;\n}\n\nmessage SubRawVolumeResponse {\n    Status status = 1;\n    int32 volume = 2;\n    VolumeChangeSource source = 3;\n}\n\nmessage SetVolumeRequest {\n    VolumeChangeSource source = 1;\n    double volume = 2;\n    ConnectLoggingParams connect_logging_params = 3;\n}\n\nmessage SetRawVolumeRequest {\n    VolumeChangeSource source = 1;\n    int32 volume = 2;\n    ConnectLoggingParams connect_logging_params = 3;\n}\n\nmessage NudgeVolumeRequest {\n    VolumeChangeSource source = 1;\n    ConnectLoggingParams connect_logging_params = 2;\n}\n\nmessage PlaybackInfoResponse {\n    reserved 3;\n    reserved 16;\n    Status status = 1;\n    uint64 length_ms = 2;\n    bool playing = 4;\n    bool buffering = 5;\n    int32 error = 6;\n    string file_id = 7;\n    string file_type = 8;\n    string resolved_content_url = 9;\n    int32 file_bitrate = 10;\n    string codec_name = 11;\n    double playback_speed = 12;\n    float gain_adjustment = 13;\n    bool has_loudness = 14;\n    float loudness = 15;\n    string strategy = 17;\n    int32 target_bitrate = 18;\n    int32 advised_bitrate = 19;\n    bool target_file_available = 20;\n    string audio_id = 21;\n}\n\nmessage GetFormatsResponse {\n    message Format {\n        string enum_key = 1;\n        uint32 enum_value = 2;\n        bool supported = 3;\n        uint32 bitrate = 4;\n        string mime_type = 5;\n    }\n\n    repeated GetFormatsResponse.Format formats = 1;\n}\n\nmessage SubPositionRequest {\n    uint64 position = 1;\n}\n\nmessage SubPositionResponse {\n    Status status = 1;\n    uint64 position = 2;\n}\n\nmessage GetFilesRequest {\n    string uri = 1;\n}\n\nmessage GetFilesResponse {\n    message File {\n        string file_id = 1;\n        string format = 2;\n        uint32 bitrate = 3;\n        uint32 format_enum = 4;\n    }\n\n    GetFilesStatus status = 1;\n    repeated File files = 2;\n}\n\nmessage DuckRequest {\n    enum Action {\n        START = 0;\n        STOP = 1;\n    }\n\n    Action action = 2;\n    double volume = 3;\n    uint32 fade_duration_ms = 4;\n}\n\nmessage DuckResponse {\n    Status status = 1;\n}\n\nenum Status {\n    OK = 0;\n    NOT_AVAILABLE = 1;\n}\n\nenum GetFilesStatus {\n    GETFILES_OK = 0;\n    METADATA_CLIENT_NOT_AVAILABLE = 1;\n    FILES_NOT_FOUND = 2;\n    TRACK_NOT_AVAILABLE = 3;\n    EXTENDED_METADATA_ERROR = 4;\n}\n\nenum VolumeChangeSource {\n    USER = 0;\n    SYSTEM = 1;\n    CONNECT = 2;\n}\n"
  },
  {
    "path": "protocol/proto/playback_platform.proto",
    "content": "// Extracted from: Spotify 1.1.73.517 (macOS)\n\nsyntax = \"proto3\";\n\npackage spotify.playback_platform.proto;\n\nimport \"media_manifest.proto\";\n\noption optimize_for = CODE_SIZE;\n\nmessage Media {\n    string id = 1;\n    int32 start_position = 6;\n    int32 stop_position = 7;\n    \n    oneof source {\n        string audio_id = 2;\n        string episode_id = 3;\n        string track_id = 4;\n        media_manifest.proto.Files files = 5;\n    }\n}\n\nmessage Annotation {\n    map<string, string> metadata = 2;\n}\n\nmessage PlaybackControl {\n    \n}\n\nmessage Context {\n    string id = 2;\n    string type = 3;\n    \n    reserved 1;\n}\n\nmessage Timeline {\n    repeated MediaTrack media_tracks = 1;\n    message MediaTrack {\n        repeated Item items = 1;\n        message Item {\n            repeated Annotation annotations = 3;\n            repeated PlaybackControl controls = 4;\n            \n            oneof content {\n                Context context = 1;\n                Media media = 2;\n            }\n        }\n    }\n}\n\nmessage PageId {\n    Context context = 1;\n    int32 index = 2;\n}\n\nmessage PagePath {\n    repeated PageId segments = 1;\n}\n\nmessage Page {\n    Header header = 1;\n    message Header {\n        int32 status_code = 1;\n        int32 num_pages = 2;\n    }\n    \n    PageId page_id = 2;\n    Timeline timeline = 3;\n}\n\nmessage PageList {\n    repeated Page pages = 1;\n}\n\nmessage PageMultiGetRequest {\n    repeated PageId page_ids = 1;\n}\n\nmessage PageMultiGetResponse {\n    repeated Page pages = 1;\n}\n\nmessage ContextPagePathState {\n    PagePath path = 1;\n    repeated int32 media_track_item_index = 3;\n}\n"
  },
  {
    "path": "protocol/proto/playback_segments.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto3\";\n\npackage spotify.podcast_segments.playback;\n\nimport \"podcast_segments.proto\";\n\noption objc_class_prefix = \"SPT\";\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_outer_classname = \"PlaybackSegmentsProto\";\noption java_package = \"com.spotify.podcastsegments.playback.proto\";\n\nmessage PlaybackSegments {\n    repeated PlaybackSegment playback_segments = 1;\n}\n"
  },
  {
    "path": "protocol/proto/playback_stack.proto",
    "content": "syntax = \"proto3\";\n\npackage spotify.stream_reporting_esperanto.proto;\n\noption java_package = \"com.spotify.stream_reporting_esperanto.proto\";\noption objc_class_prefix = \"ESP\";\n\nenum PlaybackStack {\n  BOOMBOX = 0;\n  BETAMAX = 1;\n  UNKNOWN = 2;\n}\n\n"
  },
  {
    "path": "protocol/proto/playback_stack_v2.proto",
    "content": "syntax = \"proto3\";\n\npackage spotify.stream_reporting_esperanto.proto;\n\noption java_package = \"com.spotify.stream_reporting_esperanto.proto\";\noption objc_class_prefix = \"ESP\";\n\nenum PlaybackStackV2 {\n  PLAYBACK_STACK_UNKNOWN = 0;\n  PLAYBACK_STACK_BOOMBOX = 1;\n  PLAYBACK_STACK_BETAMAX = 2;\n  PLAYBACK_STACK_KUBRICK = 3;\n}\n\n"
  },
  {
    "path": "protocol/proto/playback_state.proto",
    "content": "syntax = \"proto3\";\n\npackage spotify.stream_reporting_esperanto.proto;\n\noption java_package = \"com.spotify.stream_reporting_esperanto.proto\";\noption objc_class_prefix = \"ESP\";\n\nenum PlaybackState {\n  ACTIVE = 0;\n  PAUSED = 1;\n  SUSPENDED = 2;\n  INVALID_PLAYBACK_STATE = 3;\n}\n\n"
  },
  {
    "path": "protocol/proto/played_state/episode_played_state.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.cosmos_util.proto;\n\nimport \"played_state/playability_restriction.proto\";\n\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.cosmos.util.proto\";\n\nmessage EpisodePlayState {\n    optional uint32 time_left = 1;\n    optional bool is_playable = 2;\n    optional bool is_played = 3;\n    optional uint32 last_played_at = 4;\n    optional PlayabilityRestriction playability_restriction = 5 [default = UNKNOWN];\n}\n"
  },
  {
    "path": "protocol/proto/played_state/playability_restriction.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.cosmos_util.proto;\n\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.cosmos.util.proto\";\n\nenum PlayabilityRestriction {\n    UNKNOWN = 0;\n    NO_RESTRICTION = 1;\n    EXPLICIT_CONTENT = 2;\n    AGE_RESTRICTED = 3;\n    NOT_IN_CATALOGUE = 4;\n    NOT_AVAILABLE_OFFLINE = 5;\n    PREMIUM_ONLY = 6;\n}\n"
  },
  {
    "path": "protocol/proto/played_state/show_played_state.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.cosmos_util.proto;\n\nimport \"played_state/playability_restriction.proto\";\n\noption objc_class_prefix = \"SPTCosmosUtil\";\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.cosmos.util.proto\";\n\nmessage ShowPlayState {\n    enum Label {\n        UNKNOWN_LABEL = 0;\n        NOT_STARTED = 1;\n        IN_PROGRESS = 2;\n        COMPLETED = 3;\n    }\n\n    optional string latest_played_episode_link = 1;\n    optional uint64 played_time = 2;\n    optional bool is_playable = 3;\n    optional PlayabilityRestriction playability_restriction = 4 [default = UNKNOWN];\n    optional Label label = 5;\n    optional uint32 played_percentage = 6;\n    optional string resume_episode_link = 7;\n}\n"
  },
  {
    "path": "protocol/proto/played_state/track_played_state.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.cosmos_util.proto;\n\nimport \"played_state/playability_restriction.proto\";\n\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.cosmos.util.proto\";\n\nmessage TrackPlayState {\n    optional bool is_playable = 1;\n    optional PlayabilityRestriction playability_restriction = 2 [default = UNKNOWN];\n}\n"
  },
  {
    "path": "protocol/proto/played_state.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.played_state.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage PlayedStateItem {\n    optional string show_uri = 1;\n    optional string episode_uri = 2;\n    optional int32 resume_point = 3;\n    optional int32 last_played_at = 4;\n    optional bool is_latest = 5;\n    optional bool has_been_fully_played = 6;\n    optional bool has_been_synced = 7;\n    optional int32 episode_length = 8;\n}\n\nmessage PlayedStateItems {\n    repeated PlayedStateItem item = 1;\n    optional uint64 last_server_sync_timestamp = 2;\n}\n"
  },
  {
    "path": "protocol/proto/playedstate.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify_playedstate.proto;\n\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.playedstate.proto\";\n\nmessage PlayedStateItem {\n    optional Type type = 1;\n    optional bytes uri = 2;\n    optional int64 client_timestamp = 3;\n    optional int32 play_position = 4;\n    optional bool played = 5;\n    optional int32 duration = 6;\n}\n\nmessage PlayedState {\n    optional int64 server_timestamp = 1;\n    optional bool truncated = 2;\n    repeated PlayedStateItem state = 3;\n}\n\nmessage PlayedStateItemList {\n    repeated PlayedStateItem state = 1;\n}\n\nmessage ContentId {\n    optional Type type = 1;\n    optional bytes uri = 2;\n}\n\nmessage ContentIdList {\n    repeated ContentId contentIds = 1;\n}\n\nenum Type {\n    EPISODE = 0;\n}\n"
  },
  {
    "path": "protocol/proto/player.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.connectstate;\n\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.connectstate.model\";\n\nmessage PlayerState {\n    reserved 26;\n    reserved 27;\n    reserved 28;\n    reserved 29;\n    reserved 30;\n    reserved 31;\n    reserved 34;\n\n    int64 timestamp = 1;\n    string context_uri = 2;\n    string context_url = 3;\n    Restrictions context_restrictions = 4;\n    PlayOrigin play_origin = 5;\n    ContextIndex index = 6;\n    ProvidedTrack track = 7;\n    string playback_id = 8;\n    double playback_speed = 9;\n    int64 position_as_of_timestamp = 10;\n    int64 duration = 11;\n    bool is_playing = 12;\n    bool is_paused = 13;\n    bool is_buffering = 14;\n    bool is_system_initiated = 15;\n    ContextPlayerOptions options = 16;\n    Restrictions restrictions = 17;\n    Suppressions suppressions = 18;\n    repeated ProvidedTrack prev_tracks = 19;\n    repeated ProvidedTrack next_tracks = 20;\n    map<string, string> context_metadata = 21;\n    map<string, string> page_metadata = 22;\n    string session_id = 23;\n    string queue_revision = 24;\n    int64 position = 25;\n    PlaybackQuality playback_quality = 32;\n    repeated string signals = 33;\n    string session_command_id = 35;\n}\n\nmessage ProvidedTrack {\n    reserved 11;\n\n    string uri = 1;\n    string uid = 2;\n    map<string, string> metadata = 3;\n    repeated string removed = 4;\n    repeated string blocked = 5;\n    string provider = 6;\n    Restrictions restrictions = 7;\n    string album_uri = 8;\n    repeated string disallow_reasons = 9;\n    string artist_uri = 10;\n}\n\nmessage ContextIndex {\n    uint32 page = 1;\n    uint32 track = 2;\n}\n\nmessage ModeRestrictions {\n    map<string, RestrictionReasons> values = 1;\n}\n\nmessage RestrictionReasons {\n    repeated string reasons = 1;\n}\n\nmessage Restrictions {\n    reserved 26;\n    reserved 27;\n\n    repeated string disallow_pausing_reasons = 1;\n    repeated string disallow_resuming_reasons = 2;\n    repeated string disallow_seeking_reasons = 3;\n    repeated string disallow_peeking_prev_reasons = 4;\n    repeated string disallow_peeking_next_reasons = 5;\n    repeated string disallow_skipping_prev_reasons = 6;\n    repeated string disallow_skipping_next_reasons = 7;\n    repeated string disallow_toggling_repeat_context_reasons = 8;\n    repeated string disallow_toggling_repeat_track_reasons = 9;\n    repeated string disallow_toggling_shuffle_reasons = 10;\n    repeated string disallow_set_queue_reasons = 11;\n    repeated string disallow_interrupting_playback_reasons = 12;\n    repeated string disallow_transferring_playback_reasons = 13;\n    repeated string disallow_remote_control_reasons = 14;\n    repeated string disallow_inserting_into_next_tracks_reasons = 15;\n    repeated string disallow_inserting_into_context_tracks_reasons = 16;\n    repeated string disallow_reordering_in_next_tracks_reasons = 17;\n    repeated string disallow_reordering_in_context_tracks_reasons = 18;\n    repeated string disallow_removing_from_next_tracks_reasons = 19;\n    repeated string disallow_removing_from_context_tracks_reasons = 20;\n    repeated string disallow_updating_context_reasons = 21;\n    repeated string disallow_playing_reasons = 22;\n    repeated string disallow_stopping_reasons = 23;\n    repeated string disallow_add_to_queue_reasons = 24;\n    repeated string disallow_setting_playback_speed_reasons = 25;\n    map<string, ModeRestrictions> disallow_setting_modes = 28;\n    map<string, RestrictionReasons> disallow_signals = 29;\n}\n\nmessage PlayOrigin {\n    string feature_identifier = 1;\n    string feature_version = 2;\n    string view_uri = 3;\n    string external_referrer = 4;\n    string referrer_identifier = 5;\n    string device_identifier = 6;\n    repeated string feature_classes = 7;\n    string restriction_identifier = 8;\n}\n\nmessage ContextPlayerOptions {\n    bool shuffling_context = 1;\n    bool repeating_context = 2;\n    bool repeating_track = 3;\n    map<string, string> modes = 5;\n    optional float playback_speed = 4;\n}\n\nmessage Suppressions {\n    repeated string providers = 1;\n}\n\nmessage PlaybackQuality {\n    BitrateLevel bitrate_level = 1;\n    BitrateStrategy strategy = 2;\n    BitrateLevel target_bitrate_level = 3;\n    bool target_bitrate_available = 4;\n    HiFiStatus hifi_status = 5;\n}\n\nenum BitrateLevel {\n    unknown_bitrate_level = 0;\n    low = 1;\n    normal = 2;\n    high = 3;\n    very_high = 4;\n    hifi = 5;\n    hifi24 = 6;\n}\n\nenum BitrateStrategy {\n    unknown_strategy = 0;\n    best_matching = 1;\n    backend_advised = 2;\n    offlined_file = 3;\n    cached_file = 4;\n    local_file = 5;\n}\n\nenum HiFiStatus {\n    none = 0;\n    off = 1;\n    on = 2;\n}\n"
  },
  {
    "path": "protocol/proto/player_license.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.player.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage PlayerLicense {\n    optional string identifier = 1;\n}\n"
  },
  {
    "path": "protocol/proto/player_model.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.player.proto;\n\nimport \"logging_params.proto\";\n\noption optimize_for = CODE_SIZE;\n\nmessage PlayerModel {\n    optional bool is_paused = 1;\n    optional uint64 hash = 2;\n    optional LoggingParams logging_params = 3;\n    \n    optional StartReason start_reason = 4;\n    enum StartReason {\n        REMOTE_TRANSFER = 0;\n        COMEBACK = 1;\n        PLAY_CONTEXT = 2;\n        PLAY_SPECIFIC_TRACK = 3;\n        TRACK_FINISHED = 4;\n        SKIP_TO_NEXT_TRACK = 5;\n        SKIP_TO_PREV_TRACK = 6;\n        ERROR = 7;\n        IGNORED = 8;\n        UNKNOWN = 9;\n    }\n}\n"
  },
  {
    "path": "protocol/proto/playlist4_external.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.playlist4.proto;\n\nimport \"lens-model.proto\";\nimport \"playlist_permission.proto\";\nimport \"signal-model.proto\";\n\noption optimize_for = CODE_SIZE;\noption java_outer_classname = \"Playlist4ApiProto\";\noption java_package = \"com.spotify.playlist4.proto\";\n\nmessage Item {\n    required string uri = 1;\n    optional ItemAttributes attributes = 2;\n}\n\nmessage MetaItem {\n    optional bytes revision = 1;\n    optional ListAttributes attributes = 2;\n    optional int32 length = 3;\n    optional int64 timestamp = 4;\n    optional string owner_username = 5;\n    optional bool abuse_reporting_enabled = 6;\n    optional playlist_permission.proto.Capabilities capabilities = 7;\n    repeated GeoblockBlockingType geoblock = 8;\n    optional sint32 status_code = 9;\n}\n\nmessage ListItems {\n    required int32 pos = 1;\n    required bool truncated = 2;\n    repeated Item items = 3;\n    repeated MetaItem meta_items = 4;\n    repeated playlist.signal.proto.Signal available_signals = 5;\n    optional string continuation_token = 6;\n}\n\nmessage PaginatedUnfollowedListItems {\n    optional int32 limit = 1;\n    optional int32 offset = 2;\n    optional int32 nextPageIndex = 3;\n    optional int32 previousPageIndex = 4;\n    optional int32 totalPages = 5;\n    repeated UnfollowedListItem items = 6;\n}\n\nmessage UnfollowedListItem {\n    optional string uri = 1;\n    optional bool recoverable = 2;\n    optional string name = 3;\n    optional int64 deleted_at = 4;\n    optional int32 length = 5;\n}\n\nmessage FormatListAttribute {\n    optional string key = 1;\n    optional string value = 2;\n}\n\nmessage PictureSize {\n    optional string target_name = 1;\n    optional string url = 2;\n}\n\nmessage RecommendationInfo {\n    optional bool is_recommendation = 1;\n}\n\nmessage ListAttributes {\n    optional string name = 1;\n    optional string description = 2;\n    optional bytes picture = 3;\n    optional bool collaborative = 4;\n    optional string pl3_version = 5;\n    optional bool deleted_by_owner = 6;\n    optional string client_id = 10;\n    optional string format = 11;\n    repeated FormatListAttribute format_attributes = 12;\n    repeated PictureSize picture_size = 13;\n    optional bytes sequence_context_template = 14;\n    optional bytes ai_curation_reference_id = 15;\n}\n\nmessage ItemAttributes {\n    optional string added_by = 1;\n    optional int64 timestamp = 2;\n    optional int64 seen_at = 9;\n    optional bool public = 10;\n    repeated FormatListAttribute format_attributes = 11;\n    optional bytes item_id = 12;\n    optional lens.model.proto.Lens source_lens = 13;\n    repeated playlist.signal.proto.Signal available_signals = 14;\n    optional RecommendationInfo recommendation_info = 15;\n    optional bytes sequence_child_template = 16;\n}\n\nmessage Add {\n    optional int32 from_index = 1;\n    repeated Item items = 2;\n    optional bool add_last = 4;\n    optional bool add_first = 5;\n    optional Item add_before_item = 6;\n    optional Item add_after_item = 7;\n}\n\nmessage Rem {\n    optional int32 from_index = 1;\n    optional int32 length = 2;\n    repeated Item items = 3;\n    optional bool items_as_key = 7;\n}\n\nmessage Mov {\n    optional int32 from_index = 1;\n    optional int32 length = 2;\n    optional int32 to_index = 3;\n    repeated Item items = 4;\n    optional Item add_before_item = 5;\n    optional Item add_after_item = 6;\n    optional bool add_first = 7;\n    optional bool add_last = 8;\n}\n\nmessage ItemAttributesPartialState {\n    required ItemAttributes values = 1;\n    repeated ItemAttributeKind no_value = 2;\n}\n\nmessage ListAttributesPartialState {\n    required ListAttributes values = 1;\n    repeated ListAttributeKind no_value = 2;\n}\n\nmessage UpdateItemAttributes {\n    optional int32 index = 1;\n    required ItemAttributesPartialState new_attributes = 2;\n    optional ItemAttributesPartialState old_attributes = 3;\n    optional Item item = 4;\n}\n\nmessage UpdateListAttributes {\n    required ListAttributesPartialState new_attributes = 1;\n    optional ListAttributesPartialState old_attributes = 2;\n}\n\nmessage UpdateItemUris {\n    repeated UriReplacement uri_replacements = 1;\n}\n\nmessage UriReplacement {\n    optional int32 index = 1;\n    optional Item item = 2;\n    optional string new_uri = 3;\n}\n\nmessage Op {\n    enum Kind {\n        KIND_UNKNOWN = 0;\n        ADD = 2;\n        REM = 3;\n        MOV = 4;\n        UPDATE_ITEM_ATTRIBUTES = 5;\n        UPDATE_LIST_ATTRIBUTES = 6;\n        UPDATE_ITEM_URIS = 7;\n    }\n\n    required Kind kind = 1;\n    optional Add add = 2;\n    optional Rem rem = 3;\n    optional Mov mov = 4;\n    optional UpdateItemAttributes update_item_attributes = 5;\n    optional UpdateListAttributes update_list_attributes = 6;\n    optional UpdateItemUris update_item_uris = 7;\n}\n\nmessage OpList {\n    repeated Op ops = 1;\n}\n\nmessage ChangeInfo {\n    optional string user = 1;\n    optional int64 timestamp = 2;\n    optional bool admin = 3;\n    optional bool undo = 4;\n    optional bool redo = 5;\n    optional bool merge = 6;\n    optional bool compressed = 7;\n    optional bool migration = 8;\n    optional int32 split_id = 9;\n    optional SourceInfo source = 10;\n}\n\nmessage SourceInfo {\n    enum Client {\n        CLIENT_UNKNOWN = 0;\n        NATIVE_HERMES = 1;\n        CLIENT = 2;\n        PYTHON = 3;\n        JAVA = 4;\n        WEBPLAYER = 5;\n        LIBSPOTIFY = 6;\n    }\n\n    optional Client client = 1;\n    optional string app = 3;\n    optional string source = 4;\n    optional string version = 5;\n    optional string server_domain = 6;\n}\n\nmessage Delta {\n    optional bytes base_version = 1;\n    repeated Op ops = 2;\n    optional ChangeInfo info = 4;\n}\n\nmessage Diff {\n    required bytes from_revision = 1;\n    repeated Op ops = 2;\n    required bytes to_revision = 3;\n}\n\nmessage ListChanges {\n    optional bytes base_revision = 1;\n    repeated Delta deltas = 2;\n    optional bool want_resulting_revisions = 3;\n    optional bool want_sync_result = 4;\n    repeated int64 nonces = 6;\n}\n\nmessage ListSignals {\n    optional bytes base_revision = 1;\n    repeated playlist.signal.proto.Signal emitted_signals = 2;\n}\n\nmessage SelectedListContent {\n    optional bytes revision = 1;\n    optional int32 length = 2;\n    optional ListAttributes attributes = 3;\n    optional ListItems contents = 5;\n    optional Diff diff = 6;\n    optional Diff sync_result = 7;\n    repeated bytes resulting_revisions = 8;\n    optional bool multiple_heads = 9;\n    optional bool up_to_date = 10;\n    repeated int64 nonces = 14;\n    optional int64 timestamp = 15;\n    optional string owner_username = 16;\n    optional bool abuse_reporting_enabled = 17;\n    optional spotify.playlist_permission.proto.Capabilities capabilities = 18;\n    repeated GeoblockBlockingType geoblock = 19;\n    optional bool changes_require_resync = 20;\n    optional int64 created_at = 21;\n    optional AppliedLenses applied_lenses = 22;\n}\n\nmessage AppliedLenses {\n    repeated lens.model.proto.LensState states = 1;\n}\n\nmessage CreateListReply {\n    required string uri = 1;\n    optional bytes revision = 2;\n}\n\nmessage PlaylistV1UriRequest {\n    repeated string v2_uris = 1;\n}\n\nmessage PlaylistV1UriReply {\n    map<string, string> v2_uri_to_v1_uri = 1;\n}\n\nmessage ListUpdateRequest {\n    optional bytes base_revision = 1;\n    optional ListAttributes attributes = 2;\n    repeated Item items = 3;\n    optional ChangeInfo info = 4;\n}\n\nmessage RegisterPlaylistImageRequest {\n    optional string upload_token = 1;\n}\n\nmessage RegisterPlaylistImageResponse {\n    optional bytes picture = 1;\n}\n\nmessage ResolvedPersonalizedPlaylist {\n    optional string uri = 1;\n    optional string tag = 2;\n}\n\nmessage PlaylistUriResolverResponse {\n    repeated ResolvedPersonalizedPlaylist resolved_playlists = 1;\n}\n\nmessage SubscribeRequest {\n    repeated bytes uris = 1;\n}\n\nmessage UnsubscribeRequest {\n    repeated bytes uris = 1;\n}\n\nmessage PlaylistModificationInfo {\n    optional bytes uri = 1;\n    optional bytes new_revision = 2;\n    optional bytes parent_revision = 3;\n    repeated Op ops = 4;\n}\n\nmessage RootlistModificationInfo {\n    optional bytes new_revision = 1;\n    optional bytes parent_revision = 2;\n    repeated Op ops = 3;\n}\n\nmessage FollowerUpdate {\n    optional string uri = 1;\n    optional string username = 2;\n    optional bool is_following = 3;\n    optional uint64 timestamp = 4;\n}\n\nenum ListAttributeKind {\n    LIST_UNKNOWN = 0;\n    LIST_NAME = 1;\n    LIST_DESCRIPTION = 2;\n    LIST_PICTURE = 3;\n    LIST_COLLABORATIVE = 4;\n    LIST_PL3_VERSION = 5;\n    LIST_DELETED_BY_OWNER = 6;\n    LIST_CLIENT_ID = 10;\n    LIST_FORMAT = 11;\n    LIST_FORMAT_ATTRIBUTES = 12;\n    LIST_PICTURE_SIZE = 13;\n    LIST_SEQUENCE_CONTEXT_TEMPLATE = 14;\n    LIST_AI_CURATION_REFERENCE_ID = 15;\n}\n\nenum ItemAttributeKind {\n    ITEM_UNKNOWN = 0;\n    ITEM_ADDED_BY = 1;\n    ITEM_TIMESTAMP = 2;\n    ITEM_SEEN_AT = 9;\n    ITEM_PUBLIC = 10;\n    ITEM_FORMAT_ATTRIBUTES = 11;\n    ITEM_ID = 12;\n    ITEM_SOURCE_LENS = 13;\n    ITEM_AVAILABLE_SIGNALS = 14;\n    ITEM_RECOMMENDATION_INFO = 15;\n    ITEM_SEQUENCE_CHILD_TEMPLATE = 16;\n}\n\nenum GeoblockBlockingType {\n    GEOBLOCK_BLOCKING_TYPE_UNSPECIFIED = 0;\n    GEOBLOCK_BLOCKING_TYPE_TITLE = 1;\n    GEOBLOCK_BLOCKING_TYPE_DESCRIPTION = 2;\n    GEOBLOCK_BLOCKING_TYPE_IMAGE = 3;\n}\n\n"
  },
  {
    "path": "protocol/proto/playlist_annotate3.proto",
    "content": "// Extracted from: Spotify 1.1.33.569 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify_playlist_annotate3.proto;\n\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.playlist_annotate3.proto\";\n\nmessage TakedownRequest {\n    optional AbuseReportState abuse_report_state = 1;\n}\n\nmessage AnnotateRequest {\n    optional string description = 1;\n    optional string image_uri = 2;\n}\n\nmessage TranscodedPicture {\n    optional string target_name = 1;\n    optional string uri = 2;\n}\n\nmessage PlaylistAnnotation {\n    optional string description = 1;\n    optional string picture = 2;\n    optional RenderFeatures deprecated_render_features = 3 [default = NORMAL_FEATURES, deprecated = true];\n    repeated TranscodedPicture transcoded_picture = 4;\n    optional bool is_abuse_reporting_enabled = 6 [default = true];\n    optional AbuseReportState abuse_report_state = 7 [default = OK];\n}\n\nenum RenderFeatures {\n    NORMAL_FEATURES = 1;\n    EXTENDED_FEATURES = 2;\n}\n\nenum AbuseReportState {\n    OK = 0;\n    TAKEN_DOWN = 1;\n}\n"
  },
  {
    "path": "protocol/proto/playlist_contains_request.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.playlist_esperanto.proto;\n\nimport \"contains_request.proto\";\nimport \"response_status.proto\";\n\noption objc_class_prefix = \"ESP\";\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"spotify.playlist.esperanto.proto\";\n\nmessage PlaylistContainsRequest {\n    string uri = 1;\n    playlist.cosmos.proto.ContainsRequest request = 2;\n}\n\nmessage PlaylistContainsResponse {\n    ResponseStatus status = 1;\n    playlist.cosmos.proto.ContainsResponse response = 2;\n}\n"
  },
  {
    "path": "protocol/proto/playlist_folder_state.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.playlist.cosmos.proto;\n\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.playlist.proto\";\n\nmessage FolderMetadata {\n    optional string id = 1;\n    optional string name = 2;\n    optional uint32 num_folders = 3;\n    optional uint32 num_playlists = 4;\n    optional uint32 num_recursive_folders = 5;\n    optional uint32 num_recursive_playlists = 6;\n    optional string link = 7;\n}\n"
  },
  {
    "path": "protocol/proto/playlist_get_request.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.playlist_esperanto.proto;\n\nimport \"google/protobuf/duration.proto\";\nimport \"policy/playlist_request_decoration_policy.proto\";\nimport \"playlist_query.proto\";\nimport \"playlist_request.proto\";\nimport \"response_status.proto\";\n\noption objc_class_prefix = \"ESP\";\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"spotify.playlist.esperanto.proto\";\n\nmessage PlaylistGetRequest {\n    string uri = 1;\n    PlaylistQuery query = 2;\n    playlist.cosmos.proto.PlaylistRequestDecorationPolicy policy = 3;\n}\n\nmessage PlaylistMultiGetSingleRequest {\n    string id = 1;\n    PlaylistGetRequest request = 2;\n}\n\nmessage PlaylistMultiGetRequest {\n    repeated PlaylistMultiGetSingleRequest requests = 1;\n    google.protobuf.Duration timeout = 2;\n}\n\nmessage PlaylistGetResponse {\n    ResponseStatus status = 1;\n    playlist.cosmos.playlist_request.proto.Response data = 2;\n    PlaylistQuery query = 3;\n}\n\nmessage PlaylistMultiGetSingleResponse {\n    string id = 1;\n    string uri = 2;\n    PlaylistGetResponse response = 3;\n}\n\nmessage PlaylistMultiGetResponse {\n    ResponseStatus status = 1;\n    repeated PlaylistMultiGetSingleResponse responses = 2;\n}\n\n"
  },
  {
    "path": "protocol/proto/playlist_members_request.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.playlist_esperanto.proto;\n\nimport \"members_request.proto\";\nimport \"members_response.proto\";\nimport \"response_status.proto\";\n\noption objc_class_prefix = \"ESP\";\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"spotify.playlist.esperanto.proto\";\n\nmessage PlaylistMembersResponse {\n    ResponseStatus status = 1;\n    playlist.cosmos.proto.PlaylistMembersResponse response = 2;\n}\n"
  },
  {
    "path": "protocol/proto/playlist_modification_request.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.playlist_esperanto.proto;\n\nimport \"modification_request.proto\";\nimport \"response_status.proto\";\n\noption objc_class_prefix = \"ESP\";\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"spotify.playlist.esperanto.proto\";\n\nmessage PlaylistModificationRequest {\n    string uri = 1;\n    playlist.cosmos.proto.ModificationRequest request = 2;\n}\n\nmessage PlaylistModificationResponse {\n    ResponseStatus status = 1;\n}\n"
  },
  {
    "path": "protocol/proto/playlist_offline_request.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.playlist_esperanto.proto;\n\nimport \"playlist_query.proto\";\nimport \"response_status.proto\";\n\noption objc_class_prefix = \"ESP\";\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"spotify.playlist.esperanto.proto\";\n\nmessage PlaylistOfflineRequest {\n    string uri = 1;\n    PlaylistQuery query = 2;\n    PlaylistOfflineAction action = 3;\n}\n\nmessage PlaylistOfflineResponse {\n    ResponseStatus status = 1;\n}\n\nenum PlaylistOfflineAction {\n    NONE = 0;\n    SET_AS_AVAILABLE_OFFLINE = 1;\n    REMOVE_AS_AVAILABLE_OFFLINE = 2;\n}\n"
  },
  {
    "path": "protocol/proto/playlist_permission.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.playlist_permission.proto;\n\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.playlist.proto\";\n\nmessage Permission {\n    optional bytes revision = 1;\n    optional PermissionLevel permission_level = 2;\n}\n\nmessage GrantableLevels {\n    repeated PermissionLevel base = 1;\n    repeated PermissionLevel member = 2;\n}\n\nmessage AttributeCapabilities {\n    optional bool can_edit = 1;\n}\n\nmessage ListAttributeCapabilities {\n    optional AttributeCapabilities name = 1;\n    optional AttributeCapabilities description = 2;\n    optional AttributeCapabilities picture = 3;\n    optional AttributeCapabilities collaborative = 4;\n    optional AttributeCapabilities deleted_by_owner = 6;\n    optional AttributeCapabilities ai_curation_reference_id = 15;\n}\n\nmessage Capabilities {\n    optional bool can_view = 1;\n    optional bool can_administrate_permissions = 2;\n    repeated PermissionLevel grantable_level = 3;\n    optional bool can_edit_metadata = 4;\n    optional bool can_edit_items = 5;\n    optional bool can_cancel_membership = 6;\n    optional GrantableLevels grantable_levels = 7;\n    optional ListAttributeCapabilities list_attribute_capabilities = 8;\n}\n\nmessage CapabilitiesMultiRequest {\n    repeated CapabilitiesRequest request = 1;\n    optional string fallback_username = 2;\n    optional string fallback_user_id = 3;\n    optional string fallback_uri = 4;\n}\n\nmessage CapabilitiesRequestOptions {\n    optional bool can_view_only = 1;\n}\n\nmessage CapabilitiesRequest {\n    optional string username = 1;\n    optional string user_id = 2;\n    optional string uri = 3;\n    optional bool user_is_owner = 4;\n    optional string permission_grant_token = 5;\n    optional CapabilitiesRequestOptions request_options = 6;\n}\n\nmessage CapabilitiesMultiResponse {\n    repeated CapabilitiesResponse response = 1;\n}\n\nmessage CapabilitiesResponse {\n    optional ResponseStatus status = 1;\n    optional Capabilities capabilities = 2;\n}\n\nmessage SetPermissionLevelRequest {\n    optional PermissionLevel permission_level = 1;\n}\n\nmessage SetPermissionResponse {\n    optional Permission resulting_permission = 1;\n}\n\nmessage GetMemberPermissionsResponse {\n    map<string, Permission> member_permissions = 1;\n}\n\nmessage Permissions {\n    optional Permission base_permission = 1;\n}\n\nmessage PermissionState {\n    optional Permissions permissions = 1;\n    optional Capabilities capabilities = 2;\n    optional bool is_private = 3;\n    optional bool is_collaborative = 4;\n}\n\nmessage PermissionStatePub {\n    optional PermissionState permission_state = 1;\n}\n\nmessage PermissionGrantOptions {\n    optional Permission permission = 1;\n    optional int64 ttl_ms = 2;\n}\n\nmessage PermissionGrant {\n    optional string token = 1;\n    optional PermissionGrantOptions permission_grant_options = 2;\n}\n\nmessage PermissionGrantDetails {\n    optional bool permission_level_downgraded = 1;\n}\n\nmessage PermissionGrantDescription {\n    enum ClaimFailReason {\n        CLAIM_FAIL_REASON_UNSPECIFIED = 0;\n        CLAIM_FAIL_REASON_ANONYMOUS = 1;\n        CLAIM_FAIL_REASON_NO_GRANT_FOUND = 2;\n        CLAIM_FAIL_REASON_GRANT_EXPIRED = 3;\n    }\n\n    optional PermissionGrantOptions permission_grant_options = 1;\n    optional ClaimFailReason claim_fail_reason = 2;\n    optional bool is_effective = 3;\n    optional Capabilities capabilities = 4;\n    repeated PermissionGrantDetails details = 5;\n}\n\nmessage ClaimPermissionGrantResponse {\n    optional Permission user_permission = 1;\n    optional Capabilities capabilities = 2;\n    repeated PermissionGrantDetails details = 3;\n}\n\nmessage ResponseStatus {\n    optional int32 status_code = 1;\n    optional string status_message = 2;\n}\n\nmessage PermissionIdentifier {\n    required PermissionIdentifierKind kind = 1;\n    optional string user_id = 2;\n}\n\nmessage PermissionEntry {\n    optional PermissionIdentifier identifier = 1;\n    optional Permission permission = 2;\n}\n\nmessage CreateInitialPermissions {\n    repeated PermissionEntry permission_entry = 1;\n}\n\nmessage CreateInitialPermissionsResponse {\n    repeated PermissionEntry permission_entry = 1;\n}\n\nmessage DefaultOwnerCapabilitiesResponse {\n    optional Capabilities capabilities = 1;\n}\n\nenum PermissionLevel {\n    UNKNOWN = 0;\n    BLOCKED = 1;\n    VIEWER = 2;\n    CONTRIBUTOR = 3;\n    MADE_FOR = 4;\n}\n\nenum PermissionIdentifierKind {\n    PERMISSION_IDENTIFIER_KIND_UNSPECIFIED = 0;\n    PERMISSION_IDENTIFIER_KIND_BASE = 1;\n    PERMISSION_IDENTIFIER_KIND_MEMBER = 2;\n    PERMISSION_IDENTIFIER_KIND_ABUSE = 3;\n    PERMISSION_IDENTIFIER_KIND_PROFILE = 4;\n    PERMISSION_IDENTIFIER_KIND_AUTHORIZED = 5;\n}\n\n"
  },
  {
    "path": "protocol/proto/playlist_play_request.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.playlist_esperanto.proto;\n\nimport \"es_context.proto\";\nimport \"es_play_options.proto\";\nimport \"es_logging_params.proto\";\nimport \"es_prepare_play_options.proto\";\nimport \"es_play_origin.proto\";\nimport \"playlist_query.proto\";\nimport \"response_status.proto\";\n\noption objc_class_prefix = \"ESP\";\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"spotify.playlist.esperanto.proto\";\n\nmessage PlaylistPlayRequest {\n    PlaylistQuery playlist_query = 1;\n    player.esperanto.proto.Context context = 2;\n    player.esperanto.proto.PlayOptions play_options = 3;\n    player.esperanto.proto.LoggingParams logging_params = 4;\n    player.esperanto.proto.PreparePlayOptions prepare_play_options = 5;\n    player.esperanto.proto.PlayOrigin play_origin = 6;\n}\n\nmessage PlaylistPlayResponse {\n    ResponseStatus status = 1;\n}\n"
  },
  {
    "path": "protocol/proto/playlist_playback_request.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.playlist.cosmos.proto;\n\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.playlist.proto\";\n\nmessage PlaybackResponse {\n    bool success = 1;\n}\n"
  },
  {
    "path": "protocol/proto/playlist_playlist_state.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.playlist.cosmos.proto;\n\nimport \"metadata/extension.proto\";\nimport \"metadata/image_group.proto\";\nimport \"playlist_user_state.proto\";\n\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.playlist.proto\";\n\nmessage FormatListAttribute {\n    optional string key = 1;\n    optional string value = 2;\n}\n\nmessage Allows {\n    optional bool can_insert = 1;\n    optional bool can_remove = 2;\n}\n\nmessage PlaylistMetadata {\n    optional string link = 1;\n    optional string name = 2;\n    optional User owner = 3;\n    optional bool owned_by_self = 4;\n    optional bool collaborative = 5;\n    optional uint32 total_length = 6;\n    optional string description = 7;\n    optional cosmos_util.proto.ImageGroup pictures = 8;\n    optional bool followed = 9;\n    optional bool published = 10;\n    optional bool browsable_offline = 11;\n    optional bool description_from_annotate = 12;\n    optional bool picture_from_annotate = 13;\n    optional string format_list_type = 14;\n    repeated FormatListAttribute format_list_attributes = 15;\n    optional bool can_report_annotation_abuse = 16;\n    optional bool is_loaded = 17;\n    optional Allows allows = 18;\n    optional string load_state = 19;\n    optional User made_for = 20;\n    repeated cosmos_util.proto.Extension extension = 21;\n    optional uint32 length_ignoring_text_filter = 22;\n    optional string ai_curation_reference_id = 23;\n}\n\nmessage PlaylistOfflineState {\n    optional string offline = 1;\n    optional uint32 sync_progress = 2;\n}\n"
  },
  {
    "path": "protocol/proto/playlist_query.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.playlist_esperanto.proto;\n\nimport \"policy/supported_link_types_in_playlists.proto\";\n\noption objc_class_prefix = \"ESP\";\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"spotify.playlist.esperanto.proto\";\n\nmessage PlaylistRange {\n    int32 start = 1;\n    int32 length = 2;\n}\n\nmessage PlaylistQuery {\n    repeated BoolPredicate bool_predicates = 1;\n    enum BoolPredicate {\n        NO_FILTER = 0;\n        AVAILABLE = 1;\n        AVAILABLE_OFFLINE = 2;\n        ARTIST_NOT_BANNED = 3;\n        NOT_BANNED = 4;\n        NOT_EXPLICIT = 5;\n        NOT_EPISODE = 6;\n        NOT_RECOMMENDATION = 7;\n        UNPLAYED = 8;\n        IN_PROGRESS = 9;\n        NOT_FULLY_PLAYED = 10;\n    }\n\n    string text_filter = 2;\n\n    SortBy sort_by = 3;\n    enum SortBy {\n        NO_SORT = 0;\n        ALBUM_ARTIST_NAME_ASC = 1;\n        ALBUM_ARTIST_NAME_DESC = 2;\n        TRACK_NUMBER_ASC = 3;\n        TRACK_NUMBER_DESC = 4;\n        DISC_NUMBER_ASC = 5;\n        DISC_NUMBER_DESC = 6;\n        ALBUM_NAME_ASC = 7;\n        ALBUM_NAME_DESC = 8;\n        ARTIST_NAME_ASC = 9;\n        ARTIST_NAME_DESC = 10;\n        NAME_ASC = 11;\n        NAME_DESC = 12;\n        ADD_TIME_ASC = 13;\n        ADD_TIME_DESC = 14;\n        ADDED_BY_ASC = 15;\n        ADDED_BY_DESC = 16;\n        DURATION_ASC = 17;\n        DURATION_DESC = 18;\n        SHOW_NAME_ASC = 19;\n        SHOW_NAME_DESC = 20;\n        PUBLISH_DATE_ASC = 21;\n        PUBLISH_DATE_DESC = 22;\n    }\n\n    PlaylistRange range = 4;\n    int32 update_throttling_ms = 5;\n    bool group = 6;\n    PlaylistSourceRestriction source_restriction = 7;\n    bool show_unavailable = 8;\n    bool always_show_windowed = 9;\n    bool load_recommendations = 10;\n    repeated playlist.cosmos.proto.LinkType supported_placeholder_types = 11;\n    repeated string descriptor_filter = 12;\n    string item_id_filter = 13;\n\n    repeated AttributeFilter attribute_filter = 14;\n    message AttributeFilter {\n        repeated string contains_one_of = 1;\n    }\n\n    bool include_all_placeholders = 15;\n}\n\nenum PlaylistSourceRestriction {\n    NO_RESTRICTION = 0;\n    RESTRICT_SOURCE_TO_50 = 1;\n    RESTRICT_SOURCE_TO_500 = 2;\n}\n"
  },
  {
    "path": "protocol/proto/playlist_request.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.playlist.cosmos.playlist_request.proto;\n\nimport \"collection/episode_collection_state.proto\";\nimport \"metadata/episode_metadata.proto\";\nimport \"played_state/track_played_state.proto\";\nimport \"played_state/episode_played_state.proto\";\nimport \"sync/episode_sync_state.proto\";\nimport \"metadata/image_group.proto\";\nimport \"on_demand_in_free_reason.proto\";\nimport \"playlist_permission.proto\";\nimport \"playlist_playlist_state.proto\";\nimport \"playlist_track_state.proto\";\nimport \"playlist_user_state.proto\";\nimport \"policy/supported_link_types_in_playlists.proto\";\nimport \"metadata/track_metadata.proto\";\nimport \"metadata/extension.proto\";\n\noption objc_class_prefix = \"SPTPlaylistCosmosPlaylist\";\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.playlist.proto\";\n\nmessage AvailableSignal {\n    optional string name = 1;\n    optional .spotify.playlist.cosmos.playlist_request.proto.SignalState state = 2;\n}\n\nmessage ItemOfflineState {\n    optional string offline = 1;\n    optional uint32 sync_progress = 2;\n    optional bool locally_playable = 3;\n}\n\nmessage ItemCollectionState {\n    optional bool is_in_collection = 1;\n    optional bool is_banned = 2;\n}\n\nmessage ItemMetadata {\n    optional string name = 1;\n    optional string image = 2;\n    optional bool is_explicit = 3;\n}\n\nmessage ItemCurationState {\n    optional bool is_curated = 1;\n}\n\nmessage Item {\n    optional string header_field = 1;\n    optional uint32 add_time = 2;\n    optional cosmos.proto.User added_by = 3;\n    optional cosmos_util.proto.TrackMetadata track_metadata = 4;\n    optional cosmos.proto.TrackCollectionState track_collection_state = 5;\n    optional cosmos.proto.TrackOfflineState track_offline_state = 6;\n    optional string row_id = 7;\n    optional cosmos_util.proto.TrackPlayState track_play_state = 8;\n    repeated cosmos.proto.FormatListAttribute format_list_attributes = 9;\n    optional cosmos_util.proto.EpisodeMetadata episode_metadata = 10;\n    optional cosmos_util.proto.EpisodeSyncState episode_offline_state = 11;\n    optional cosmos_util.proto.EpisodeCollectionState episode_collection_state = 12;\n    optional cosmos_util.proto.EpisodePlayState episode_play_state = 13;\n    optional cosmos_util.proto.ImageGroup display_covers = 14;\n    repeated AvailableSignal available_signals = 15;\n    optional bool is_recommendation = 16;\n    repeated cosmos_util.proto.Extension extension = 17;\n    optional string uri = 18;\n    optional ItemOfflineState offline_state = 19;\n    optional ItemCollectionState collection_state = 20;\n    optional ItemMetadata metadata = 21;\n    optional ItemCurationState curation_state = 22;\n    optional bool should_be_obfuscated = 23;\n}\n\nmessage Lens {\n    optional string name = 1;\n}\n\nmessage LensState {\n    repeated Lens requested_lenses = 1;\n    repeated Lens applied_lenses = 2;\n}\n\nmessage Playlist {\n    optional cosmos.proto.PlaylistMetadata playlist_metadata = 1;\n    optional cosmos.proto.PlaylistOfflineState playlist_offline_state = 2;\n    optional LensState lenses = 3;\n}\n\nmessage RecommendationItem {\n    optional cosmos_util.proto.TrackMetadata track_metadata = 1;\n    optional cosmos.proto.TrackCollectionState track_collection_state = 2;\n    optional cosmos.proto.TrackOfflineState track_offline_state = 3;\n    optional cosmos_util.proto.TrackPlayState track_play_state = 4;\n}\n\nmessage Collaborator {\n    optional cosmos.proto.User user = 1;\n    optional uint32 number_of_items = 2;\n    optional uint32 number_of_tracks = 3;\n    optional uint32 number_of_episodes = 4;\n    optional bool is_owner = 5;\n}\n\nmessage Collaborators {\n    optional uint32 count = 1;\n    repeated Collaborator collaborator = 2;\n}\n\nmessage NumberOfItemsForLinkType {\n    optional cosmos.proto.LinkType link_type = 1;\n    optional int32 num_items = 2;\n}\n\nmessage Response {\n    repeated Item item = 1;\n    optional Playlist playlist = 2;\n    optional uint32 unfiltered_length = 3;\n    optional uint32 unranged_length = 4;\n    optional uint64 duration = 5;\n    optional bool loading_contents = 6;\n    optional uint64 last_modification = 7;\n    optional uint32 num_followers = 8;\n    optional bool playable = 9;\n    repeated RecommendationItem recommendations = 10;\n    optional bool has_explicit_content = 11;\n    optional bool contains_spotify_tracks = 12;\n    optional bool contains_episodes = 13;\n    optional bool only_contains_explicit = 14;\n    optional bool contains_audio_episodes = 15;\n    optional bool contains_tracks = 16;\n    optional bool is_on_demand_in_free = 17;\n    optional uint32 number_of_tracks = 18;\n    optional uint32 number_of_episodes = 19;\n    optional bool prefer_linear_playback = 20;\n    optional on_demand_set.proto.OnDemandInFreeReason on_demand_in_free_reason = 21;\n    optional Collaborators collaborators = 22;\n    optional playlist_permission.proto.Permission base_permission = 23;\n    optional playlist_permission.proto.Capabilities user_capabilities = 24;\n    repeated NumberOfItemsForLinkType number_of_items_per_link_type = 25;\n    repeated AvailableSignal available_signals = 26;\n}\n\nenum SignalState {\n    READY = 0;\n    PENDING = 1;\n}\n\n"
  },
  {
    "path": "protocol/proto/playlist_set_base_permission_request.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.playlist_esperanto.proto;\n\nimport \"playlist_set_permission_request.proto\";\nimport \"response_status.proto\";\n\noption objc_class_prefix = \"ESP\";\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"spotify.playlist.esperanto.proto\";\n\nmessage PlaylistSetBasePermissionRequest {\n    string uri = 1;\n    playlist.cosmos.proto.SetBasePermissionRequest request = 2;\n}\n\nmessage PlaylistSetBasePermissionResponse {\n    ResponseStatus status = 1;\n    playlist.cosmos.proto.SetBasePermissionResponse response = 2;\n}\n"
  },
  {
    "path": "protocol/proto/playlist_set_member_permission_request.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.playlist_esperanto.proto;\n\nimport \"response_status.proto\";\n\noption objc_class_prefix = \"ESP\";\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"spotify.playlist.esperanto.proto\";\n\nmessage PlaylistSetMemberPermissionResponse {\n    ResponseStatus status = 1;\n}\n"
  },
  {
    "path": "protocol/proto/playlist_set_permission_request.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.playlist.cosmos.proto;\n\nimport \"playlist_permission.proto\";\n\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.playlist.proto\";\n\nmessage SetBasePermissionRequest {\n    optional playlist_permission.proto.PermissionLevel permission_level = 1;\n    optional uint32 timeout_ms = 2;\n}\n\nmessage SetBasePermissionResponse {\n    optional playlist_permission.proto.Permission base_permission = 1;\n}\n"
  },
  {
    "path": "protocol/proto/playlist_track_state.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.playlist.cosmos.proto;\n\noption objc_class_prefix = \"SPTPlaylist\";\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.playlist.proto\";\n\nmessage TrackCollectionState {\n    optional bool is_in_collection = 1;\n    optional bool can_add_to_collection = 2;\n    optional bool is_banned = 3;\n    optional bool can_ban = 4;\n}\n\nmessage TrackOfflineState {\n    optional string offline = 1;\n}\n"
  },
  {
    "path": "protocol/proto/playlist_user_state.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.playlist.cosmos.proto;\n\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.playlist.proto\";\n\nmessage User {\n    optional string link = 1;\n    optional string username = 2;\n    optional string display_name = 3;\n    optional string image_uri = 4;\n    optional string thumbnail_uri = 5;\n    optional int32 color = 6;\n}\n"
  },
  {
    "path": "protocol/proto/plugin.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.offline.proto;\n\nimport \"google/protobuf/any.proto\";\nimport \"extension_kind.proto\";\nimport \"resource_type.proto\";\n\noption optimize_for = CODE_SIZE;\n\nmessage PluginRegistry {\n    message Entry {\n        string id = 1;\n        repeated LinkType supported_link_types = 2;\n        ResourceType resource_type = 3;\n        repeated extendedmetadata.ExtensionKind extension_kinds = 4;\n    }\n\n    enum LinkType {\n        EMPTY = 0;\n        TRACK = 1;\n        EPISODE = 2;\n    }\n\n    repeated Entry plugins = 1;\n}\n\nmessage PluginInit {\n    string id = 1;\n}\n\nmessage TargetFormat {\n    int32 bitrate = 1;\n}\n\nmessage Metadata {\n    message Header {\n        int32 status_code = 1;\n        bool is_empty = 2;\n    }\n\n    Header header = 1;\n    google.protobuf.Any extension_data = 2;\n}\n\nmessage IdentifyCommand {\n    message Header {\n        TargetFormat target_format = 1;\n    }\n\n    message Query {\n        message MetadataEntry {\n            int32 key = 1;\n            Metadata value = 2;\n        }\n\n        string link = 1;\n        repeated MetadataEntry metadata = 2;\n    }\n\n    Header header = 3;\n    repeated Query query = 4;\n}\n\nmessage IdentifyResponse {\n    message Result {\n        enum Status {\n            UNKNOWN = 0;\n            MISSING = 1;\n            COMPLETE = 2;\n            NOT_APPLICABLE = 3;\n        }\n\n        Status status = 1;\n        int64 estimated_file_size = 2;\n    }\n\n    map<string, Result> results = 1;\n}\n\nmessage DownloadCommand {\n    message MetadataEntry {\n        int32 key = 1;\n        Metadata value = 2;\n    }\n\n    string link = 1;\n    TargetFormat target_format = 2;\n    repeated MetadataEntry metadata = 3;\n}\n\nmessage DownloadResponse {\n    enum Error {\n        OK = 0;\n        TEMPORARY_ERROR = 1;\n        PERMANENT_ERROR = 2;\n        DISK_FULL = 3;\n    }\n\n    string link = 1;\n    bool complete = 2;\n    int64 file_size = 3;\n    int64 bytes_downloaded = 4;\n    Error error = 5;\n}\n\nmessage StopDownloadCommand {\n    string link = 1;\n}\n\nmessage StopDownloadResponse {\n}\n\nmessage RemoveCommand {\n    message Header {\n    }\n\n    message Query {\n        string link = 1;\n    }\n\n    Header header = 2;\n    repeated Query query = 3;\n}\n\nmessage RemoveResponse {\n}\n\nmessage PluginCommand {\n    string id = 1;\n    oneof command {\n        IdentifyCommand identify = 2;\n        DownloadCommand download = 3;\n        RemoveCommand remove = 4;\n        StopDownloadCommand stop_download = 5;\n    }\n}\n\nmessage PluginResponse {\n    string id = 1;\n    oneof response {\n        IdentifyResponse identify = 2;\n        DownloadResponse download = 3;\n        RemoveResponse remove = 4;\n        StopDownloadResponse stop_download = 5;\n    }\n}\n"
  },
  {
    "path": "protocol/proto/podcast_ad_segments.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.ads.formats;\n\noption objc_class_prefix = \"SPT\";\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_outer_classname = \"PodcastAdsProto\";\noption java_package = \"com.spotify.ads.formats.proto\";\n\nmessage PodcastAds {\n    repeated string file_ids = 1;\n    repeated string manifest_ids = 2;\n    repeated Segment segments = 3;\n    string request_id = 4;\n}\n\nmessage Segment {\n    Slot slot = 1;\n    int32 start_ms = 2;\n    int32 stop_ms = 3;\n}\n\nenum Slot {\n    UNKNOWN = 0;\n    PODCAST_PREROLL = 1;\n    PODCAST_POSTROLL = 2;\n    PODCAST_MIDROLL_1 = 3;\n    PODCAST_MIDROLL_2 = 4;\n    PODCAST_MIDROLL_3 = 5;\n    PODCAST_MIDROLL_4 = 6;\n    PODCAST_MIDROLL_5 = 7;\n}\n"
  },
  {
    "path": "protocol/proto/podcast_cta_cards.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.context_mdata.podcastctacards;\n\nmessage Card {\n    bool has_cards = 1;\n}\n"
  },
  {
    "path": "protocol/proto/podcast_paywalls_cosmos.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto3\";\n\npackage spotify.podcast_paywalls_cosmos.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage PodcastPaywallsShowSubscriptionRequest {\n    string show_uri = 1;\n}\n\nmessage PodcastPaywallsShowSubscriptionResponse {\n    bool is_user_subscribed = 1;\n}\n"
  },
  {
    "path": "protocol/proto/podcast_poll.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.polls;\n\noption objc_class_prefix = \"SPT\";\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_outer_classname = \"PollMetadataProto\";\noption java_package = \"com.spotify.podcastcreatorinteractivity.v1\";\n\nmessage PodcastPoll {\n    Poll poll = 1;\n}\n\nmessage Poll {\n    int32 id = 1;\n    string opening_date = 2;\n    string closing_date = 3;\n    int32 entity_timestamp_ms = 4;\n    string entity_uri = 5;\n    string name = 6;\n    string question = 7;\n    PollType type = 8;\n    repeated PollOption options = 9;\n    PollStatus status = 10;\n}\n\nmessage PollOption {\n    string option = 1;\n    int32 total_votes = 2;\n    int32 poll_id = 3;\n    int32 option_id = 4;\n}\n\nenum PollType {\n    MULTIPLE_CHOICE = 0;\n    SINGLE_CHOICE = 1;\n}\n\nenum PollStatus {\n    DRAFT = 0;\n    SCHEDULED = 1;\n    LIVE = 2;\n    CLOSED = 3;\n    BLOCKED = 4;\n}\n"
  },
  {
    "path": "protocol/proto/podcast_qna.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.qanda;\n\nimport \"google/protobuf/timestamp.proto\";\n\noption objc_class_prefix = \"SPT\";\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_outer_classname = \"QnAMetadataProto\";\noption java_package = \"com.spotify.podcastcreatorinteractivity.v1\";\n\nmessage PodcastQna {\n    Prompt prompt = 1;\n}\n\nmessage Prompt {\n    int32 id = 1;\n    google.protobuf.Timestamp opening_date = 2;\n    google.protobuf.Timestamp closing_date = 3;\n    string text = 4;\n    QAndAStatus status = 5;\n}\n\nenum QAndAStatus {\n    DRAFT = 0;\n    SCHEDULED = 1;\n    LIVE = 2;\n    CLOSED = 3;\n    DELETED = 4;\n}\n"
  },
  {
    "path": "protocol/proto/podcast_ratings.proto",
    "content": "// Extracted from: Spotify 1.1.73.517 (macOS)\n\nsyntax = \"proto3\";\n\npackage spotify.ratings;\n\nimport \"google/protobuf/timestamp.proto\";\n\noption objc_class_prefix = \"SPT\";\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_outer_classname = \"RatingsMetadataProto\";\noption java_package = \"com.spotify.podcastcreatorinteractivity.v1\";\n\nmessage Rating {\n    string user_id = 1;\n    string show_uri = 2;\n    int32 rating = 3;\n    google.protobuf.Timestamp rated_at = 4;\n}\n\nmessage AverageRating {\n    double average = 1;\n    int64 total_ratings = 2;\n    bool show_average = 3;\n}\n\nmessage PodcastRating {\n    AverageRating average_rating = 1;\n    Rating rating = 2;\n    bool can_rate = 3;\n}\n"
  },
  {
    "path": "protocol/proto/podcast_segments.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto3\";\n\npackage spotify.podcast_segments;\n\noption objc_class_prefix = \"SPT\";\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_outer_classname = \"PodcastSegmentsProto\";\noption java_package = \"com.spotify.podcastsegments.proto\";\n\nmessage PodcastSegments {\n    string episode_uri = 1;\n    repeated PlaybackSegment playback_segments = 2;\n    repeated EmbeddedSegment embedded_segments = 3;\n    bool can_upsell = 4;\n    string album_mosaic_uri = 5;\n    repeated string artists = 6;\n    int32 duration_ms = 7;\n}\n\nmessage PlaybackSegment {\n    string uri = 1;\n    int32 start_ms = 2;\n    int32 stop_ms = 3;\n    int32 duration_ms = 4;\n    SegmentType type = 5;\n    string title = 6;\n    string subtitle = 7;\n    string image_url = 8;\n    string action_url = 9;\n    bool is_abridged = 10;\n}\n\nmessage EmbeddedSegment {\n    string uri = 1;\n    int32 absolute_start_ms = 2;\n    int32 absolute_stop_ms = 3;\n}\n\nenum SegmentType {\n    UNKNOWN = 0;\n    TALK = 1;\n    MUSIC = 2;\n    UPSELL = 3;\n}\n"
  },
  {
    "path": "protocol/proto/podcast_segments_cosmos_request.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto3\";\n\npackage spotify.podcast_segments.cosmos.proto;\n\nimport \"policy/album_decoration_policy.proto\";\nimport \"policy/artist_decoration_policy.proto\";\nimport \"policy/episode_decoration_policy.proto\";\nimport \"policy/track_decoration_policy.proto\";\nimport \"policy/show_decoration_policy.proto\";\n\noption optimize_for = CODE_SIZE;\n\nmessage SegmentsRequest {\n    repeated string episode_uris = 1;\n    TrackDecorationPolicy track_decoration_policy = 2;\n    SegmentsPolicy segments_policy = 3;\n    EpisodeDecorationPolicy episode_decoration_policy = 4;\n}\n\nmessage TrackDecorationPolicy {\n    cosmos_util.proto.TrackDecorationPolicy track_policy = 1;\n    cosmos_util.proto.ArtistDecorationPolicy artists_policy = 2;\n    cosmos_util.proto.AlbumDecorationPolicy album_policy = 3;\n    cosmos_util.proto.ArtistDecorationPolicy album_artist_policy = 4;\n}\n\nmessage SegmentsPolicy {\n    bool playback = 1;\n    bool embedded = 2;\n}\n\nmessage EpisodeDecorationPolicy {\n    cosmos_util.proto.EpisodeDecorationPolicy episode_policy = 1;\n    cosmos_util.proto.ShowDecorationPolicy show_decoration_policy = 2;\n}\n"
  },
  {
    "path": "protocol/proto/podcast_segments_cosmos_response.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto3\";\n\npackage spotify.podcast_segments.cosmos.proto;\n\nimport \"metadata/episode_metadata.proto\";\nimport \"podcast_segments.proto\";\nimport \"metadata/track_metadata.proto\";\n\noption optimize_for = CODE_SIZE;\n\nmessage SegmentsResponse {\n    bool success = 1;\n    repeated EpisodeSegments episode_segments = 2;\n}\n\nmessage EpisodeSegments {\n    string episode_uri = 1;\n    repeated DecoratedSegment segments = 2;\n    bool can_upsell = 3;\n    string album_mosaic_uri = 4;\n    repeated string artists = 5;\n    int32 duration_ms = 6;\n}\n\nmessage DecoratedSegment {\n    string uri = 1;\n    int32 start_ms = 2;\n    int32 stop_ms = 3;\n    cosmos_util.proto.TrackMetadata track_metadata = 4;\n    SegmentType type = 5;\n    string title = 6;\n    string subtitle = 7;\n    string image_url = 8;\n    string action_url = 9;\n    cosmos_util.proto.EpisodeMetadata episode_metadata = 10;\n    bool is_abridged = 11;\n}\n"
  },
  {
    "path": "protocol/proto/podcast_subscription.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.podcast_paywalls;\n\noption objc_class_prefix = \"SPT\";\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_outer_classname = \"PodcastSubscriptionProto\";\noption java_package = \"com.spotify.podcastsubscription.proto\";\n\nmessage PodcastSubscription {\n    bool is_paywalled = 1;\n    bool is_user_subscribed = 2;\n\n    UserExplanation user_explanation = 3;\n    enum UserExplanation {\n        SUBSCRIPTION_DIALOG = 0;\n        NONE = 1;\n    }\n}\n"
  },
  {
    "path": "protocol/proto/podcast_virality.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto3\";\n\npackage spotify.podcastvirality.v1;\n\noption objc_class_prefix = \"SPT\";\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_outer_classname = \"PodcastViralityProto\";\noption java_package = \"com.spotify.podcastvirality.proto.v1\";\n\nmessage PodcastVirality {\n    bool is_viral = 1;\n}\n"
  },
  {
    "path": "protocol/proto/podcastextensions.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.podcast.extensions;\n\noption objc_class_prefix = \"SPT\";\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_outer_classname = \"PodcastExtensionsProto\";\noption java_package = \"com.spotify.podcastextensions.proto\";\n\nmessage PodcastTopics {\n    repeated PodcastTopic topics = 1;\n}\n\nmessage PodcastTopic {\n    string uri = 1;\n    string title = 2;\n}\n\nmessage PodcastHtmlDescription {\n    Header header = 1;\n    message Header {\n    }\n\n    string html_description = 2;\n}\n"
  },
  {
    "path": "protocol/proto/policy/album_decoration_policy.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.cosmos_util.proto;\n\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.cosmos.util.policy.proto\";\n\nmessage AlbumDecorationPolicy {\n    bool link = 1;\n    bool name = 2;\n    bool copyrights = 3;\n    bool covers = 4;\n    bool year = 5;\n    bool num_discs = 6;\n    bool num_tracks = 7;\n    bool playability = 8;\n    bool is_premium_only = 9;\n}\n\nmessage AlbumCollectionDecorationPolicy {\n    bool collection_link = 1;\n    bool num_tracks_in_collection = 2;\n    bool complete = 3;\n}\n\nmessage AlbumSyncDecorationPolicy {\n    bool inferred_offline = 1;\n    bool offline_state = 2;\n    bool sync_progress = 3;\n}\n"
  },
  {
    "path": "protocol/proto/policy/artist_decoration_policy.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.cosmos_util.proto;\n\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.cosmos.util.policy.proto\";\n\nmessage ArtistDecorationPolicy {\n    bool link = 1;\n    bool name = 2;\n    bool is_various_artists = 3;\n    bool portraits = 4;\n}\n\nmessage ArtistCollectionDecorationPolicy {\n    bool collection_link = 1;\n    bool is_followed = 2;\n    bool num_tracks_in_collection = 3;\n    bool num_albums_in_collection = 4;\n    bool is_banned = 5;\n    bool can_ban = 6;\n    bool num_explicitly_liked_tracks = 8;\n}\n\nmessage ArtistSyncDecorationPolicy {\n    bool inferred_offline = 1;\n    bool offline_state = 2;\n    bool sync_progress = 3;\n}\n"
  },
  {
    "path": "protocol/proto/policy/episode_decoration_policy.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.cosmos_util.proto;\n\nimport \"extension_kind.proto\";\n\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.cosmos.util.policy.proto\";\n\nmessage EpisodeDecorationPolicy {\n    reserved 19;\n    reserved 20;\n    bool link = 1;\n    bool length = 2;\n    bool name = 3;\n    bool manifest_id = 4;\n    bool preview_id = 5;\n    bool preview_manifest_id = 6;\n    bool description = 7;\n    bool publish_date = 8;\n    bool covers = 9;\n    bool freeze_frames = 10;\n    bool language = 11;\n    bool available = 12;\n    bool media_type_enum = 13;\n    bool number = 14;\n    bool backgroundable = 15;\n    bool is_explicit = 16;\n    bool type = 17;\n    bool is_music_and_talk = 18;\n    repeated extendedmetadata.ExtensionKind extension = 21;\n    bool is_19_plus_only = 22;\n    bool is_book_chapter = 23;\n    bool is_podcast_short = 24;\n    bool is_curated = 25;\n}\n\nmessage EpisodeCollectionDecorationPolicy {\n    bool is_following_show = 1;\n    bool is_in_listen_later = 2;\n    bool is_new = 3;\n}\n\nmessage EpisodeSyncDecorationPolicy {\n    bool offline = 1;\n    bool sync_progress = 2;\n}\n\nmessage EpisodePlayedStateDecorationPolicy {\n    bool time_left = 1;\n    bool is_played = 2;\n    bool playable = 3;\n    bool playability_restriction = 4;\n    bool last_played_at = 5;\n}\n\n"
  },
  {
    "path": "protocol/proto/policy/folder_decoration_policy.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.playlist.cosmos.proto;\n\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.playlist.policy.proto\";\n\nmessage FolderDecorationPolicy {\n    bool row_id = 1;\n    bool id = 2;\n    bool link = 3;\n    bool name = 4;\n    bool folders = 5;\n    bool playlists = 6;\n    bool recursive_folders = 7;\n    bool recursive_playlists = 8;\n    bool rows = 9;\n}\n"
  },
  {
    "path": "protocol/proto/policy/playlist_album_decoration_policy.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.playlist.cosmos.proto;\n\nimport \"policy/album_decoration_policy.proto\";\nimport \"policy/artist_decoration_policy.proto\";\n\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.playlist.policy.proto\";\n\nmessage PlaylistAlbumDecorationPolicy {\n    cosmos_util.proto.AlbumDecorationPolicy album = 1;\n    cosmos_util.proto.ArtistDecorationPolicy artist = 2;\n}\n"
  },
  {
    "path": "protocol/proto/policy/playlist_decoration_policy.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.playlist.cosmos.proto;\n\nimport \"extension_kind.proto\";\nimport \"policy/user_decoration_policy.proto\";\n\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.playlist.policy.proto\";\n\nmessage PlaylistAllowsDecorationPolicy {\n    bool insert = 1;\n    bool remove = 2;\n}\n\nmessage PlaylistDecorationPolicy {\n    bool row_id = 1;\n    bool link = 2;\n    bool name = 3;\n    bool load_state = 4;\n    bool loaded = 5;\n    bool collaborative = 6;\n    bool length = 7;\n    bool last_modification = 8;\n    bool total_length = 9;\n    bool duration = 10;\n    bool description = 11;\n    bool picture = 12;\n    bool playable = 13;\n    bool description_from_annotate = 14;\n    bool picture_from_annotate = 15;\n    bool can_report_annotation_abuse = 16;\n    bool followed = 17;\n    bool followers = 18;\n    bool owned_by_self = 19;\n    bool offline = 20;\n    bool sync_progress = 21;\n    bool published = 22;\n    bool browsable_offline = 23;\n    bool format_list_type = 24;\n    bool format_list_attributes = 25;\n    bool has_explicit_content = 26;\n    bool contains_spotify_tracks = 27;\n    bool contains_tracks = 28;\n    bool contains_episodes = 29;\n    bool contains_audio_episodes = 30;\n    bool only_contains_explicit = 31;\n    bool is_on_demand_in_free = 32;\n    UserDecorationPolicy owner = 33;\n    UserDecorationPolicy made_for = 34;\n    PlaylistAllowsDecorationPolicy allows = 35;\n    bool number_of_episodes = 36;\n    bool number_of_tracks = 37;\n    bool prefer_linear_playback = 38;\n    bool on_demand_in_free_reason = 39;\n    CollaboratingUsersDecorationPolicy collaborating_users = 40;\n    bool base_permission = 41;\n    bool user_capabilities = 42;\n    repeated extendedmetadata.ExtensionKind extension = 43;\n    bool lenses = 44;\n    bool length_ignoring_text_filter = 45;\n    bool number_of_items_per_link_type = 46;\n    bool available_signals = 47;\n    bool ai_curation_reference_id = 48;\n    bool unranged_length = 49;\n    bool unfiltered_length = 50;\n}\n"
  },
  {
    "path": "protocol/proto/policy/playlist_episode_decoration_policy.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.playlist.cosmos.proto;\n\nimport \"policy/episode_decoration_policy.proto\";\nimport \"policy/show_decoration_policy.proto\";\nimport \"policy/user_decoration_policy.proto\";\n\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.playlist.policy.proto\";\n\nmessage PlaylistEpisodeDecorationPolicy {\n    cosmos_util.proto.EpisodeDecorationPolicy episode = 1;\n    bool row_id = 2;\n    bool add_time = 3;\n    bool format_list_attributes = 4;\n    cosmos_util.proto.EpisodeCollectionDecorationPolicy collection = 5;\n    cosmos_util.proto.EpisodeSyncDecorationPolicy sync = 6;\n    cosmos_util.proto.EpisodePlayedStateDecorationPolicy played_state = 7;\n    UserDecorationPolicy added_by = 8;\n    cosmos_util.proto.ShowDecorationPolicy show = 9;\n    bool signals = 10;\n    bool is_recommendation = 11;\n}\n"
  },
  {
    "path": "protocol/proto/policy/playlist_request_decoration_policy.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.playlist.cosmos.proto;\n\nimport \"policy/playlist_decoration_policy.proto\";\nimport \"policy/playlist_episode_decoration_policy.proto\";\nimport \"policy/playlist_track_decoration_policy.proto\";\nimport \"policy/supported_link_types_in_playlists.proto\";\nimport \"extension_kind.proto\";\n\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.playlist.policy.proto\";\n\nmessage ItemExtensionPolicy {\n    LinkType link_type = 1;\n    extendedmetadata.ExtensionKind extension = 2;\n}\n\nmessage ItemOfflineStateDecorationPolicy {\n    bool offline_state = 1;\n    bool sync_progress = 2;\n    bool locally_playable = 3;\n}\n\nmessage ItemMetadataPolicy {\n    bool name = 1;\n    bool image = 2;\n    bool is_explicit = 3;\n}\n\nmessage ItemCurationStatePolicy {\n    bool is_curated = 1;\n}\n\nmessage PlaylistItemDecorationPolicy {\n    bool uri = 1;\n    repeated ItemExtensionPolicy extension_policy = 2;\n    ItemOfflineStateDecorationPolicy offline_state = 3;\n    bool collection_state = 4;\n    ItemMetadataPolicy metadata = 5;\n    ItemCurationStatePolicy curation_state = 6;\n    bool obfuscation_state = 7;\n}\n\nmessage PlaylistRequestDecorationPolicy {\n    PlaylistDecorationPolicy playlist = 1;\n    PlaylistTrackDecorationPolicy track = 2;\n    PlaylistEpisodeDecorationPolicy episode = 3;\n    PlaylistItemDecorationPolicy item = 4;\n}\n"
  },
  {
    "path": "protocol/proto/policy/playlist_track_decoration_policy.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.playlist.cosmos.proto;\n\nimport \"policy/artist_decoration_policy.proto\";\nimport \"policy/track_decoration_policy.proto\";\nimport \"policy/playlist_album_decoration_policy.proto\";\nimport \"policy/user_decoration_policy.proto\";\n\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.playlist.policy.proto\";\n\nmessage PlaylistTrackDecorationPolicy {\n    cosmos_util.proto.TrackDecorationPolicy track = 1;\n    bool row_id = 2;\n    bool add_time = 3;\n    bool in_collection = 4;\n    bool can_add_to_collection = 5;\n    bool is_banned = 6;\n    bool can_ban = 7;\n    bool local_file = 8;\n    bool offline = 9;\n    bool format_list_attributes = 10;\n    bool display_covers = 11;\n    UserDecorationPolicy added_by = 12;\n    PlaylistAlbumDecorationPolicy album = 13;\n    cosmos_util.proto.ArtistDecorationPolicy artist = 14;\n    bool signals = 15;\n    bool is_recommendation = 16;\n}\n"
  },
  {
    "path": "protocol/proto/policy/rootlist_folder_decoration_policy.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.playlist.cosmos.proto;\n\nimport \"policy/folder_decoration_policy.proto\";\n\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.playlist.policy.proto\";\n\nmessage RootlistFolderDecorationPolicy {\n    optional bool add_time = 1;\n    optional FolderDecorationPolicy folder = 2;\n    optional bool group_label = 3;\n}\n"
  },
  {
    "path": "protocol/proto/policy/rootlist_playlist_decoration_policy.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.playlist.cosmos.proto;\n\nimport \"policy/playlist_decoration_policy.proto\";\n\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.playlist.policy.proto\";\n\nmessage RootlistPlaylistDecorationPolicy {\n    optional bool add_time = 1;\n    optional PlaylistDecorationPolicy playlist = 2;\n    optional bool group_label = 3;\n}\n"
  },
  {
    "path": "protocol/proto/policy/rootlist_request_decoration_policy.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.playlist.cosmos.proto;\n\nimport \"policy/rootlist_folder_decoration_policy.proto\";\nimport \"policy/rootlist_playlist_decoration_policy.proto\";\n\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.playlist.policy.proto\";\n\nmessage RootlistRequestDecorationPolicy {\n    optional bool unfiltered_length = 1;\n    optional bool unranged_length = 2;\n    optional bool is_loading_contents = 3;\n    optional RootlistPlaylistDecorationPolicy playlist = 4;\n    optional RootlistFolderDecorationPolicy folder = 5;\n}\n"
  },
  {
    "path": "protocol/proto/policy/show_decoration_policy.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.cosmos_util.proto;\n\nimport \"extension_kind.proto\";\n\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.cosmos.util.policy.proto\";\n\nmessage ShowDecorationPolicy {\n    reserved 15;\n    bool link = 1;\n    bool name = 2;\n    bool description = 3;\n    bool popularity = 4;\n    bool publisher = 5;\n    bool language = 6;\n    bool is_explicit = 7;\n    bool covers = 8;\n    bool num_episodes = 9;\n    bool consumption_order = 10;\n    bool media_type_enum = 11;\n    bool copyrights = 12;\n    bool trailer_uri = 13;\n    bool is_music_and_talk = 14;\n    repeated extendedmetadata.ExtensionKind extension = 16;\n    bool is_book = 17;\n    bool is_creator_channel = 18;\n}\n\nmessage ShowPlayedStateDecorationPolicy {\n    bool latest_played_episode_link = 1;\n    bool played_time = 2;\n    bool is_playable = 3;\n    bool playability_restriction = 4;\n    bool label = 5;\n    bool resume_episode_link = 7;\n}\n\nmessage ShowCollectionDecorationPolicy {\n    bool is_in_collection = 1;\n}\n\nmessage ShowOfflineStateDecorationPolicy {\n    bool offline = 1;\n    bool sync_progress = 2;\n}\n\n"
  },
  {
    "path": "protocol/proto/policy/supported_link_types_in_playlists.proto",
    "content": "syntax = \"proto3\";\n\npackage spotify.playlist.cosmos.proto;\n\noption java_package = \"com.spotify.playlist.policy.proto\";\noption java_multiple_files = true;\n\nenum LinkType {\n  EMPTY = 0;\n  ARTIST = 1;\n  ALBUM = 2;\n  TRACK = 4;\n  LOCAL_TRACK = 9;\n  SHOW = 62;\n  EPISODE = 63;\n}\n\n"
  },
  {
    "path": "protocol/proto/policy/track_decoration_policy.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.cosmos_util.proto;\n\nimport \"extension_kind.proto\";\n\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.cosmos.util.policy.proto\";\n\nmessage TrackDecorationPolicy {\n    bool has_lyrics = 1;\n    bool link = 2;\n    bool name = 3;\n    bool length = 4;\n    bool playable = 5;\n    bool is_available_in_metadata_catalogue = 6;\n    bool locally_playable = 7;\n    bool playable_local_track = 8;\n    bool disc_number = 9;\n    bool track_number = 10;\n    bool is_explicit = 11;\n    bool preview_id = 12;\n    bool is_local = 13;\n    bool is_premium_only = 14;\n    bool playable_track_link = 15;\n    bool popularity = 16;\n    bool is_19_plus_only = 17;\n    bool track_descriptors = 18;\n    repeated extendedmetadata.ExtensionKind extension = 19;\n    bool is_curated = 20;\n    bool to_be_obfuscated = 22;\n}\n\nmessage TrackPlayedStateDecorationPolicy {\n    bool playable = 1;\n    bool is_currently_playable = 2;\n    bool playability_restriction = 3;\n}\n\nmessage TrackCollectionDecorationPolicy {\n    bool is_in_collection = 1;\n    bool can_add_to_collection = 2;\n    bool is_banned = 3;\n    bool can_ban = 4;\n}\n\nmessage TrackSyncDecorationPolicy {\n    bool offline_state = 1;\n    bool sync_progress = 2;\n}\n"
  },
  {
    "path": "protocol/proto/policy/user_decoration_policy.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.playlist.cosmos.proto;\n\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.playlist.policy.proto\";\n\nmessage UserDecorationPolicy {\n    bool username = 1;\n    bool link = 2;\n    bool name = 3;\n    bool image = 4;\n    bool thumbnail = 5;\n    bool color = 6;\n}\n\nmessage CollaboratorPolicy {\n    UserDecorationPolicy user = 1;\n    bool number_of_items = 2;\n    bool number_of_tracks = 3;\n    bool number_of_episodes = 4;\n    bool is_owner = 5;\n}\n\nmessage CollaboratingUsersDecorationPolicy {\n    bool count = 1;\n    int32 limit = 2;\n    CollaboratorPolicy collaborator = 3;\n}\n"
  },
  {
    "path": "protocol/proto/popcount2_external.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.popcount2.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage PopcountRequest {\n}\n\nmessage PopcountResult {\n    optional sint64 count = 1;\n    optional bool truncated = 2;\n    repeated string user = 3;\n    repeated string userid = 6;\n    optional int64 raw_count = 7;\n    optional bool count_hidden_from_users = 8;\n}\n\nmessage PopcountUserUpdate {\n    optional string user = 1;\n    optional sint64 timestamp = 2;\n    optional bool added = 3;\n    optional string userid = 4;\n}\n\nmessage PopcountFollowerResult {\n    optional bool is_truncated = 1;\n    repeated bytes user_id = 2;\n}\n\nmessage PopcountSetFollowerCounterValueRequest {\n    optional int64 count = 1;\n}\n"
  },
  {
    "path": "protocol/proto/prepare_play_options.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.player.proto;\n\nimport \"context_player_options.proto\";\nimport \"player_license.proto\";\nimport \"skip_to_track.proto\";\n\noption optimize_for = CODE_SIZE;\n\nmessage PreparePlayOptions {\n    optional ContextPlayerOptionOverrides player_options_override = 1;\n    optional PlayerLicense license = 2;\n    map<string, string> configuration_override = 3;\n    optional string playback_id = 4;\n    optional bool always_play_something = 5;\n    optional SkipToTrack skip_to_track = 6;\n    optional int64 seek_to = 7;\n    optional bool initially_paused = 8;\n    optional bool system_initiated = 9;\n    repeated string suppressions = 10;\n    optional PrefetchLevel prefetch_level = 11;\n    optional string session_id = 12;\n    optional AudioStream audio_stream = 13;\n}\n\nenum PrefetchLevel {\n    NONE = 0;\n    MEDIA = 1;\n}\n\nenum AudioStream {\n    DEFAULT = 0;\n    ALARM = 1;\n}\n"
  },
  {
    "path": "protocol/proto/profile_cosmos.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.profile_cosmos.proto;\n\nimport \"identity.proto\";\n\noption optimize_for = CODE_SIZE;\n\nmessage GetProfilesRequest {\n    repeated string usernames = 1;\n}\n\nmessage GetProfilesResponse {\n    repeated identity.v3.UserProfile profiles = 1;\n}\n\nmessage ChangeDisplayNameRequest {\n    string username = 1;\n    string display_name = 2;\n}\n"
  },
  {
    "path": "protocol/proto/profile_service.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.profile_esperanto.proto.v1;\n\nimport \"identity.proto\";\n\noption java_package = \"spotify.profile_esperanto.proto\";\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\n\nservice ProfileService {\n    rpc GetProfiles(GetProfilesRequest) returns (GetProfilesResponse);\n    rpc SubscribeToProfiles(GetProfilesRequest) returns (stream GetProfilesResponse);\n    rpc ChangeDisplayName(ChangeDisplayNameRequest) returns (ChangeDisplayNameResponse);\n}\n\nmessage GetProfilesRequest {\n    repeated string usernames = 1;\n}\n\nmessage GetProfilesResponse {\n    repeated identity.v3.UserProfile profiles = 1;\n    int32 status_code = 2;\n}\n\nmessage ChangeDisplayNameRequest {\n    string username = 1;\n    string display_name = 2;\n}\n\nmessage ChangeDisplayNameResponse {\n    int32 status_code = 1;\n}\n"
  },
  {
    "path": "protocol/proto/property_definition.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.remote_config.ucs.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage PropertyDefinition {\n    reserved \"hash\";\n    reserved 2;\n\n    message BoolSpec {\n        bool default = 1;\n    }\n\n    message IntSpec {\n        int32 default = 1;\n        int32 lower = 2;\n        int32 upper = 3;\n    }\n\n    message EnumSpec {\n        string default = 1;\n        repeated string values = 2;\n    }\n\n    Identifier id = 1;\n    message Identifier {\n        string scope = 1;\n        string name = 2;\n    }\n\n    Metadata metadata = 4;\n    message Metadata {\n        string component_id = 1;\n        string description = 2;\n    }\n\n    oneof specification {\n        BoolSpec bool_spec = 5;\n        IntSpec int_spec = 6;\n        EnumSpec enum_spec = 7;\n    }\n}\n"
  },
  {
    "path": "protocol/proto/protobuf_delta.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.protobuf_deltas.proto;\n\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.cosmos.util.proto\";\n\nmessage Delta {\n    required Type type = 1;\n    enum Type {\n        DELETE = 0;\n        INSERT = 1;\n    }\n\n    required uint32 index = 2;\n    required uint32 length = 3;\n}\n"
  },
  {
    "path": "protocol/proto/pubsub.proto",
    "content": "syntax = \"proto2\";\n\nmessage Subscription {\n    optional string uri = 0x1;\n    optional int32 expiry = 0x2;\n    optional int32 status_code = 0x3;\n}\n\n"
  },
  {
    "path": "protocol/proto/queue.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.player.proto.transfer;\n\nimport \"context_track.proto\";\n\noption optimize_for = CODE_SIZE;\n\nmessage Queue {\n    repeated ContextTrack tracks = 1;\n    optional bool is_playing_queue = 2;\n}\n"
  },
  {
    "path": "protocol/proto/rate_limited_events.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage RateLimitedEventsEntity {\n    int32 file_format_version = 1;\n    map<string, uint32> map_field = 2;\n}\n"
  },
  {
    "path": "protocol/proto/rcs.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.remote_config.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage GranularConfiguration {\n    message AssignedPropertyValue {\n        Platform platform = 7;\n        string client_id = 4;\n        string component_id = 5;\n        int64 groupId = 8;\n        string name = 6;\n        oneof structured_value {\n            BoolValue bool_value = 1;\n            IntValue int_value = 2;\n            EnumValue enum_value = 3;\n        }\n\n        message BoolValue {\n            bool value = 1;\n        }\n\n        message IntValue {\n            int32 value = 1;\n        }\n\n        message EnumValue {\n            string value = 1;\n        }\n    }\n\n    repeated AssignedPropertyValue properties = 1;\n    int64 rcs_fetch_time = 2;\n    string configuration_assignment_id = 3;\n    string etag = 10;\n}\n\nmessage PolicyGroupId {\n    int64 policy_id = 1;\n    int64 policy_group_id = 2;\n}\n\nmessage ClientPropertySet {\n    message ComponentInfo {\n        reserved \"owner\";\n        reserved \"tags\";\n        reserved 1;\n        reserved 2;\n        string name = 3;\n    }\n\n    message PublisherInfo {\n        string published_for_client_version = 1;\n        int64 published_at = 2;\n    }\n\n    string client_id = 1;\n    string version = 2;\n    repeated PropertyDefinition properties = 5;\n    repeated ComponentInfo component_infos = 6;\n    string property_set_key = 7;\n    PublisherInfo publisherInfo = 8;\n}\n\nmessage PropertyDefinition {\n    reserved 1;\n    message BoolSpec {\n        bool default = 1;\n    }\n\n    message IntSpec {\n        int32 default = 1;\n        int32 lower = 2;\n        int32 upper = 3;\n    }\n\n    message EnumSpec {\n        string default = 1;\n        repeated string values = 2;\n    }\n\n    string description = 2;\n    string component_id = 3;\n    Platform platform = 8;\n    oneof identifier {\n        string id = 9;\n        string name = 7;\n    }\n    oneof spec {\n        BoolSpec bool_spec = 4;\n        IntSpec int_spec = 5;\n        EnumSpec enum_spec = 6;\n    }\n}\n\nenum Platform {\n    UNKNOWN_PLATFORM = 0;\n    ANDROID_PLATFORM = 1;\n    BACKEND_PLATFORM = 2;\n    IOS_PLATFORM = 3;\n    WEB_PLATFORM = 4;\n}\n"
  },
  {
    "path": "protocol/proto/recently_played.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.recently_played.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage Item {\n    string uri = 1;\n    int64 timestamp = 2;\n    bool hidden = 3;\n}\n"
  },
  {
    "path": "protocol/proto/recently_played_backend.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.recently_played_backend.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage Context {\n    optional string uri = 1;\n    optional int64 lastPlayedTime = 2;\n}\n\nmessage RecentlyPlayed {\n    repeated Context contexts = 1;\n    optional int32 offset = 2;\n    optional int32 total = 3;\n}\n"
  },
  {
    "path": "protocol/proto/record_id.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage RecordId {\n    uint64 value = 1;\n}\n"
  },
  {
    "path": "protocol/proto/remote.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.player.shuffle.remote;\n\noption optimize_for = CODE_SIZE;\n\nmessage ServiceRequest {\n    repeated Track tracks = 1;\n    message Track {\n        required string uri = 1;\n        required string uid = 2;\n    }\n}\n\nmessage ServiceResponse {\n    repeated uint32 order = 1;\n}\n"
  },
  {
    "path": "protocol/proto/repeating_track_node.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.player.proto;\n\nimport \"track_instance.proto\";\nimport \"track_instantiator.proto\";\n\noption optimize_for = CODE_SIZE;\n\nmessage RepeatingTrackNode {\n    optional TrackInstance instance = 1;\n    optional TrackInstantiator instantiator = 2;\n}\n"
  },
  {
    "path": "protocol/proto/request_failure.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.image.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage RequestFailure {\n    optional string request = 1;\n    optional string source = 2;\n    optional string error = 3;\n    optional int64 result = 4;\n}\n"
  },
  {
    "path": "protocol/proto/resolve.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.remote_config.ucs.proto;\n\nimport \"property_definition.proto\";\n\noption optimize_for = CODE_SIZE;\n\nmessage ResolveRequest {\n    reserved \"custom_context\";\n    reserved \"projection\";\n    reserved 4;\n    reserved 5;\n    string property_set_id = 1;\n    Fetch fetch_type = 2;\n    Context context = 11;\n    oneof resolution_context {\n        BackendContext backend_context = 12;\n    }\n}\n\nmessage ResolveResponse {\n    Configuration configuration = 1;\n}\n\nmessage Configuration {\n    message AssignedValue {\n        message Metadata {\n            int64 policy_id = 1;\n            string external_realm = 2;\n            int64 external_realm_id = 3;\n        }\n\n        message BoolValue {\n            bool value = 1;\n        }\n\n        message IntValue {\n            int32 value = 1;\n        }\n\n        message EnumValue {\n            string value = 1;\n        }\n\n        PropertyDefinition.Identifier property_id = 1;\n        Metadata metadata = 2;\n        oneof structured_value {\n            BoolValue bool_value = 3;\n            IntValue int_value = 4;\n            EnumValue enum_value = 5;\n        }\n    }\n\n    string configuration_assignment_id = 1;\n    int64 fetch_time_millis = 2;\n    repeated AssignedValue assigned_values = 3;\n}\n\nmessage Fetch {\n    enum Type {\n        BLOCKING = 0;\n        BACKGROUND_SYNC = 1;\n        ASYNC = 2;\n        PUSH_INITIATED = 3;\n        RECONNECT = 4;\n    }\n\n    Type type = 1;\n}\n\nmessage Context {\n    message ContextEntry {\n        string value = 10;\n        oneof context {\n            DynamicContext.KnownContext known_context = 1;\n            string policy_input_name = 2;\n        }\n    }\n\n    repeated ContextEntry context = 1;\n}\n\nmessage BackendContext {\n    message StaticContext {\n        string system = 1;\n        string service_name = 2;\n    }\n\n    message SurfaceMetadata {\n        string backend_sdk_version = 1;\n    }\n\n    string system = 1;\n    string service_name = 2;\n    StaticContext static_context = 3;\n    DynamicContext dynamic_context = 4;\n    SurfaceMetadata surface_metadata = 10;\n}\n\nmessage DynamicContext {\n    message ContextDefinition {\n        oneof context {\n            KnownContext known_context = 1;\n        }\n    }\n\n    enum KnownContext {\n        KNOWN_CONTEXT_INVALID = 0;\n        KNOWN_CONTEXT_USER_ID = 1;\n        KNOWN_CONTEXT_INSTALLATION_ID = 2;\n        KNOWN_CONTEXT_VERSION = 3;\n    }\n\n    repeated ContextDefinition context_definition = 1;\n}\n"
  },
  {
    "path": "protocol/proto/resource_type.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.offline.proto;\n\noption optimize_for = CODE_SIZE;\n\nenum ResourceType {\n    OTHER = 0;\n    AUDIO = 1;\n    DRM = 2;\n    IMAGE = 3;\n    VIDEO = 4;\n}\n"
  },
  {
    "path": "protocol/proto/response_status.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.playlist_esperanto.proto;\n\noption objc_class_prefix = \"SPTPlaylistEsperanto\";\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"spotify.playlist.esperanto.proto\";\n\nmessage ResponseStatus {\n    int32 status_code = 1;\n    string reason = 2;\n}\n"
  },
  {
    "path": "protocol/proto/restrictions.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.player.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage ModeRestrictions {\n    map<string, RestrictionReasons> values = 1;\n}\n\nmessage RestrictionReasons {\n    repeated string reasons = 1;\n}\n\nmessage Restrictions {\n    reserved 24;\n\n    repeated string disallow_pausing_reasons = 1;\n    repeated string disallow_resuming_reasons = 2;\n    repeated string disallow_seeking_reasons = 3;\n    repeated string disallow_peeking_prev_reasons = 4;\n    repeated string disallow_peeking_next_reasons = 5;\n    repeated string disallow_skipping_prev_reasons = 6;\n    repeated string disallow_skipping_next_reasons = 7;\n    repeated string disallow_toggling_repeat_context_reasons = 8;\n    repeated string disallow_toggling_repeat_track_reasons = 9;\n    repeated string disallow_toggling_shuffle_reasons = 10;\n    repeated string disallow_set_queue_reasons = 11;\n    repeated string disallow_interrupting_playback_reasons = 12;\n    repeated string disallow_transferring_playback_reasons = 13;\n    repeated string disallow_remote_control_reasons = 14;\n    repeated string disallow_inserting_into_next_tracks_reasons = 15;\n    repeated string disallow_inserting_into_context_tracks_reasons = 16;\n    repeated string disallow_reordering_in_next_tracks_reasons = 17;\n    repeated string disallow_reordering_in_context_tracks_reasons = 18;\n    repeated string disallow_removing_from_next_tracks_reasons = 19;\n    repeated string disallow_removing_from_context_tracks_reasons = 20;\n    repeated string disallow_updating_context_reasons = 21;\n    repeated string disallow_add_to_queue_reasons = 22;\n    repeated string disallow_setting_playback_speed = 23;\n    map<string, ModeRestrictions> disallow_setting_modes = 25;\n    map<string, RestrictionReasons> disallow_signals = 26;\n}\n\n"
  },
  {
    "path": "protocol/proto/resume_points_node.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify_shows.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage ResumePointsNode {\n    optional int64 resume_point = 1;\n}\n"
  },
  {
    "path": "protocol/proto/rootlist_request.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.playlist.cosmos.rootlist_request.proto;\n\nimport \"playlist_folder_state.proto\";\nimport \"playlist_permission.proto\";\nimport \"playlist_playlist_state.proto\";\nimport \"protobuf_delta.proto\";\n\noption objc_class_prefix = \"SPTPlaylistCosmosRootlist\";\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.playlist.proto\";\n\nmessage Playlist {\n    optional string row_id = 1;\n    optional cosmos.proto.PlaylistMetadata playlist_metadata = 2;\n    optional cosmos.proto.PlaylistOfflineState playlist_offline_state = 3;\n    optional uint32 add_time = 4;\n    optional bool is_on_demand_in_free = 5;\n    optional string group_label = 6;\n    optional playlist_permission.proto.Capabilities capabilities = 7;\n}\n\nmessage Item {\n    optional string header_field = 1;\n    optional Folder folder = 2;\n    optional Playlist playlist = 3;\n    optional protobuf_deltas.proto.Delta delta = 4;\n}\n\nmessage Folder {\n    repeated Item item = 1;\n    optional cosmos.proto.FolderMetadata folder_metadata = 2;\n    optional string row_id = 3;\n    optional uint32 add_time = 4;\n    optional string group_label = 5;\n}\n\nmessage Response {\n    optional Folder root = 1;\n    optional int32 unfiltered_length = 2;\n    optional int32 unranged_length = 3;\n    optional bool is_loading_contents = 4;\n}\n"
  },
  {
    "path": "protocol/proto/seek_to_position.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.player.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage SeekToPosition {\n    optional uint64 value = 1;\n    optional uint32 revision = 2;\n}\n"
  },
  {
    "path": "protocol/proto/sequence_number_entity.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.event_sender.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage SequenceNumberEntity {\n    uint32 file_format_version = 1;\n    string event_name = 2;\n    bytes sequence_id = 3;\n    uint64 sequence_number_next = 4;\n}\n"
  },
  {
    "path": "protocol/proto/session.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.player.proto.transfer;\n\nimport \"context.proto\";\nimport \"context_player_options.proto\";\nimport \"play_origin.proto\";\nimport \"suppressions.proto\";\nimport \"instrumentation_params.proto\";\n\noption optimize_for = CODE_SIZE;\n\nmessage Session {\n    optional PlayOrigin play_origin = 1;\n    optional Context context = 2;\n    optional string current_uid = 3;\n    optional ContextPlayerOptionOverrides option_overrides = 4;\n    optional Suppressions suppressions = 5;\n    optional InstrumentationParams instrumentation_params = 6;\n    optional string shuffle_seed = 7;\n    optional Context main_context = 8;\n    optional string original_session_id = 9;\n}\n"
  },
  {
    "path": "protocol/proto/set_member_permission_request.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.playlist.cosmos.proto;\n\nimport \"playlist_permission.proto\";\n\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.playlist.proto\";\n\nmessage SetMemberPermissionRequest {\n    optional string playlist_uri = 1;\n    optional string username = 2;\n    optional playlist_permission.proto.PermissionLevel permission_level = 3;\n    optional uint32 timeout_ms = 4;\n}\n"
  },
  {
    "path": "protocol/proto/show_access.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.podcast_paywalls;\n\nimport \"spotify/audiobookcashier/v1/audiobook_price.proto\";\n\noption objc_class_prefix = \"SPT\";\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_outer_classname = \"ShowAccessProto\";\noption java_package = \"com.spotify.podcast.access.proto\";\n\nmessage ShowAccess {\n    reserved 7;\n    AccountLinkPrompt prompt = 5;\n    bool is_user_member_of_at_least_one_group = 8;\n    repeated UnlockingMethod unlocked_by = 10;\n    repeated UnlockingMethod unlocking_methods = 14;\n    Signifier signifier = 15;\n    Disclaimer disclaimer = 16;\n    oneof explanation {\n        NoExplanation none = 1;\n        LegacyExplanation legacy = 2;\n        BasicExplanation basic = 3;\n        UpsellLinkExplanation upsellLink = 4;\n        EngagementExplanation engagement = 6;\n        MultiPassExplanation multiPass = 9;\n        CheckoutOnWebOverlayExplanation checkoutOnWebOverlay = 11;\n        FreeCheckoutExplanation freeCheckout = 12;\n        ConsumptionCappedExplanation consumptionCapped = 13;\n    }\n}\n\nmessage Signifier {\n    string text = 1;\n}\n\nmessage BasicExplanation {\n    string title = 1;\n    string body = 2;\n    string cta = 3;\n}\n\nmessage LegacyExplanation {\n}\n\nmessage NoExplanation {\n}\n\nmessage UpsellLinkExplanation {\n    string title = 1;\n    string body = 2;\n    string cta = 3;\n    string url = 4;\n}\n\nmessage EngagementExplanation {\n    string header = 1;\n    string title = 2;\n    string body = 3;\n    string cta = 4;\n    string dismiss = 5;\n    string action_type = 6;\n    string body_secondary = 7;\n}\n\nmessage CheckoutOnWebOverlayExplanation {\n    string cta = 1;\n    string snackbar_success = 2;\n    string snackbar_error = 3;\n    string snackbar_fulfilment_complete = 4;\n    audiobookcashier.v1.AudiobookPrice price = 5;\n    bool is_price_displayed = 6;\n}\n\nmessage FreeCheckoutExplanation {\n    string snackbar_awaiting_fulfilment = 1;\n}\n\nmessage ConsumptionCappedExplanation {\n    string title = 1;\n    string body = 2;\n    string cta = 3;\n}\n\nmessage MultiPassExplanation {\n    string title = 1;\n    string soa_description = 2;\n    repeated .spotify.podcast_paywalls.SOAPartner soa_partner = 3;\n}\n\nmessage SOAPartner {\n    string display_name = 1;\n    string link_url = 2;\n    string logo_url = 3;\n}\n\nmessage AccountLinkPrompt {\n    string title = 1;\n    string body = 2;\n    string cta = 3;\n    string url = 4;\n}\n\nmessage Disclaimer {\n    string title = 1;\n    string body = 2;\n}\n\nenum UnlockingMethod {\n    UNKNOWN = 0;\n    ANCHOR_PAYWALL = 1;\n    OAP_OTP = 2;\n    OAP_LINKING = 3;\n    AUDIOBOOK_DIRECT_SALES = 4;\n    ABP = 5;\n    AUDIOBOOK_PROMOTION = 6;\n}\n\n"
  },
  {
    "path": "protocol/proto/show_episode_state.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.show_cosmos.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage EpisodeCollectionState {\n    optional bool is_following_show = 1;\n    optional bool is_new = 2;\n    optional bool is_in_listen_later = 3;\n}\n\nmessage EpisodeOfflineState {\n    optional string offline_state = 1;\n    optional uint32 sync_progress = 2;\n}\n"
  },
  {
    "path": "protocol/proto/show_offline_state.proto",
    "content": "syntax = \"proto2\";\n\npackage spotify.show_cosmos.proto;\n\nmessage ShowOfflineState {\n  optional string offline_state = 1;\n  optional uint32 sync_progress = 2;\n}\n\n"
  },
  {
    "path": "protocol/proto/show_request.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.show_cosmos.proto;\n\nimport \"metadata/episode_metadata.proto\";\nimport \"metadata/show_metadata.proto\";\nimport \"played_state/episode_played_state.proto\";\nimport \"played_state/show_played_state.proto\";\nimport \"show_episode_state.proto\";\nimport \"show_show_state.proto\";\nimport \"show_offline_state.proto\";\n\noption objc_class_prefix = \"SPTShowCosmos\";\noption optimize_for = CODE_SIZE;\n\nmessage Item {\n    reserved 6;\n    reserved 7;\n    reserved 8;\n    reserved 9;\n    optional string header_field = 1;\n    optional cosmos_util.proto.EpisodeMetadata episode_metadata = 2;\n    optional EpisodeCollectionState episode_collection_state = 3;\n    optional EpisodeOfflineState episode_offline_state = 4;\n    optional cosmos_util.proto.EpisodePlayState episode_play_state = 5;\n}\n\nmessage Header {\n    optional cosmos_util.proto.ShowMetadata show_metadata = 1;\n    optional ShowCollectionState show_collection_state = 2;\n    optional cosmos_util.proto.ShowPlayState show_play_state = 3;\n    optional ShowOfflineState show_offline_state = 4;\n}\n\nmessage Response {\n    reserved \"online_data\";\n    reserved 3;\n    reserved 9;\n    repeated Item item = 1;\n    optional Header header = 2;\n    optional uint32 unfiltered_length = 4;\n    optional uint32 length = 5;\n    optional bool loading_contents = 6;\n    optional uint32 unranged_length = 7;\n    optional AuxiliarySections auxiliary_sections = 8;\n    optional uint32 range_offset = 10;\n}\n\nmessage AuxiliarySections {\n    reserved 2;\n    reserved 4;\n    reserved 5;\n    reserved 6;\n    reserved 7;\n    reserved 8;\n    optional ContinueListeningSection continue_listening = 1;\n    optional TrailerSection trailer_section = 3;\n    optional LatestUnplayedEpisodeSection latest_unplayed_episode_section = 9;\n    optional NextBestEpisodeSection next_best_episode_section = 10;\n    optional SavedEpisodesSection saved_episodes_section = 11;\n}\n\nmessage ContinueListeningSection {\n    optional Item item = 1;\n}\n\nmessage TrailerSection {\n    optional Item item = 1;\n}\n\nmessage LatestUnplayedEpisodeSection {\n    optional Item item = 1;\n}\n\nmessage NextBestEpisodeSection {\n    enum Label {\n        UNKNOWN = 0;\n        TRAILER = 1;\n        CONTINUE_LISTENING = 2;\n        LATEST_PUBLISHED = 3;\n        UP_NEXT = 4;\n        FIRST_PUBLISHED = 5;\n    }\n\n    optional Label label = 1;\n    optional Item item = 2;\n}\n\nmessage SavedEpisodesSection {\n    optional uint32 saved_episodes_count = 1;\n    optional uint32 downloaded_episodes_count = 2;\n}\n\n"
  },
  {
    "path": "protocol/proto/show_show_state.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.show_cosmos.proto;\n\noption objc_class_prefix = \"SPTShowCosmos\";\noption optimize_for = CODE_SIZE;\n\nmessage ShowCollectionState {\n    optional bool is_in_collection = 1;\n}\n\n"
  },
  {
    "path": "protocol/proto/signal-model.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.playlist.signal.proto;\n\noption java_package = \"com.spotify.playlist_signal.model.proto\";\noption java_outer_classname = \"SignalModelProto\";\noption optimize_for = CODE_SIZE;\n\nmessage Signal {\n  string identifier = 1;\n  bytes data = 2;\n  bytes client_payload = 3;\n}\n\n"
  },
  {
    "path": "protocol/proto/skip_to_track.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.player.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage SkipToTrack {\n    optional string page_url = 1;\n    optional uint64 page_index = 2;\n    optional string track_uid = 3;\n    optional string track_uri = 4;\n    optional uint64 track_index = 5;\n}\n"
  },
  {
    "path": "protocol/proto/social_connect_v2.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage socialconnect;\n\noption optimize_for = CODE_SIZE;\n\nmessage Session {\n    reserved 8;\n    int64 timestamp = 1;\n    string session_id = 2;\n    string join_session_token = 3;\n    string join_session_url = 4;\n    string session_owner_id = 5;\n    repeated SessionMember session_members = 6;\n    string join_session_uri = 7;\n    bool is_session_owner = 9;\n    bool is_listening = 10;\n    bool is_controlling = 11;\n    bool is_discoverable = 12;\n    SessionType initial_session_type = 13;\n    optional string host_active_device_id = 14;\n}\n\nmessage SessionMember {\n    int64 timestamp = 1;\n    string id = 2;\n    string username = 3;\n    string display_name = 4;\n    string image_url = 5;\n    string large_image_url = 6;\n    bool is_listening = 7;\n    bool is_controlling = 8;\n}\n\nmessage SessionUpdate {\n    Session session = 1;\n    SessionUpdateReason reason = 2;\n    repeated SessionMember updated_session_members = 3;\n}\n\nmessage DevicesExposure {\n    int64 timestamp = 1;\n    map<string, DeviceExposureStatus> devices_exposure = 2;\n}\n\nenum SessionType {\n    UNKNOWN_SESSION_TYPE = 0;\n    IN_PERSON = 3;\n    REMOTE = 4;\n    REMOTE_V2 = 5;\n}\n\nenum SessionUpdateReason {\n    UNKNOWN_UPDATE_TYPE = 0;\n    NEW_SESSION = 1;\n    USER_JOINED = 2;\n    USER_LEFT = 3;\n    SESSION_DELETED = 4;\n    YOU_LEFT = 5;\n    YOU_WERE_KICKED = 6;\n    YOU_JOINED = 7;\n    PARTICIPANT_PROMOTED_TO_HOST = 8;\n    DISCOVERABILITY_CHANGED = 9;\n    USER_KICKED = 10;\n}\n\nenum DeviceExposureStatus {\n    NOT_EXPOSABLE = 0;\n    NOT_EXPOSED = 1;\n    EXPOSED = 2;\n}\n"
  },
  {
    "path": "protocol/proto/social_service.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.social_esperanto.proto;\n\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.social.esperanto.proto\";\n\nservice SocialService {\n    rpc SetAccessToken(SetAccessTokenRequest) returns (SetAccessTokenResponse);\n    rpc SubscribeToEvents(SubscribeToEventsRequest) returns (stream SubscribeToEventsResponse);\n    rpc SubscribeToState(SubscribeToStateRequest) returns (stream SubscribeToStateResponse);\n}\n\nmessage SetAccessTokenRequest {\n    string accessToken = 1;\n}\n\nmessage SetAccessTokenResponse {\n}\n\nmessage SubscribeToEventsRequest {\n}\n\nmessage SubscribeToEventsResponse {\n    enum Error {\n        NONE = 0;\n        FAILED_TO_CONNECT = 1;\n        USER_DATA_FAIL = 2;\n        PERMISSIONS = 3;\n        SERVICE_CONNECT_NOT_PERMITTED = 4;\n        USER_UNAUTHORIZED = 5;\n    }\n\n    Error status = 1;\n    string description = 2;\n}\n\nmessage SubscribeToStateRequest {\n}\n\nmessage SubscribeToStateResponse {\n    bool available = 1;\n    bool enabled = 2;\n    repeated string missingPermissions = 3;\n    string accessToken = 4;\n}\n\n"
  },
  {
    "path": "protocol/proto/socialgraph_response_status.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.socialgraph_esperanto.proto;\n\noption objc_class_prefix = \"ESP\";\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"spotify.socialgraph.esperanto.proto\";\n\nmessage ResponseStatus {\n    int32 status_code = 1;\n    string reason = 2;\n}\n"
  },
  {
    "path": "protocol/proto/socialgraphv2.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.socialgraph.proto;\n\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"com.socialgraph.proto\";\n\nmessage SocialGraphEntity {\n    optional string user_uri = 1;\n    optional string artist_uri = 2;\n    optional int32 followers_count = 3;\n    optional int32 following_count = 4;\n    optional int32 status = 5;\n    optional bool is_following = 6;\n    optional bool is_followed = 7;\n    optional bool is_dismissed = 8;\n    optional bool is_blocked = 9;\n    optional int64 following_at = 10;\n    optional int64 followed_at = 11;\n    optional int64 dismissed_at = 12;\n    optional int64 blocked_at = 13;\n}\n\nmessage SocialGraphRequest {\n    repeated string target_uris = 1;\n    optional string source_uri = 2;\n}\n\nmessage SocialGraphReply {\n    repeated SocialGraphEntity entities = 1;\n    optional int32 num_total_entities = 2;\n}\n\nmessage ChangeNotification {\n    optional EventType event_type = 1;\n    repeated SocialGraphEntity entities = 2;\n}\n\nenum EventType {\n    FOLLOW = 1;\n    UNFOLLOW = 2;\n}\n"
  },
  {
    "path": "protocol/proto/spirc.proto",
    "content": "syntax = \"proto2\";\n\nmessage Frame {\n    optional uint32 version = 0x1;\n    optional string ident = 0x2;\n    optional string protocol_version = 0x3;\n    optional uint32 seq_nr = 0x4;\n    optional MessageType typ = 0x5;\n    optional DeviceState device_state = 0x7;\n    optional Goodbye goodbye = 0xb;\n    optional State state = 0xc;\n    optional uint32 position = 0xd;\n    optional uint32 volume = 0xe;\n    optional int64 state_update_id = 0x11;\n    repeated string recipient = 0x12;\n    optional bytes context_player_state = 0x13;\n    optional string new_name = 0x14;\n    optional Metadata metadata = 0x19;\n}\n\nenum MessageType {\n    kMessageTypeHello = 0x1;\n    kMessageTypeGoodbye = 0x2;\n    kMessageTypeProbe = 0x3;\n    kMessageTypeNotify = 0xa;\n    kMessageTypeLoad = 0x14;\n    kMessageTypePlay = 0x15;\n    kMessageTypePause = 0x16;\n    kMessageTypePlayPause = 0x17;\n    kMessageTypeSeek = 0x18;\n    kMessageTypePrev = 0x19;\n    kMessageTypeNext = 0x1a;\n    kMessageTypeVolume = 0x1b;\n    kMessageTypeShuffle = 0x1c;\n    kMessageTypeRepeat = 0x1d;\n    kMessageTypeVolumeDown = 0x1f;\n    kMessageTypeVolumeUp = 0x20;\n    kMessageTypeReplace = 0x21;\n    kMessageTypeLogout = 0x22;\n    kMessageTypeAction = 0x23;\n    kMessageTypeRename = 0x24;\n    kMessageTypeUpdateMetadata = 0x80;\n}\n\nmessage DeviceState {\n    optional string sw_version = 0x1;\n    optional bool is_active = 0xa;\n    optional bool can_play = 0xb;\n    optional uint32 volume = 0xc;\n    optional string name = 0xd;\n    optional uint32 error_code = 0xe;\n    optional int64 became_active_at = 0xf;\n    optional string error_message = 0x10;\n    repeated Capability capabilities = 0x11;\n    optional string context_player_error = 0x14;\n    repeated Metadata metadata = 0x19;\n}\n\nmessage Capability {\n    optional CapabilityType typ = 0x1;\n    repeated int64 intValue = 0x2;\n    repeated string stringValue = 0x3;\n}\n\nenum CapabilityType {\n    kSupportedContexts = 0x1;\n    kCanBePlayer = 0x2;\n    kRestrictToLocal = 0x3;\n    kDeviceType = 0x4;\n    kGaiaEqConnectId = 0x5;\n    kSupportsLogout = 0x6;\n    kIsObservable = 0x7;\n    kVolumeSteps = 0x8;\n    kSupportedTypes = 0x9;\n    kCommandAcks = 0xa;\n    kSupportsRename = 0xb;\n    kHidden = 0xc;\n    kSupportsPlaylistV2 = 0xd;\n    kSupportsExternalEpisodes = 0xe;\n}\n\nmessage Goodbye {\n    optional string reason = 0x1;\n}\n\nmessage State {\n    optional string context_uri = 0x2;\n    optional uint32 index = 0x3;\n    optional uint32 position_ms = 0x4;\n    optional PlayStatus status = 0x5;\n    optional uint64 position_measured_at = 0x7;\n    optional string context_description = 0x8;\n    optional bool shuffle = 0xd;\n    optional bool repeat = 0xe;\n    optional string last_command_ident = 0x14;\n    optional uint32 last_command_msgid = 0x15;\n    optional bool playing_from_fallback = 0x18;\n    optional uint32 row = 0x19;\n    optional uint32 playing_track_index = 0x1a;\n    repeated TrackRef track = 0x1b;\n    optional Ad ad = 0x1c;\n}\n\nenum PlayStatus {\n    kPlayStatusStop = 0x0;\n    kPlayStatusPlay = 0x1;\n    kPlayStatusPause = 0x2;\n    kPlayStatusLoading = 0x3;\n}\n\nmessage TrackRef {\n    optional bytes gid = 0x1;\n    optional string uri = 0x2;\n    optional bool queued = 0x3;\n    optional string context = 0x4;\n}\n\nmessage Ad {\n    optional int32 next = 0x1;\n    optional bytes ogg_fid = 0x2;\n    optional bytes image_fid = 0x3;\n    optional int32 duration = 0x4;\n    optional string click_url = 0x5;\n    optional string impression_url = 0x6;\n    optional string product = 0x7;\n    optional string advertiser = 0x8;\n    optional bytes gid = 0x9;\n}\n\nmessage Metadata {\n    optional string type = 0x1;\n    optional string metadata = 0x2;\n}\n"
  },
  {
    "path": "protocol/proto/spotify/audiobookcashier/v1/audiobook_price.proto",
    "content": "syntax = \"proto3\";\n\npackage spotify.audiobookcashier.v1;\n\noption java_package = \"com.spotify.audiobookcashier.v1\";\noption java_multiple_files = true;\n\nmessage Price {\n  double amount = 1;\n  string currency = 2;\n  string formatted_price = 3;\n}\n\nmessage AudiobookPrice {\n  .spotify.audiobookcashier.v1.Price final_price = 1;\n  .spotify.audiobookcashier.v1.Price final_list_price = 2;\n}\n\n"
  },
  {
    "path": "protocol/proto/spotify/clienttoken/v0/clienttoken_http.proto",
    "content": "syntax = \"proto3\";\n\npackage spotify.clienttoken.http.v0;\n\nimport \"connectivity.proto\";\n\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"spotify.clienttoken.http.v0\";\n\nmessage ClientTokenRequest {\n    ClientTokenRequestType request_type = 1;\n    \n    oneof request {\n        ClientDataRequest client_data = 2;\n        ChallengeAnswersRequest challenge_answers = 3;\n    }\n}\n\nmessage ClientDataRequest {\n    string client_version = 1;\n    string client_id = 2;\n    \n    oneof data {\n        spotify.clienttoken.data.v0.ConnectivitySdkData connectivity_sdk_data = 3;\n    }\n}\n\nmessage ChallengeAnswersRequest {\n    string state = 1;\n    repeated ChallengeAnswer answers = 2;\n}\n\nmessage ClientTokenResponse {\n    ClientTokenResponseType response_type = 1;\n    \n    oneof response {\n        GrantedTokenResponse granted_token = 2;\n        ChallengesResponse challenges = 3;\n    }\n}\n\nmessage TokenDomain {\n     string domain = 1;\n}\n \nmessage GrantedTokenResponse {\n    string token = 1;\n    int32 expires_after_seconds = 2;\n    int32 refresh_after_seconds = 3;\n    repeated TokenDomain domains = 4;\n}\n\nmessage ChallengesResponse {\n    string state = 1;\n    repeated Challenge challenges = 2;\n}\n\nmessage ClientSecretParameters {\n    string salt = 1;\n}\n\nmessage EvaluateJSParameters {\n    string code = 1;\n    repeated string libraries = 2;\n}\n\nmessage HashCashParameters {\n    int32 length = 1;\n    string prefix = 2;\n}\n\nmessage Challenge {\n    ChallengeType type = 1;\n    \n    oneof parameters {\n        ClientSecretParameters client_secret_parameters = 2;\n        EvaluateJSParameters evaluate_js_parameters = 3;\n        HashCashParameters evaluate_hashcash_parameters = 4;\n    }\n}\n\nmessage ClientSecretHMACAnswer {\n    string hmac = 1;\n}\n\nmessage EvaluateJSAnswer {\n    string result = 1;\n}\n\nmessage HashCashAnswer {\n    string suffix = 1;\n}\n\nmessage ChallengeAnswer {\n    ChallengeType ChallengeType = 1;\n    \n    oneof answer {\n        ClientSecretHMACAnswer client_secret = 2;\n        EvaluateJSAnswer evaluate_js = 3;\n        HashCashAnswer hash_cash = 4;\n    }\n}\n\nmessage ClientTokenBadRequest {\n    string message = 1;\n}\n\nenum ClientTokenRequestType {\n    REQUEST_UNKNOWN = 0;\n    REQUEST_CLIENT_DATA_REQUEST = 1;\n    REQUEST_CHALLENGE_ANSWERS_REQUEST = 2;\n}\n\nenum ClientTokenResponseType {\n    RESPONSE_UNKNOWN = 0;\n    RESPONSE_GRANTED_TOKEN_RESPONSE = 1;\n    RESPONSE_CHALLENGES_RESPONSE = 2;\n}\n\nenum ChallengeType {\n    CHALLENGE_UNKNOWN = 0;\n    CHALLENGE_CLIENT_SECRET_HMAC = 1;\n    CHALLENGE_EVALUATE_JS = 2;\n    CHALLENGE_HASH_CASH = 3;\n}\n"
  },
  {
    "path": "protocol/proto/spotify/login5/v3/challenges/code.proto",
    "content": "// Extracted from: Spotify 1.1.33.569 (Windows)\n\nsyntax = \"proto3\";\n\npackage spotify.login5.v3.challenges;\n\noption objc_class_prefix = \"SPTLogin5\";\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.login5.v3.challenges.proto\";\n\nmessage CodeChallenge {\n    Method method = 1;\n    enum Method {\n        UNKNOWN = 0;\n        SMS = 1;\n    }\n    \n    int32 code_length = 2;\n    int32 expires_in = 3;\n    string canonical_phone_number = 4;\n}\n\nmessage CodeSolution {\n    string code = 1;\n}\n"
  },
  {
    "path": "protocol/proto/spotify/login5/v3/challenges/hashcash.proto",
    "content": "// Extracted from: Spotify 1.1.33.569 (Windows)\n\nsyntax = \"proto3\";\n\npackage spotify.login5.v3.challenges;\n\nimport \"google/protobuf/duration.proto\";\n\noption objc_class_prefix = \"SPTLogin5\";\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.login5.v3.challenges.proto\";\n\nmessage HashcashChallenge {\n    bytes prefix = 1;\n    int32 length = 2;\n}\n\nmessage HashcashSolution {\n    bytes suffix = 1;\n    google.protobuf.Duration duration = 2;\n}\n"
  },
  {
    "path": "protocol/proto/spotify/login5/v3/client_info.proto",
    "content": "// Extracted from: Spotify 1.1.33.569 (Windows)\n\nsyntax = \"proto3\";\n\npackage spotify.login5.v3;\n\noption objc_class_prefix = \"SPTLogin5\";\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.login5.v3.proto\";\n\nmessage ClientInfo {\n    string client_id = 1;\n    string device_id = 2;\n}\n"
  },
  {
    "path": "protocol/proto/spotify/login5/v3/credentials/credentials.proto",
    "content": "// Extracted from: Spotify 1.1.33.569 (Windows)\n\nsyntax = \"proto3\";\n\npackage spotify.login5.v3.credentials;\n\noption objc_class_prefix = \"SPTLogin5\";\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.login5.v3.credentials.proto\";\n\nmessage StoredCredential {\n    string username = 1;\n    bytes data = 2;\n}\n\nmessage Password {\n    string id = 1;\n    string password = 2;\n    bytes padding = 3;\n}\n\nmessage FacebookAccessToken {\n    string fb_uid = 1;\n    string access_token = 2;\n}\n\nmessage OneTimeToken {\n    string token = 1;\n}\n\nmessage ParentChildCredential {\n    string child_id = 1;\n    StoredCredential parent_stored_credential = 2;\n}\n\nmessage AppleSignInCredential {\n    string auth_code = 1;\n    string redirect_uri = 2;\n    string bundle_id = 3;\n}\n\nmessage SamsungSignInCredential {\n    string auth_code = 1;\n    string redirect_uri = 2;\n    string id_token = 3;\n    string token_endpoint_url = 4;\n}\n\nmessage GoogleSignInCredential {\n    string auth_code = 1;\n    string redirect_uri = 2;\n}\n"
  },
  {
    "path": "protocol/proto/spotify/login5/v3/identifiers/identifiers.proto",
    "content": "// Extracted from: Spotify 1.1.33.569 (Windows)\n\nsyntax = \"proto3\";\n\npackage spotify.login5.v3.identifiers;\n\noption objc_class_prefix = \"SPTLogin5\";\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.login5.v3.identifiers.proto\";\n\nmessage PhoneNumber {\n    string number = 1;\n    string iso_country_code = 2;\n    string country_calling_code = 3;\n}\n"
  },
  {
    "path": "protocol/proto/spotify/login5/v3/login5.proto",
    "content": "// Extracted from: Spotify 1.1.33.569 (Windows)\n\nsyntax = \"proto3\";\n\npackage spotify.login5.v3;\n\nimport \"spotify/login5/v3/client_info.proto\";\nimport \"spotify/login5/v3/user_info.proto\";\nimport \"spotify/login5/v3/challenges/code.proto\";\nimport \"spotify/login5/v3/challenges/hashcash.proto\";\nimport \"spotify/login5/v3/credentials/credentials.proto\";\nimport \"spotify/login5/v3/identifiers/identifiers.proto\";\n\noption objc_class_prefix = \"SPTLogin5\";\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.login5.v3.proto\";\n\nmessage Challenges {\n    repeated Challenge challenges = 1;\n}\n\nmessage Challenge {\n    oneof challenge {\n        challenges.HashcashChallenge hashcash = 1;\n        challenges.CodeChallenge code = 2;\n    }\n}\n\nmessage ChallengeSolutions {\n    repeated ChallengeSolution solutions = 1;\n}\n\nmessage ChallengeSolution {\n    oneof solution {\n        challenges.HashcashSolution hashcash = 1;\n        challenges.CodeSolution code = 2;\n    }\n}\n\nmessage LoginRequest {\n    ClientInfo client_info = 1;\n    bytes login_context = 2;\n    ChallengeSolutions challenge_solutions = 3;\n    \n    oneof login_method {\n        credentials.StoredCredential stored_credential = 100;\n        credentials.Password password = 101;\n        credentials.FacebookAccessToken facebook_access_token = 102;\n        identifiers.PhoneNumber phone_number = 103;\n        credentials.OneTimeToken one_time_token = 104;\n        credentials.ParentChildCredential parent_child_credential = 105;\n        credentials.AppleSignInCredential apple_sign_in_credential = 106;\n        credentials.SamsungSignInCredential samsung_sign_in_credential = 107;\n        credentials.GoogleSignInCredential google_sign_in_credential = 108;\n    }\n}\n\nmessage LoginOk {\n    string username = 1;\n    string access_token = 2;\n    bytes stored_credential = 3;\n    int32 access_token_expires_in = 4;\n}\n\nmessage LoginResponse {\n    repeated Warnings warnings = 4;\n    enum Warnings {\n        UNKNOWN_WARNING = 0;\n        DEPRECATED_PROTOCOL_VERSION = 1;\n    }\n    \n    bytes login_context = 5;\n    string identifier_token = 6;\n    UserInfo user_info = 7;\n    \n    oneof response {\n        LoginOk ok = 1;\n        LoginError error = 2;\n        Challenges challenges = 3;\n    }\n}\n\nenum LoginError {\n    UNKNOWN_ERROR = 0;\n    INVALID_CREDENTIALS = 1;\n    BAD_REQUEST = 2;\n    UNSUPPORTED_LOGIN_PROTOCOL = 3;\n    TIMEOUT = 4;\n    UNKNOWN_IDENTIFIER = 5;\n    TOO_MANY_ATTEMPTS = 6;\n    INVALID_PHONENUMBER = 7;\n    TRY_AGAIN_LATER = 8;\n}\n"
  },
  {
    "path": "protocol/proto/spotify/login5/v3/user_info.proto",
    "content": "// Extracted from: Spotify 1.1.33.569 (Windows)\n\nsyntax = \"proto3\";\n\npackage spotify.login5.v3;\n\noption objc_class_prefix = \"SPTLogin5\";\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.login5.v3.proto\";\n\nmessage UserInfo {\n    string name = 1;\n    string email = 2;\n    bool email_verified = 3;\n    string birthdate = 4;\n    \n    Gender gender = 5;\n    enum Gender {\n        UNKNOWN = 0;\n        MALE = 1;\n        FEMALE = 2;\n        NEUTRAL = 3;\n    }\n    \n    string phone_number = 6;\n    bool phone_number_verified = 7;\n    bool email_already_registered = 8;\n}\n"
  },
  {
    "path": "protocol/proto/state_restore/ads_rules_inject_tracks.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.player.proto.state_restore;\n\nimport \"state_restore/provided_track.proto\";\n\noption optimize_for = CODE_SIZE;\n\nmessage AdsRulesInjectTracks {\n    repeated ProvidedTrack ads = 1;\n    optional bool is_playing_slot = 2;\n}\n"
  },
  {
    "path": "protocol/proto/state_restore/automix_rules.proto",
    "content": "syntax = \"proto2\";\n\npackage spotify.player.proto.state_restore;\n\nmessage AutomixRules {\n  required bool automix = 1;\n  required string current_track_uri = 2;\n}\n\n"
  },
  {
    "path": "protocol/proto/state_restore/automix_talk_rules.proto",
    "content": "syntax = \"proto2\";\n\npackage spotify.player.proto.state_restore;\n\nimport \"state_restore/provided_track.proto\";\n\nmessage AutomixTalkRules {\n  optional ProvidedTrack current_track = 1;\n  optional int64 narration_duration = 2;\n}\n\n"
  },
  {
    "path": "protocol/proto/state_restore/behavior_metadata_rules.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.player.proto.state_restore;\n\noption optimize_for = CODE_SIZE;\n\nmessage BehaviorMetadataRules {\n    repeated string page_instance_ids = 1;\n    repeated string interaction_ids = 2;\n}\n"
  },
  {
    "path": "protocol/proto/state_restore/circuit_breaker_rules.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.player.proto.state_restore;\n\noption optimize_for = CODE_SIZE;\n\nmessage CircuitBreakerRules {\n    repeated string discarded_track_uids = 1;\n    required int32 num_errored_tracks = 2;\n    required bool context_track_played = 3;\n}\n"
  },
  {
    "path": "protocol/proto/state_restore/context_loader.proto",
    "content": "syntax = \"proto2\";\n\npackage spotify.player.proto.state_restore;\n\nimport \"context.proto\";\n\nmessage ContextLoader {\n  required Context context = 1;\n}\n\n"
  },
  {
    "path": "protocol/proto/state_restore/context_player_restorable.proto",
    "content": "syntax = \"proto2\";\n\npackage spotify.player.proto.state_restore;\n\nimport \"state_restore/play_history.proto\";\nimport \"state_restore/player_model.proto\";\nimport \"state_restore/mft_state.proto\";\nimport \"state_restore/mft_context_history.proto\";\nimport \"state_restore/mft_fallback_page_history.proto\";\nimport \"state_restore/pns_capper.proto\";\n\nmessage ContextPlayerRestorable {\n  reserved 8;\n  required int32 version = 1;\n  optional string version_suffix = 2;\n  required PlayerModel player_model = 3;\n  required PlayHistory play_history = 4;\n  required MftState mft_can_play_checker = 5;\n  required MftContextHistory mft_context_history = 6;\n  required MftFallbackPageHistory mft_fallback_page_history = 7;\n  optional PnsCapper pns_capper = 9;\n}\n\n"
  },
  {
    "path": "protocol/proto/state_restore/context_player_rules.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.player.proto.state_restore;\n\nimport \"state_restore/ads_rules_inject_tracks.proto\";\nimport \"state_restore/automix_rules.proto\";\nimport \"state_restore/automix_talk_rules.proto\";\nimport \"state_restore/behavior_metadata_rules.proto\";\nimport \"state_restore/circuit_breaker_rules.proto\";\nimport \"state_restore/explicit_content_rules.proto\";\nimport \"state_restore/kitteh_box_rules.proto\";\nimport \"state_restore/mft_rules_core.proto\";\nimport \"state_restore/mod_rules_interruptions.proto\";\nimport \"state_restore/music_injection_rules.proto\";\nimport \"state_restore/remove_banned_tracks_rules.proto\";\nimport \"state_restore/resume_points_rules.proto\";\nimport \"state_restore/track_error_rules.proto\";\n\noption optimize_for = CODE_SIZE;\n\nmessage PlayEvents {\n    required uint64 max_consecutive = 1;\n    required uint64 max_occurrences_in_period = 2;\n    required int64 period = 3;\n}\n\nmessage SkipEvents {\n    required uint64 max_occurrences_in_period = 1;\n    required int64 period = 2;\n}\n\nmessage Context {\n    required uint64 min_tracks = 1;\n}\n\nmessage MftConfiguration {\n    optional PlayEvents track = 1;\n    optional PlayEvents album = 2;\n    optional PlayEvents artist = 3;\n    optional SkipEvents skip = 4;\n    optional Context context = 5;\n    optional PlayEvents social_track = 6;\n}\n\nmessage MftRules {\n    required bool locked = 1;\n    optional MftConfiguration config = 2;\n    map<string, ContextPlayerRules> old_forward_rules = 3;\n    optional ContextPlayerRules forward_rules = 4;\n}\n\nmessage ContextPlayerRules {\n    optional BehaviorMetadataRules behavior_metadata_rules = 1;\n    optional CircuitBreakerRules circuit_breaker_rules = 2;\n    optional ExplicitContentRules explicit_content_rules = 3;\n    optional MusicInjectionRules music_injection_rules = 5;\n    optional RemoveBannedTracksRules remove_banned_tracks_rules = 6;\n    optional ResumePointsRules resume_points_rules = 7;\n    optional TrackErrorRules track_error_rules = 8;\n    optional AdsRulesInjectTracks ads_rules_inject_tracks = 9;\n    optional MftRulesCore mft_rules_core = 10;\n    optional ModRulesInterruptions mod_rules_interruptions = 11;\n    optional KittehBoxRules kitteh_box_rules = 12;\n    optional AutomixRules automix_rules = 13;\n    optional AutomixTalkRules automix_talk_rules = 14;\n    optional MftRules mft_rules = 15;\n    map<string, ContextPlayerRules> sub_rules = 16;\n    optional bool is_adaptor_only = 17;\n}\n\n"
  },
  {
    "path": "protocol/proto/state_restore/context_player_rules_base.proto",
    "content": "// Extracted from: Spotify 1.1.73.517 (macOS)\n\nsyntax = \"proto2\";\n\npackage spotify.player.proto.state_restore;\n\nimport \"state_restore/ads_rules_inject_tracks.proto\";\nimport \"state_restore/behavior_metadata_rules.proto\";\nimport \"state_restore/circuit_breaker_rules.proto\";\nimport \"state_restore/explicit_content_rules.proto\";\nimport \"state_restore/explicit_request_rules.proto\";\nimport \"state_restore/mft_rules_core.proto\";\nimport \"state_restore/mod_rules_interruptions.proto\";\nimport \"state_restore/music_injection_rules.proto\";\nimport \"state_restore/remove_banned_tracks_rules.proto\";\nimport \"state_restore/resume_points_rules.proto\";\nimport \"state_restore/track_error_rules.proto\";\n\noption optimize_for = CODE_SIZE;\n\nmessage ContextPlayerRulesBase {\n    optional BehaviorMetadataRules behavior_metadata_rules = 1;\n    optional CircuitBreakerRules circuit_breaker_rules = 2;\n    optional ExplicitContentRules explicit_content_rules = 3;\n    optional ExplicitRequestRules explicit_request_rules = 4;\n    optional MusicInjectionRules music_injection_rules = 5;\n    optional RemoveBannedTracksRules remove_banned_tracks_rules = 6;\n    optional ResumePointsRules resume_points_rules = 7;\n    optional TrackErrorRules track_error_rules = 8;\n    optional AdsRulesInjectTracks ads_rules_inject_tracks = 9;\n    optional MftRulesCore mft_rules_core = 10;\n    optional ModRulesInterruptions mod_rules_interruptions = 11;\n}\n"
  },
  {
    "path": "protocol/proto/state_restore/context_player_state.proto",
    "content": "syntax = \"proto2\";\n\npackage spotify.player.proto.state_restore;\n\nimport \"context_index.proto\";\nimport \"restrictions.proto\";\nimport \"play_origin.proto\";\nimport \"state_restore/provided_track.proto\";\nimport \"context_player_options.proto\";\nimport \"prepare_play_options.proto\";\nimport \"state_restore/playback_quality.proto\";\n\nmessage ContextPlayerState {\n  message ContextMetadataEntry {\n    optional string key = 1;\n    optional string value = 2;\n  }\n\n  message PageMetadataEntry {\n    optional string key = 1;\n    optional string value = 2;\n  }\n\n  message ModesEntry {\n    optional string key = 1;\n    optional string value = 2;\n  }\n\n  optional uint64 timestamp = 1;\n  optional string context_uri = 2;\n  optional string context_url = 3;\n  optional Restrictions context_restrictions = 4;\n  optional PlayOrigin play_origin = 5;\n  optional ContextIndex index = 6;\n  optional ProvidedTrack track = 7;\n  optional bytes playback_id = 8;\n  optional PlaybackQuality playback_quality = 9;\n  optional double playback_speed = 10;\n  optional uint64 position_as_of_timestamp = 11;\n  optional uint64 duration = 12;\n  optional bool is_playing = 13;\n  optional bool is_paused = 14;\n  optional bool is_buffering = 15;\n  optional bool is_system_initiated = 16;\n  optional ContextPlayerOptions options = 17;\n  optional Restrictions restrictions = 18;\n  repeated string suppressions = 19;\n  repeated ProvidedTrack prev_tracks = 20;\n  repeated ProvidedTrack next_tracks = 21;\n  repeated ContextPlayerState.ContextMetadataEntry context_metadata = 22;\n  repeated ContextPlayerState.PageMetadataEntry page_metadata = 23;\n  optional string session_id = 24;\n  optional uint64 queue_revision = 25;\n  optional AudioStream audio_stream = 26;\n  repeated string signals = 27;\n  repeated ContextPlayerState.ModesEntry modes = 28;\n  optional string session_command_id = 29;\n}\n\n"
  },
  {
    "path": "protocol/proto/state_restore/explicit_content_rules.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.player.proto.state_restore;\n\noption optimize_for = CODE_SIZE;\n\nmessage ExplicitContentRules {\n    required bool filter_explicit_content = 1;\n    required bool filter_age_restricted_content = 2;\n}\n"
  },
  {
    "path": "protocol/proto/state_restore/explicit_request_rules.proto",
    "content": "// Extracted from: Spotify 1.1.73.517 (macOS)\n\nsyntax = \"proto2\";\n\npackage spotify.player.proto.state_restore;\n\noption optimize_for = CODE_SIZE;\n\nmessage ExplicitRequestRules {\n    required bool always_play_something = 1;\n}\n"
  },
  {
    "path": "protocol/proto/state_restore/kitteh_box_rules.proto",
    "content": "syntax = \"proto2\";\n\npackage spotify.player.proto.state_restore;\n\nmessage KittehBoxRules {\n  message NodeAspectsEntry {\n    optional string key = 1;\n    optional bytes value = 2;\n  }\n\n  enum Transition {\n    ADVANCE = 0;\n    SKIP_NEXT = 1;\n    SKIP_PREV = 2;\n  }\n\n  enum Position {\n    BETWEEN_TRACKS = 0;\n    ON_DELIMITER = 1;\n    ON_NODE_TRACK = 2;\n  }\n\n  repeated KittehBoxRules.NodeAspectsEntry node_aspects = 1;\n  required KittehBoxRules.Position pos = 2;\n  required KittehBoxRules.Transition last_transition = 3;\n  required int32 context_iteration = 4;\n  required bool pending_skip_to = 5;\n  optional int64 page_index = 6;\n}\n\n"
  },
  {
    "path": "protocol/proto/state_restore/mft_context_history.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.player.proto.state_restore;\n\nimport \"context_track.proto\";\n\noption optimize_for = CODE_SIZE;\n\nmessage MftContextHistoryEntry {\n    required ContextTrack track = 1;\n    required int64 timestamp = 2;\n    optional int64 position = 3;\n}\n\nmessage MftContextHistory {\n    map<string, MftContextHistoryEntry> lookup = 1;\n}\n\n"
  },
  {
    "path": "protocol/proto/state_restore/mft_context_switch_rules.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.player.proto.state_restore;\n\noption optimize_for = CODE_SIZE;\n\nmessage MftContextSwitchRules {\n    required bool has_played_track = 1;\n    required bool enabled = 2;\n}\n"
  },
  {
    "path": "protocol/proto/state_restore/mft_fallback_page_history.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.player.proto.state_restore;\n\noption optimize_for = CODE_SIZE;\n\nmessage ContextAndPage {\n    required string context_uri = 1;\n    required string fallback_page_url = 2;\n}\n\nmessage MftFallbackPageHistory {\n    repeated ContextAndPage context_to_fallback_page = 1;\n}\n"
  },
  {
    "path": "protocol/proto/state_restore/mft_rules.proto",
    "content": "// Extracted from: Spotify 1.1.73.517 (macOS)\n\nsyntax = \"proto2\";\n\npackage spotify.player.proto.state_restore;\n\nimport \"state_restore/context_player_rules_base.proto\";\n\noption optimize_for = CODE_SIZE;\n\nmessage PlayEvents {\n    required int32 max_consecutive = 1;\n    required int32 max_occurrences_in_period = 2;\n    required int64 period = 3;\n}\n\nmessage SkipEvents {\n    required int32 max_occurrences_in_period = 1;\n    required int64 period = 2;\n}\n\nmessage Context {\n    required int32 min_tracks = 1;\n}\n\nmessage MftConfiguration {\n    optional PlayEvents track = 1;\n    optional PlayEvents album = 2;\n    optional PlayEvents artist = 3;\n    optional SkipEvents skip = 4;\n    optional Context context = 5;\n}\n\nmessage MftRules {\n    required bool locked = 1;\n    optional MftConfiguration config = 2;\n    map<string, ContextPlayerRulesBase> forward_rules = 3;\n}\n"
  },
  {
    "path": "protocol/proto/state_restore/mft_rules_core.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.player.proto.state_restore;\n\nimport \"state_restore/mft_context_switch_rules.proto\";\nimport \"state_restore/mft_rules_inject_filler_tracks.proto\";\n\noption optimize_for = CODE_SIZE;\n\nmessage MftRulesCore {\n    required MftRulesInjectFillerTracks inject_filler_tracks = 1;\n    required MftContextSwitchRules context_switch_rules = 2;\n    repeated string feature_classes = 3;\n}\n"
  },
  {
    "path": "protocol/proto/state_restore/mft_rules_inject_filler_tracks.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.player.proto.state_restore;\n\nimport \"context_track.proto\";\nimport \"state_restore/random_source.proto\";\n\noption optimize_for = CODE_SIZE;\n\nmessage MftRandomTrackInjection {\n    required RandomSource random_source = 1;\n    required int32 offset = 2;\n}\n\nmessage MftRulesInjectFillerTracks {\n    repeated ContextTrack fallback_tracks = 1;\n    required MftRandomTrackInjection padding_track_injection = 2;\n    required RandomSource random_source = 3;\n    required bool filter_explicit_content = 4;\n    repeated string feature_classes = 5;\n}\n"
  },
  {
    "path": "protocol/proto/state_restore/mft_state.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.player.proto.state_restore;\n\noption optimize_for = CODE_SIZE;\n\nmessage EventList {\n    repeated uint64 event_times = 1;\n}\n\nmessage LastEvent {\n    required string uri = 1;\n    required uint64 when = 2;\n}\n\nmessage History {\n    map<string, EventList> when = 1;\n    required LastEvent last = 2;\n}\n\nmessage MftState {\n    required History track = 1;\n    required History social_track = 2;\n    required History album = 3;\n    required History artist = 4;\n    optional EventList skip = 5;\n    required uint64 time = 6;\n    required bool did_skip = 7;\n}\n"
  },
  {
    "path": "protocol/proto/state_restore/mod_interruption_state.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.player.proto.state_restore;\n\nimport \"context_track.proto\";\nimport \"state_restore/provided_track.proto\";\n\noption optimize_for = CODE_SIZE;\n\nmessage StoredInterruption {\n    required ContextTrack context_track = 1;\n    required int64 fetched_at = 2;\n}\n\nmessage ModInterruptionState {\n    optional string context_uri = 1;\n    optional ProvidedTrack last_track = 2;\n    map<string, int32> active_play_count = 3;\n    repeated StoredInterruption active_play_interruptions = 4;\n    repeated StoredInterruption repeat_play_interruptions = 5;\n}\n"
  },
  {
    "path": "protocol/proto/state_restore/mod_rules_interruptions.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.player.proto.state_restore;\n\nimport \"player_license.proto\";\nimport \"state_restore/provided_track.proto\";\n\noption optimize_for = CODE_SIZE;\n\nmessage ModRulesInterruptions {\n    enum InterruptionSource {\n        CONTEXT = 1;\n        SAS = 2;\n        NO_INTERRUPTIONS = 3;\n    }\n\n    optional ProvidedTrack seek_repeat_track = 1;\n    required uint32 prng_seed = 2;\n    required bool support_video = 3;\n    required bool is_active_action = 4;\n    required bool is_in_seek_repeat = 5;\n    required bool has_tp_api_restrictions = 6;\n    required InterruptionSource interruption_source = 7;\n    required PlayerLicense license = 8;\n}\n\n"
  },
  {
    "path": "protocol/proto/state_restore/music_injection_rules.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.player.proto.state_restore;\n\noption optimize_for = CODE_SIZE;\n\nmessage InjectionSegment {\n    required string track_uri = 1;\n    optional int64 start = 2;\n    optional int64 stop = 3;\n    required int64 duration = 4;\n}\n\nmessage InjectionModel {\n    optional string episode_uri = 1;\n    optional int64 total_duration = 2;\n    repeated InjectionSegment segments = 3;\n}\n\nmessage MusicInjectionRules {\n    optional InjectionModel injection_model = 1;\n    optional bytes playback_id = 2;\n}\n"
  },
  {
    "path": "protocol/proto/state_restore/playback_state.proto",
    "content": "syntax = \"proto2\";\n\npackage spotify.player.proto.state_restore;\n\nimport \"state_restore/playback_quality.proto\";\n\nmessage PlaybackState {\n  optional int64 timestamp = 1;\n  optional int32 position_as_of_timestamp = 2;\n  optional int32 duration = 3;\n  optional bool is_buffering = 4;\n  optional PlaybackQuality playback_quality = 5;\n  optional double playback_speed = 6;\n}\n\n"
  },
  {
    "path": "protocol/proto/state_restore/player_model.proto",
    "content": "syntax = \"proto2\";\n\npackage spotify.player.proto.state_restore;\n\nimport \"context_player_options.proto\";\nimport \"state_restore/player_session_queue.proto\";\n\nmessage PlayerModel {\n  message ConfigurationEntry {\n    optional string key = 1;\n    optional string value = 2;\n  }\n\n  enum AdvanceReason {\n    SKIP_TO_PREV_TRACK = 1;\n    SKIP_TO_NEXT_TRACK = 2;\n    EXTERNAL_ADVANCE = 3;\n    INTERRUPTED = 4;\n    SWITCHED_TO_VIDEO = 5;\n    SWITCHED_TO_AUDIO = 6;\n  }\n\n  enum StartReason {\n    PLAY_CONTEXT = 1;\n    PLAY_CONTEXT_TRACK = 2;\n    STATE_RESTORE = 3;\n    REMOTE_TRANSFER = 4;\n  }\n\n  required ContextPlayerOptions options = 1;\n  repeated PlayerModel.ConfigurationEntry configuration = 2;\n  required PlayerSessionQueue session_queue = 3;\n  required PlayerModel.AdvanceReason last_advance_reason = 4;\n  optional PlayerModel.StartReason last_start_reason = 5;\n  required string prev_state_id = 6;\n  optional string override_state_id = 7;\n}\n\n"
  },
  {
    "path": "protocol/proto/state_restore/player_session.proto",
    "content": "syntax = \"proto2\";\n\npackage spotify.player.proto.state_restore;\n\nimport \"context_track.proto\";\nimport \"context_player_options.proto\";\nimport \"logging_params.proto\";\nimport \"play_origin.proto\";\nimport \"player_license.proto\";\nimport \"prepare_play_options.proto\";\nimport \"state_restore/context_loader.proto\";\nimport \"state_restore/context_player_rules.proto\";\nimport \"state_restore/playback_state.proto\";\nimport \"state_restore/player_session_fake.proto\";\nimport \"state_restore/provided_track.proto\";\n\nmessage PlayerSession {\n  required PreparePlayOptions prepare_play_options = 1;\n  optional PlaybackState playback_state = 2;\n  optional ProvidedTrack track = 3;\n  optional ContextTrack track_to_skip_to = 4;\n  optional bytes given_playback_id = 5;\n  required LoggingParams next_command_logging_params = 6;\n  required LoggingParams curr_command_logging_params = 7;\n  required PlayOrigin play_origin = 8;\n  required bool is_playing = 9;\n  required bool is_paused = 10;\n  required bool is_system_initiated = 11;\n  required bool is_finished = 12;\n  required ContextPlayerOptions options = 13;\n  required uint64 playback_seed = 14;\n  required int32 num_advances = 15;\n  required bool did_skip_prev = 16;\n  required PlayerLicense license = 17;\n  required ContextPlayerRules rules = 18;\n  required ContextLoader loader = 19;\n  optional PlayerSessionFake fake = 100;\n}\n\n"
  },
  {
    "path": "protocol/proto/state_restore/player_session_fake.proto",
    "content": "syntax = \"proto2\";\n\npackage spotify.player.proto.state_restore;\n\nimport \"context.proto\";\nimport \"state_restore/context_player_state.proto\";\n\nmessage PlayerSessionFake {\n  required ContextPlayerState player_state = 1;\n  required Context player_context = 2;\n  required bool is_finished = 3;\n}\n\n"
  },
  {
    "path": "protocol/proto/state_restore/player_session_queue.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\nimport \"state_restore/player_session.proto\";\n\npackage spotify.player.proto.state_restore;\n\noption optimize_for = CODE_SIZE;\n\nmessage QueuedSession {\n    enum Trigger {\n        DID_GO_PAST_TRACK = 1;\n        DID_GO_PAST_CONTEXT = 2;\n    }\n\n    optional QueuedSession.Trigger trigger = 1;\n    optional PlayerSession session = 2;\n}\n\nmessage PlayerSessionQueue {\n    optional PlayerSession active = 1;\n    repeated PlayerSession pushed = 2;\n    repeated QueuedSession queued = 3;\n}\n\n"
  },
  {
    "path": "protocol/proto/state_restore/provided_track.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.player.proto.state_restore;\n\nimport \"restrictions.proto\";\n\noption optimize_for = CODE_SIZE;\n\nmessage ProvidedTrack {\n    optional string uid = 1;\n    optional string uri = 2;\n    map<string, string> metadata = 3;\n    optional string provider = 4;\n    repeated string removed = 5;\n    repeated string blocked = 6;\n    map<string, string> internal_metadata = 7;\n    optional Restrictions restrictions = 8;\n}\n"
  },
  {
    "path": "protocol/proto/state_restore/random_source.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.player.proto.state_restore;\n\noption optimize_for = CODE_SIZE;\n\nmessage RandomSource {\n    required uint64 random_0 = 1;\n    required uint64 random_1 = 2;\n}\n"
  },
  {
    "path": "protocol/proto/state_restore/remove_banned_tracks_rules.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.player.proto.state_restore;\n\noption optimize_for = CODE_SIZE;\n\nmessage Strings {\n    repeated string strings = 1;\n}\n\nmessage RemoveBannedTracksRules {\n    repeated string banned_tracks = 1;\n    repeated string banned_albums = 2;\n    repeated string banned_artists = 3;\n    map<string, Strings> banned_context_tracks = 4;\n}\n"
  },
  {
    "path": "protocol/proto/state_restore/resume_points_rules.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.player.proto.state_restore;\n\noption optimize_for = CODE_SIZE;\n\nmessage ResumePoint {\n    required bool is_fully_played = 1;\n    required int64 position = 2;\n    required int64 timestamp = 3;\n}\n\nmessage ResumePointsRules {\n    map<string, ResumePoint> resume_points = 1;\n}\n"
  },
  {
    "path": "protocol/proto/state_restore/track_error_rules.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.player.proto.state_restore;\n\noption optimize_for = CODE_SIZE;\n\nmessage TrackErrorRules {\n    repeated string reasons = 1;\n    required int32 num_attempted_tracks = 2;\n    required int32 num_failed_tracks = 3;\n}\n"
  },
  {
    "path": "protocol/proto/status.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.collection_cosmos.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage Status {\n    int32 code = 1;\n    string reason = 2;\n}\n"
  },
  {
    "path": "protocol/proto/status_code.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.stream_reporting_esperanto.proto;\n\noption objc_class_prefix = \"ESP\";\noption java_package = \"com.spotify.stream_reporting_esperanto.proto\";\n\nenum StatusCode {\n    INVALID_STATUS_CODE = 0;\n    SUCCESS = 1;\n    EVENT_SENDER_ERROR = 2;\n    INVALID_STREAM_HANDLE = 3;\n    PENDING_EVENTS_ERROR = 4;\n    IGNORED = 5;\n}\n"
  },
  {
    "path": "protocol/proto/status_response.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.stream_reporting_esperanto.proto;\n\nimport \"status_code.proto\";\n\noption objc_class_prefix = \"ESP\";\noption java_package = \"com.spotify.stream_reporting_esperanto.proto\";\n\nmessage StatusResponse {\n    StatusCode status_code = 1;\n}\n"
  },
  {
    "path": "protocol/proto/storage-resolve.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.download.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage StorageResolveResponse {\n    Result result = 1;\n    enum Result {\n        CDN = 0;\n        STORAGE = 1;\n        RESTRICTED = 3;\n    }\n\n    repeated string cdnurl = 2;\n    bytes fileid = 4;\n}\n"
  },
  {
    "path": "protocol/proto/storage_cosmos.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto3\";\n\npackage spotify.storage_cosmos.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage GetFileCacheRangesResponse {\n    bool byte_size_known = 1;\n    uint64 byte_size = 2;\n    \n    repeated Range ranges = 3;\n    message Range {\n        uint64 from_byte = 1;\n        uint64 to_byte = 2;\n    }\n}\n"
  },
  {
    "path": "protocol/proto/storylines.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.storylines.v1;\n\noption java_multiple_files = true;\noption java_outer_classname = \"StorylinesProto\";\noption java_package = \"com.spotify.storylines.v1.extended_metadata\";\n\nmessage Artist {\n    string uri = 1;\n    string name = 2;\n    string avatar_cdn_url = 3;\n}\n\nmessage Card {\n    string id = 1;\n    string image_cdn_url = 2;\n    int32 image_width = 3;\n    int32 image_height = 4;\n}\n\nmessage Storyline {\n    string id = 1;\n    string entity_uri = 2;\n    Artist artist = 3;\n    repeated Card cards = 4;\n}\n"
  },
  {
    "path": "protocol/proto/stream_end_request.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.stream_reporting_esperanto.proto;\n\nimport \"stream_handle.proto\";\nimport \"play_reason.proto\";\nimport \"media_format.proto\";\n\noption objc_class_prefix = \"ESP\";\noption java_package = \"com.spotify.stream_reporting_esperanto.proto\";\n\nmessage StreamEndRequest {\n    StreamHandle stream_handle = 1;\n    string source_end = 2;\n    PlayReason reason_end = 3;\n    google.protobuf.Timestamp client_timestamp = 5;\n    optional AudioFormat format = 4;\n}\n"
  },
  {
    "path": "protocol/proto/stream_handle.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.stream_reporting_esperanto.proto;\n\noption objc_class_prefix = \"ESP\";\noption java_package = \"com.spotify.stream_reporting_esperanto.proto\";\n\nmessage StreamHandle {\n    reserved 1;\n    uint32 raw_handle = 2;\n}\n"
  },
  {
    "path": "protocol/proto/stream_progress_request.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.stream_reporting_esperanto.proto;\n\nimport \"google/protobuf/timestamp.proto\";\nimport \"audio_format.proto\";\nimport \"stream_handle.proto\";\nimport \"playback_state.proto\";\n\noption objc_class_prefix = \"ESP\";\noption java_package = \"com.spotify.stream_reporting_esperanto.proto\";\n\nmessage StreamProgressRequest {\n    StreamHandle stream_handle = 1;\n    uint64 current_position = 2;\n    bool is_paused = 3;\n    bool is_playing_video = 4;\n    bool is_overlapping = 5;\n    bool is_background = 6;\n    bool is_fullscreen = 7;\n    bool is_external = 8;\n    double playback_speed = 9;\n    google.protobuf.Timestamp client_timestamp = 14;\n    PlaybackState playback_state = 15;\n    optional string media_id = 10;\n    optional bool content_is_downloaded = 11;\n    optional AudioFormat audio_format = 12;\n    optional string content_uri = 13;\n    optional bool is_audio_on = 16;\n    optional string video_surface = 17;\n}\n"
  },
  {
    "path": "protocol/proto/stream_seek_request.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.stream_reporting_esperanto.proto;\n\nimport \"google/protobuf/timestamp.proto\";\nimport \"stream_handle.proto\";\n\noption objc_class_prefix = \"ESP\";\noption java_package = \"com.spotify.stream_reporting_esperanto.proto\";\n\nmessage StreamSeekRequest {\n    reserved 2;\n    StreamHandle stream_handle = 1;\n    uint64 from_position = 3;\n    uint64 to_position = 4;\n    google.protobuf.Timestamp client_timestamp = 5;\n    optional bool is_system_initiated = 6;\n}\n"
  },
  {
    "path": "protocol/proto/stream_start_request.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.stream_reporting_esperanto.proto;\n\nimport \"google/protobuf/timestamp.proto\";\nimport \"media_type.proto\";\nimport \"play_reason.proto\";\nimport \"playback_stack.proto\";\nimport \"playback_stack_v2.proto\";\nimport \"streaming_rule.proto\";\n\noption objc_class_prefix = \"ESP\";\noption java_package = \"com.spotify.stream_reporting_esperanto.proto\";\n\nmessage StreamStartRequest {\n    reserved 9;\n    reserved 10;\n    reserved 13;\n    reserved 14;\n    reserved 27;\n    reserved 25;\n    reserved 35;\n    bytes playback_id = 1;\n    bytes parent_playback_id = 2;\n    string parent_play_track = 3;\n    string video_session_id = 4;\n    string play_context = 5;\n    string content_uri = 6;\n    string displayed_content_uri = 7;\n    PlaybackStack playback_stack = 8;\n    string provider = 11;\n    string referrer = 12;\n    StreamingRule streaming_rule = 15;\n    string connect_controller_device_id = 16;\n    string page_instance_id = 17;\n    string interaction_id = 18;\n    string source_start = 19;\n    PlayReason reason_start = 20;\n    bool is_shuffle = 23;\n    string media_id = 28;\n    MediaType media_type = 29;\n    uint64 playback_start_time = 30;\n    uint64 start_position = 31;\n    bool is_live = 32;\n    bool content_is_downloaded = 33;\n    bool client_offline = 34;\n    string feature_uuid = 36;\n    string decision_id = 37;\n    string custom_reporting_attribution = 38;\n    string play_context_decision_id = 39;\n    google.protobuf.Timestamp client_timestamp = 40;\n    bool is_video_on = 44;\n    string player_session_id = 47;\n    optional bool is_repeating_track = 41;\n    optional bool is_repeating_context = 42;\n    optional bool is_audio_on = 43;\n    optional string video_surface = 45;\n    optional PlaybackStackV2 playback_stack_v2 = 46;\n    optional string preview_impression_uri = 48;\n}\n\n"
  },
  {
    "path": "protocol/proto/stream_start_response.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.stream_reporting_esperanto.proto;\n\nimport \"status_response.proto\";\nimport \"stream_handle.proto\";\n\noption objc_class_prefix = \"ESP\";\noption java_package = \"com.spotify.stream_reporting_esperanto.proto\";\n\nmessage StreamStartResponse {\n    StatusResponse status = 1;\n    StreamHandle stream_handle = 2;\n}\n"
  },
  {
    "path": "protocol/proto/streaming_rule.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.stream_reporting_esperanto.proto;\n\noption objc_class_prefix = \"ESP\";\noption java_package = \"com.spotify.stream_reporting_esperanto.proto\";\n\nenum StreamingRule {\n    STREAMING_RULE_NONE = 0;\n    STREAMING_RULE_DMCA_RADIO = 1;\n    STREAMING_RULE_PREVIEW = 2;\n    STREAMING_RULE_WIFI = 3;\n    STREAMING_RULE_SHUFFLE_MODE = 4;\n    STREAMING_RULE_TABLET_FREE = 5;\n}\n"
  },
  {
    "path": "protocol/proto/suppressions.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.player.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage Suppressions {\n    repeated string providers = 1;\n}\n"
  },
  {
    "path": "protocol/proto/sync/album_sync_state.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.cosmos_util.proto;\n\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.cosmos.util.proto\";\n\nmessage AlbumSyncState {\n    optional string offline = 1;\n    optional string inferred_offline = 2;\n    optional uint32 sync_progress = 3;\n}\n"
  },
  {
    "path": "protocol/proto/sync/artist_sync_state.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.cosmos_util.proto;\n\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.cosmos.util.proto\";\n\nmessage ArtistSyncState {\n    optional string offline = 1;\n    optional string inferred_offline = 2;\n    optional uint32 sync_progress = 3;\n}\n"
  },
  {
    "path": "protocol/proto/sync/episode_sync_state.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.cosmos_util.proto;\n\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.cosmos.util.proto\";\n\nmessage EpisodeSyncState {\n    optional string offline_state = 1;\n    optional uint32 sync_progress = 2;\n}\n"
  },
  {
    "path": "protocol/proto/sync/track_sync_state.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.cosmos_util.proto;\n\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.cosmos.util.proto\";\n\nmessage TrackSyncState {\n    optional string offline = 1;\n    optional uint32 sync_progress = 2;\n}\n"
  },
  {
    "path": "protocol/proto/sync_request.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.playlist.cosmos.proto;\n\noption objc_class_prefix = \"SPTPlaylist\";\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"com.spotify.playlist.proto\";\n\nmessage SyncRequest {\n    repeated string playlist_uris = 1;\n}\n"
  },
  {
    "path": "protocol/proto/techu_core_exercise_cosmos.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto3\";\n\npackage spotify.techu_core_exercise_cosmos.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage TechUCoreExerciseRequest {\n    string a = 1;\n    string b = 2;\n}\n\nmessage TechUCoreExerciseResponse {\n    string concatenated = 1;\n}\n"
  },
  {
    "path": "protocol/proto/track_instance.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.player.proto;\n\nimport \"context_index.proto\";\nimport \"context_track.proto\";\nimport \"seek_to_position.proto\";\n\noption optimize_for = CODE_SIZE;\n\nmessage TrackInstance {\n    reserved 3;\n    optional ContextTrack track = 1;\n    optional uint64 id = 2;\n    optional SeekToPosition seek_to_position = 7;\n    optional bool initially_paused = 4;\n    optional ContextIndex index = 5;\n    optional string provider = 6;\n}\n"
  },
  {
    "path": "protocol/proto/track_instantiator.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.player.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage TrackInstantiator {\n    optional uint64 unique = 1;\n    optional uint64 count = 2;\n    optional string provider = 3;\n}\n"
  },
  {
    "path": "protocol/proto/transcripts.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto3\";\n\npackage spotify.corex.transcripts.metadata;\n\noption objc_class_prefix = \"SPT\";\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_outer_classname = \"TranscriptMetadataProto\";\noption java_package = \"com.spotify.corex.transcripts.metadata.proto\";\n\nmessage EpisodeTranscript {\n    string episode_uri = 1;\n    repeated Transcript transcripts = 2;\n}\n\nmessage Transcript {\n    string uri = 1;\n    string language = 2;\n    bool curated = 3;\n    string cdn_url = 4;\n}\n"
  },
  {
    "path": "protocol/proto/transfer_node.proto",
    "content": "// Extracted from: Spotify 1.1.61.583 (Windows)\n\nsyntax = \"proto2\";\n\npackage spotify.player.proto;\n\nimport \"track_instance.proto\";\nimport \"track_instantiator.proto\";\n\noption optimize_for = CODE_SIZE;\n\nmessage TransferNode {\n    optional TrackInstance instance = 1;\n    optional TrackInstantiator instantiator = 2;\n}\n"
  },
  {
    "path": "protocol/proto/transfer_state.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.player.proto.transfer;\n\nimport \"context_player_options.proto\";\nimport \"playback.proto\";\nimport \"play_history.proto\";\nimport \"session.proto\";\nimport \"queue.proto\";\n\noption optimize_for = CODE_SIZE;\n\nmessage TransferState {\n    optional ContextPlayerOptions options = 1;\n    optional Playback playback = 2;\n    optional Session current_session = 3;\n    optional Queue queue = 4;\n    optional PlayHistory play_history = 5;\n}\n"
  },
  {
    "path": "protocol/proto/tts-resolve.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.narration.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage ResolveRequest {\n    AudioFormat audio_format = 3;\n    enum AudioFormat {\n        UNSPECIFIED = 0;\n        WAV = 1;\n        PCM = 2;\n        OPUS = 3;\n        VORBIS = 4;\n        MP3 = 5;\n    }\n\n    string language = 4;\n\n    TtsVoice tts_voice = 5;\n    enum TtsVoice {\n        UNSET_TTS_VOICE = 0;\n        VOICE1 = 1;\n        VOICE2 = 2;\n        VOICE3 = 3;\n        VOICE4 = 4;\n        VOICE5 = 5;\n        VOICE6 = 6;\n        VOICE7 = 7;\n        VOICE8 = 8;\n        VOICE9 = 9;\n        VOICE10 = 10;\n        VOICE11 = 11;\n        VOICE12 = 12;\n        VOICE13 = 13;\n        VOICE14 = 14;\n        VOICE15 = 15;\n        VOICE16 = 16;\n        VOICE17 = 17;\n        VOICE18 = 18;\n        VOICE19 = 19;\n        VOICE20 = 20;\n        VOICE21 = 21;\n        VOICE22 = 22;\n        VOICE23 = 23;\n        VOICE24 = 24;\n        VOICE25 = 25;\n        VOICE26 = 26;\n        VOICE27 = 27;\n        VOICE28 = 28;\n        VOICE29 = 29;\n        VOICE30 = 30;\n        VOICE31 = 31;\n        VOICE32 = 32;\n        VOICE33 = 33;\n        VOICE34 = 34;\n        VOICE35 = 35;\n        VOICE36 = 36;\n        VOICE37 = 37;\n        VOICE38 = 38;\n        VOICE39 = 39;\n        VOICE40 = 40;\n    }\n\n    TtsProvider tts_provider = 6;\n    enum TtsProvider {\n        UNSET_TTS_PROVIDER = 0;\n        CLOUD_TTS = 1;\n        READSPEAKER = 2;\n        POLLY = 3;\n        WELL_SAID = 4;\n        SONANTIC_DEPRECATED = 5;\n        SONANTIC_FAST = 6;\n    }\n\n    int32 sample_rate_hz = 7;\n    oneof prompt {\n        string text = 1;\n        string ssml = 2;\n    }\n}\n\nmessage ResolveResponse {\n    string url = 1;\n    int64 expiry = 2;\n}\n"
  },
  {
    "path": "protocol/proto/ucs.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.remote_config.ucs.proto;\n\nimport \"resolve.proto\";\nimport \"useraccount.proto\";\n\noption optimize_for = CODE_SIZE;\n\nmessage UcsRequest {\n    CallerInfo caller_info = 1;\n    message CallerInfo {\n        string request_origin_id = 1;\n        string request_orgin_version = 2;\n        string reason = 3;\n    }\n\n    ResolveRequest resolve_request = 2;\n\n    AccountAttributesRequest account_attributes_request = 3;\n    message AccountAttributesRequest {\n    }\n}\n\nmessage UcsResponseWrapper {\n    oneof result {\n        UcsResponse success = 1;\n        Error error = 2;\n    }\n\n    message UcsResponse {\n        int64 fetch_time_millis = 5;\n        oneof resolve_result {\n            ResolveResponse resolve_success = 1;\n            Error resolve_error = 2;\n        }\n        oneof account_attributes_result {\n            AccountAttributesResponse account_attributes_success = 3;\n            Error account_attributes_error = 4;\n        }\n    }\n\n    message AccountAttributesResponse {\n        map<string, AccountAttribute> account_attributes = 1;\n    }\n\n    message Error {\n        int32 error_code = 1;\n        string error_message = 2;\n    }\n}\n"
  },
  {
    "path": "protocol/proto/unfinished_episodes_request.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto2\";\n\npackage spotify.show_cosmos.unfinished_episodes_request.proto;\n\nimport \"metadata/episode_metadata.proto\";\nimport \"played_state/episode_played_state.proto\";\nimport \"show_episode_state.proto\";\n\noption objc_class_prefix = \"SPTShowCosmosUnfinshedEpisodes\";\noption optimize_for = CODE_SIZE;\n\nmessage Episode {\n    optional cosmos_util.proto.EpisodeMetadata episode_metadata = 1;\n    optional show_cosmos.proto.EpisodeCollectionState episode_collection_state = 2;\n    optional show_cosmos.proto.EpisodeOfflineState episode_offline_state = 3;\n    optional cosmos_util.proto.EpisodePlayState episode_play_state = 4;\n    optional string link = 5;\n}\n\nmessage Response {\n    repeated Episode episode = 2;\n\n    reserved 1;\n}\n"
  },
  {
    "path": "protocol/proto/user_attributes.proto",
    "content": "// Custom protobuf crafted from spotify:user:attributes:mutated response:\n//\n// 1 {\n//   1: \"filter-explicit-content\"\n// }\n// 2 {\n//   1: 1639087299\n//   2: 418909000\n// }\n\nsyntax = \"proto3\";\n\npackage spotify.user_attributes.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage UserAttributesMutation {\n\trepeated MutatedField fields = 1;\n\tMutationCommand cmd = 2;\n}\n\nmessage MutatedField {\n\tstring name = 1;\n}\n\nmessage MutationCommand {\n\tint64 timestamp = 1;\n\tint32 unknown = 2;\n}\n"
  },
  {
    "path": "protocol/proto/useraccount.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.remote_config.ucs.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage AccountAttribute {\n    oneof value {\n        bool bool_value = 2;\n        int64 long_value = 3;\n        string string_value = 4;\n    }\n}\n"
  },
  {
    "path": "protocol/proto/your_library_config.proto",
    "content": "syntax = \"proto3\";\n\npackage spotify.your_library.proto;\n\nmessage YourLibraryLabelAndImage {\n  string label = 1;\n  string image = 2;\n  bool include_empty = 3;\n}\n\nmessage YourLibraryPseudoPlaylistConfig {\n  .spotify.your_library.proto.YourLibraryLabelAndImage liked_songs = 1;\n  .spotify.your_library.proto.YourLibraryLabelAndImage your_episodes = 2;\n  .spotify.your_library.proto.YourLibraryLabelAndImage new_episodes = 3;\n  .spotify.your_library.proto.YourLibraryLabelAndImage local_files = 4;\n  .spotify.your_library.proto.YourLibraryLabelAndImage cached_files = 5;\n  bool your_highlights = 6;\n  bool all_available_configs_provided = 99;\n}\n\nmessage YourLibraryFilters {\n  enum Filter {\n    ALBUM = 0;\n    ARTIST = 1;\n    PLAYLIST = 2;\n    SHOW = 3;\n    BOOK = 4;\n    EVENT = 5;\n    AUTHOR = 7;\n    DOWNLOADED = 100;\n    WRITABLE = 101;\n    BY_YOU = 102;\n    BY_SPOTIFY = 103;\n    UNPLAYED = 104;\n    IN_PROGRESS = 105;\n    FINISHED = 106;\n  }\n\n  repeated .spotify.your_library.proto.YourLibraryFilters.Filter filter = 1;\n}\n\nmessage YourLibrarySortOrder {\n  enum SortOrder {\n    NAME = 0;\n    RECENTLY_ADDED = 1;\n    CREATOR = 2;\n    CUSTOM = 4;\n    RECENTLY_UPDATED = 5;\n    RECENTLY_PLAYED_OR_ADDED = 6;\n    RELEVANCE = 7;\n    EVENT_START_TIME = 8;\n    RELEASE_DATE = 9;\n  }\n\n  .spotify.your_library.proto.YourLibrarySortOrder.SortOrder sort_order = 1;\n}\n\n"
  },
  {
    "path": "protocol/proto/your_library_contains_request.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.your_library.proto;\n\nimport \"your_library_config.proto\";\n\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"spotify.your_library.esperanto.proto\";\n\nmessage YourLibraryContainsRequest {\n    repeated string requested_uri = 3;\n    YourLibraryPseudoPlaylistConfig pseudo_playlist_config = 4;\n    int32 update_throttling = 5;\n}\n"
  },
  {
    "path": "protocol/proto/your_library_contains_response.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.your_library.proto;\n\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"spotify.your_library.esperanto.proto\";\n\nmessage YourLibraryContainsResponseHeader {\n    bool is_loading = 2;\n}\n\nmessage YourLibraryContainsResponseEntity {\n    string uri = 1;\n    bool is_in_library = 2;\n}\n\nmessage YourLibraryContainsResponse {\n    YourLibraryContainsResponseHeader header = 1;\n    repeated YourLibraryContainsResponseEntity entity = 2;\n    uint32 status_code = 98;\n    string error = 99;\n}\n"
  },
  {
    "path": "protocol/proto/your_library_decorate_request.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.your_library.proto;\n\nimport \"your_library_config.proto\";\n\noption java_package = \"spotify.your_library.esperanto.proto\";\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\n\nmessage YourLibraryDecorateRequest {\n    repeated string requested_uri = 3;\n    YourLibraryPseudoPlaylistConfig pseudo_playlist_config = 6;\n    int32 update_throttling = 7;\n}\n"
  },
  {
    "path": "protocol/proto/your_library_decorate_response.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.your_library.proto;\n\nimport \"your_library_decorated_entity.proto\";\n\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"spotify.your_library.esperanto.proto\";\n\nmessage YourLibraryDecorateResponseHeader {\n    bool is_loading = 2;\n}\n\nmessage YourLibraryDecorateResponse {\n    YourLibraryDecorateResponseHeader header = 1;\n    repeated YourLibraryDecoratedEntity entity = 2;\n    uint32 status_code = 98;\n    string error = 99;\n}\n"
  },
  {
    "path": "protocol/proto/your_library_decorated_entity.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.your_library.proto;\n\nimport \"policy/supported_link_types_in_playlists.proto\";\n\noption optimize_for = CODE_SIZE;\n\nmessage YourLibraryEntityInfo {\n    enum Pinnable {\n        YES = 0;\n        NO_IN_FOLDER = 1;\n    }\n\n    string name = 2;\n    string uri = 3;\n    string group_label = 5;\n    string image_uri = 6;\n    bool pinned = 7;\n    Pinnable pinnable = 8;\n    Offline.Availability offline_availability = 9;\n    int64 add_time = 11;\n    int64 last_played = 12;\n    bool has_curated_items = 13;\n}\n\nmessage Offline {\n    enum Availability {\n        UNKNOWN = 0;\n        NO = 1;\n        YES = 2;\n        DOWNLOADING = 3;\n        WAITING = 4;\n    }\n\n}\n\nmessage YourLibraryAlbumExtraInfo {\n    enum Type {\n        ALBUM = 0;\n        SINGLE = 1;\n        COMPILATION = 2;\n        EP = 3;\n    }\n\n    string artist_name = 1;\n    string artist_uri = 2;\n    Type type = 3;\n    bool is_premium_only = 4;\n    bool new_release = 5;\n}\n\nmessage YourLibraryArtistExtraInfo {\n    bool has_liked_tracks_or_albums = 1;\n}\n\nmessage NumberOfItemsForLinkType {\n    playlist.cosmos.proto.LinkType link_type = 1;\n    int32 num_items = 2;\n}\n\nmessage YourLibraryPlaylistFolderInfo {\n    string uri = 1;\n    string name = 2;\n}\n\nmessage YourLibraryPlaylistExtraInfo {\n    string creator_name = 1;\n    string creator_uri = 8;\n    bool is_loading = 5;\n    bool can_view = 6;\n    bool can_add = 9;\n    string row_id = 7;\n    string made_for_name = 10;\n    string made_for_uri = 11;\n    repeated NumberOfItemsForLinkType number_of_items_per_link_type = 12;\n    bool owned_by_self = 13;\n    YourLibraryPlaylistFolderInfo from_folder = 14;\n    string name_prefix = 15;\n}\n\nmessage YourLibraryShowExtraInfo {\n    string creator_name = 1;\n    int64 publish_date = 4;\n    bool is_music_and_talk = 5;\n    int32 number_of_downloaded_episodes = 6;\n}\n\nmessage YourLibraryFolderExtraInfo {\n    int32 number_of_playlists = 2;\n    int32 number_of_folders = 3;\n    string row_id = 4;\n    repeated YourLibraryDecoratedEntity entity = 5;\n}\n\nmessage YourLibraryLikedSongsExtraInfo {\n    int32 number_of_songs = 3;\n}\n\nmessage YourLibraryYourEpisodesExtraInfo {\n    int32 number_of_downloaded_episodes = 4;\n}\n\nmessage YourLibraryNewEpisodesExtraInfo {\n    int64 publish_date = 1;\n}\n\nmessage YourLibraryLocalFilesExtraInfo {\n    int32 number_of_files = 1;\n}\n\nmessage YourLibraryBookExtraInfo {\n    enum Access {\n        OPEN = 0;\n        LOCKED = 1;\n        CAPPED = 2;\n    }\n\n    enum State {\n        NOT_STARTED = 0;\n        IN_PROGRESS = 1;\n        FINISHED = 2;\n    }\n\n    string author_name = 1;\n    Access access = 2;\n    int64 milliseconds_left = 3;\n    int32 percent_done = 4;\n    State state = 5;\n}\n\nmessage YourLibraryCachedFilesExtraInfo {\n    int32 number_of_items = 1;\n    int32 duration_in_seconds = 2;\n}\n\nmessage YourLibraryPreReleaseExtraInfo {\n    enum Type {\n        ALBUM = 0;\n        BOOK = 1;\n    }\n\n    string artist_name = 1;\n    string artist_uri = 2;\n    Type type = 3;\n    YourLibraryAlbumExtraInfo.Type album_type = 4;\n}\n\nmessage YourLibraryEventExtraInfo {\n    string location_name = 1;\n    int64 start_time = 2;\n    string city_name = 3;\n}\n\nmessage YourLibraryAuthorExtraInfo {\n}\n\nmessage YourLibraryDecoratedEntity {\n    YourLibraryEntityInfo entity_info = 1;\n    oneof entity {\n        YourLibraryAlbumExtraInfo album = 2;\n        YourLibraryArtistExtraInfo artist = 3;\n        YourLibraryPlaylistExtraInfo playlist = 4;\n        YourLibraryShowExtraInfo show = 5;\n        YourLibraryFolderExtraInfo folder = 6;\n        YourLibraryLikedSongsExtraInfo liked_songs = 8;\n        YourLibraryYourEpisodesExtraInfo your_episodes = 9;\n        YourLibraryNewEpisodesExtraInfo new_episodes = 10;\n        YourLibraryLocalFilesExtraInfo local_files = 11;\n        YourLibraryBookExtraInfo book = 12;\n        YourLibraryCachedFilesExtraInfo cached_files = 13;\n        YourLibraryPreReleaseExtraInfo prerelease = 15;\n        YourLibraryEventExtraInfo event = 16;\n        YourLibraryAuthorExtraInfo author = 17;\n    }\n}\n\n"
  },
  {
    "path": "protocol/proto/your_library_entity.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.your_library.proto;\n\nimport \"your_library_index.proto\";\nimport \"collection_index.proto\";\n\noption optimize_for = CODE_SIZE;\n\nmessage YourLibraryEntity {\n    oneof entity {\n        collection.proto.CollectionAlbumEntry album = 1;\n        collection.proto.CollectionArtistEntry artist = 2;\n        YourLibraryRootlistEntity rootlist_entity = 3;\n        collection.proto.CollectionShowEntry show = 4;\n        collection.proto.CollectionBookEntry book = 5;\n        YourLibraryPreReleaseEntity prerelease = 6;\n        YourLibraryEventEntity event = 7;\n        collection.proto.CollectionAuthorEntry author = 9;\n    }\n}\n"
  },
  {
    "path": "protocol/proto/your_library_index.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.your_library.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage YourLibraryRootlistPlaylist {\n    string prefix = 1;\n    string image_uri = 2;\n    string creator_uri = 3;\n    string made_for_name = 4;\n    string made_for_uri = 5;\n    bool is_loading = 6;\n    int32 rootlist_index = 7;\n    string row_id = 8;\n    bool can_view = 9;\n    bool can_add = 10;\n    bool owned_by_self = 11;\n}\n\nmessage YourLibraryPredefinedPlaylist {\n    string prefix = 1;\n    string image_uri = 2;\n    string creator_uri = 3;\n    string made_for_name = 4;\n    string made_for_uri = 5;\n    bool is_loading = 6;\n    bool can_view = 7;\n    bool can_add = 8;\n    bool owned_by_self = 9;\n}\n\nmessage YourLibraryRootlistFolder {\n    int32 number_of_playlists = 1;\n    int32 number_of_folders = 2;\n    int32 rootlist_index = 3;\n    string row_id = 4;\n}\n\nmessage YourLibraryRootlistPseudoPlaylist {\n    enum Kind {\n        LIKED_SONGS = 0;\n        YOUR_EPISODES = 1;\n        NEW_EPISODES = 2;\n        LOCAL_FILES = 3;\n        CACHED_FILES = 4;\n        CONTENT_FEED = 5;\n        YOUR_HIGHLIGHTS = 6;\n    }\n\n    Kind kind = 1;\n}\n\nmessage YourLibraryRootlistEntity {\n    string uri = 1;\n    string name = 2;\n    string creator_name = 3;\n    int64 add_time = 4;\n    int64 last_played = 5;\n    oneof entity {\n        YourLibraryRootlistPlaylist playlist = 6;\n        YourLibraryRootlistFolder folder = 7;\n        YourLibraryRootlistPseudoPlaylist pseudo_playlist = 8;\n        YourLibraryPredefinedPlaylist predefined_playlist = 9;\n    }\n}\n\nmessage YourLibraryPreReleaseEntity {\n    enum Type {\n        ALBUM = 0;\n        BOOK = 1;\n    }\n\n    string entity_name = 1;\n    string uri = 2;\n    string creator_name = 3;\n    string creator_uri = 4;\n    string image_uri = 5;\n    int64 add_time = 6;\n    int64 release_time = 9;\n    Type type = 7;\n    string type_str = 8;\n}\n\nmessage YourLibraryEventEntity {\n    string uri = 1;\n    string event_name = 2;\n    repeated string artist_names = 3;\n    string location_name = 4;\n    string image_uri = 5;\n    int64 add_time = 6;\n    int64 event_time = 7;\n    int64 utc_event_time = 8;\n    string city_name = 9;\n}\n\n"
  },
  {
    "path": "protocol/proto/your_library_pseudo_playlist_config.proto",
    "content": "// Extracted from: Spotify 1.1.73.517 (macOS)\n\nsyntax = \"proto3\";\n\npackage spotify.your_library.proto;\n\noption optimize_for = CODE_SIZE;\n\nmessage YourLibraryLabelAndImage {\n    string label = 1;\n    string image = 2;\n}\n\nmessage YourLibraryPseudoPlaylistConfig {\n    YourLibraryLabelAndImage liked_songs = 1;\n    YourLibraryLabelAndImage your_episodes = 2;\n    YourLibraryLabelAndImage new_episodes = 3;\n    YourLibraryLabelAndImage local_files = 4;\n}\n"
  },
  {
    "path": "protocol/proto/your_library_request.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.your_library.proto;\n\nimport \"your_library_config.proto\";\n\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"spotify.your_library.esperanto.proto\";\n\nmessage YourLibraryTagFilter {\n    string tag_uri = 1;\n}\n\nmessage CuratedItems {\n    enum CuratedItemsFilter {\n        NONE = 0;\n        GROUP_BY = 1;\n        ONLY_CURATED = 2;\n        ONLY_NOT_CURATED = 3;\n    }\n\n    repeated string items = 1;\n    CuratedItemsFilter filter = 2;\n}\n\nmessage YourLibraryRequestHeader {\n    bool remaining_entities = 9;\n    bool total_count = 18;\n    string lower_bound = 10;\n    int32 skip = 11;\n    int32 length = 12;\n    string text_filter = 13;\n    YourLibraryFilters filters = 14;\n    YourLibrarySortOrder sort_order = 15;\n    bool all_playlists = 17;\n    repeated int64 fill_folders = 34;\n    bool separate_pinned_items = 22;\n    bool num_link_types_in_playlists = 25;\n    bool ignore_pinning = 26;\n    CuratedItems curated_items = 29;\n    bool include_events = 30;\n    bool include_prereleases = 31;\n    bool include_authors = 33;\n    oneof maybe_folder_id {\n        int64 folder_id = 16;\n    }\n    oneof maybe_tag_filter {\n        .spotify.your_library.proto.YourLibraryTagFilter tag_filter = 24;\n    }\n}\n\nmessage YourLibraryRequest {\n    YourLibraryRequestHeader header = 1;\n    YourLibraryPseudoPlaylistConfig pseudo_playlist_config = 4;\n    int32 update_throttling = 5;\n}\n"
  },
  {
    "path": "protocol/proto/your_library_response.proto",
    "content": "// Extracted from: Spotify 1.2.52.442 (windows)\n\nsyntax = \"proto3\";\n\npackage spotify.your_library.proto;\n\nimport \"your_library_decorated_entity.proto\";\nimport \"your_library_config.proto\";\n\noption java_multiple_files = true;\noption optimize_for = CODE_SIZE;\noption java_package = \"spotify.your_library.esperanto.proto\";\n\nmessage YourLibraryTagPlaylist {\n    string name = 1;\n    string uri = 2;\n    string description = 3;\n    string image_uri = 4;\n    proto.Offline.Availability offline_availability = 5;\n    bool is_curated = 6;\n    bool is_loading = 7;\n}\n\nmessage YourLibraryTagInfo {\n    string tag_name = 1;\n    bool is_added = 5;\n    YourLibraryTagPlaylist tag_playlist_info = 7;\n}\n\nmessage YourLibraryResponseHeader {\n    int32 remaining_entities = 9;\n    int32 total_count = 17;\n    int32 pin_count = 18;\n    int32 maximum_pinned_items = 19;\n    bool is_loading = 12;\n    string folder_name = 15;\n    string parent_folder_uri = 20;\n    YourLibraryFilters available_filters = 16;\n    YourLibraryTagInfo tag_info = 21;\n}\n\nmessage YourLibraryResponse {\n    YourLibraryResponseHeader header = 1;\n    repeated YourLibraryDecoratedEntity entity = 2;\n    repeated YourLibraryDecoratedEntity pinned_entity = 3;\n    int32 status_code = 98;\n    string error = 99;\n}\n"
  },
  {
    "path": "protocol/src/impl_trait/context.rs",
    "content": "use crate::{context::Context, context_page::ContextPage, context_track::ContextTrack};\nuse protobuf::Message;\nuse std::hash::{Hash, Hasher};\n\nimpl Hash for Context {\n    fn hash<H: Hasher>(&self, state: &mut H) {\n        if let Ok(ctx) = self.write_to_bytes() {\n            ctx.hash(state)\n        }\n    }\n}\n\nimpl Eq for Context {}\n\nimpl From<Vec<String>> for ContextPage {\n    fn from(value: Vec<String>) -> Self {\n        ContextPage {\n            tracks: value\n                .into_iter()\n                .map(|uri| ContextTrack {\n                    uri: Some(uri),\n                    ..Default::default()\n                })\n                .collect(),\n            ..Default::default()\n        }\n    }\n}\n\nimpl From<Vec<ContextTrack>> for ContextPage {\n    fn from(tracks: Vec<ContextTrack>) -> Self {\n        ContextPage {\n            tracks,\n            ..Default::default()\n        }\n    }\n}\n"
  },
  {
    "path": "protocol/src/impl_trait/player.rs",
    "content": "use crate::{\n    context_player_options::ContextPlayerOptions,\n    play_origin::PlayOrigin,\n    player::{\n        ContextPlayerOptions as PlayerContextPlayerOptions,\n        ModeRestrictions as PlayerModeRestrictions, PlayOrigin as PlayerPlayOrigin,\n        RestrictionReasons as PlayerRestrictionReasons, Restrictions as PlayerRestrictions,\n        Suppressions as PlayerSuppressions,\n    },\n    restrictions::{ModeRestrictions, RestrictionReasons, Restrictions},\n    suppressions::Suppressions,\n};\nuse std::collections::HashMap;\n\nfn hashmap_into<T: Into<V>, V>(map: HashMap<String, T>) -> HashMap<String, V> {\n    map.into_iter().map(|(k, v)| (k, v.into())).collect()\n}\n\nimpl From<ContextPlayerOptions> for PlayerContextPlayerOptions {\n    fn from(value: ContextPlayerOptions) -> Self {\n        PlayerContextPlayerOptions {\n            shuffling_context: value.shuffling_context.unwrap_or_default(),\n            repeating_context: value.repeating_context.unwrap_or_default(),\n            repeating_track: value.repeating_track.unwrap_or_default(),\n            modes: value.modes,\n            playback_speed: value.playback_speed,\n            special_fields: value.special_fields,\n        }\n    }\n}\n\nimpl From<PlayerRestrictions> for Restrictions {\n    fn from(value: PlayerRestrictions) -> Self {\n        Restrictions {\n            disallow_pausing_reasons: value.disallow_pausing_reasons,\n            disallow_resuming_reasons: value.disallow_resuming_reasons,\n            disallow_seeking_reasons: value.disallow_seeking_reasons,\n            disallow_peeking_prev_reasons: value.disallow_peeking_prev_reasons,\n            disallow_peeking_next_reasons: value.disallow_peeking_next_reasons,\n            disallow_skipping_prev_reasons: value.disallow_skipping_prev_reasons,\n            disallow_skipping_next_reasons: value.disallow_skipping_next_reasons,\n            disallow_toggling_repeat_context_reasons: value\n                .disallow_toggling_repeat_context_reasons,\n            disallow_toggling_repeat_track_reasons: value.disallow_toggling_repeat_track_reasons,\n            disallow_toggling_shuffle_reasons: value.disallow_toggling_shuffle_reasons,\n            disallow_set_queue_reasons: value.disallow_set_queue_reasons,\n            disallow_interrupting_playback_reasons: value.disallow_interrupting_playback_reasons,\n            disallow_transferring_playback_reasons: value.disallow_transferring_playback_reasons,\n            disallow_remote_control_reasons: value.disallow_remote_control_reasons,\n            disallow_inserting_into_next_tracks_reasons: value\n                .disallow_inserting_into_next_tracks_reasons,\n            disallow_inserting_into_context_tracks_reasons: value\n                .disallow_inserting_into_context_tracks_reasons,\n            disallow_reordering_in_next_tracks_reasons: value\n                .disallow_reordering_in_next_tracks_reasons,\n            disallow_reordering_in_context_tracks_reasons: value\n                .disallow_reordering_in_context_tracks_reasons,\n            disallow_removing_from_next_tracks_reasons: value\n                .disallow_removing_from_next_tracks_reasons,\n            disallow_removing_from_context_tracks_reasons: value\n                .disallow_removing_from_context_tracks_reasons,\n            disallow_updating_context_reasons: value.disallow_updating_context_reasons,\n            disallow_add_to_queue_reasons: value.disallow_add_to_queue_reasons,\n            disallow_setting_playback_speed: value.disallow_setting_playback_speed_reasons,\n            disallow_setting_modes: hashmap_into(value.disallow_setting_modes),\n            disallow_signals: hashmap_into(value.disallow_signals),\n            special_fields: value.special_fields,\n        }\n    }\n}\n\nimpl From<Restrictions> for PlayerRestrictions {\n    fn from(value: Restrictions) -> Self {\n        PlayerRestrictions {\n            disallow_pausing_reasons: value.disallow_pausing_reasons,\n            disallow_resuming_reasons: value.disallow_resuming_reasons,\n            disallow_seeking_reasons: value.disallow_seeking_reasons,\n            disallow_peeking_prev_reasons: value.disallow_peeking_prev_reasons,\n            disallow_peeking_next_reasons: value.disallow_peeking_next_reasons,\n            disallow_skipping_prev_reasons: value.disallow_skipping_prev_reasons,\n            disallow_skipping_next_reasons: value.disallow_skipping_next_reasons,\n            disallow_toggling_repeat_context_reasons: value\n                .disallow_toggling_repeat_context_reasons,\n            disallow_toggling_repeat_track_reasons: value.disallow_toggling_repeat_track_reasons,\n            disallow_toggling_shuffle_reasons: value.disallow_toggling_shuffle_reasons,\n            disallow_set_queue_reasons: value.disallow_set_queue_reasons,\n            disallow_interrupting_playback_reasons: value.disallow_interrupting_playback_reasons,\n            disallow_transferring_playback_reasons: value.disallow_transferring_playback_reasons,\n            disallow_remote_control_reasons: value.disallow_remote_control_reasons,\n            disallow_inserting_into_next_tracks_reasons: value\n                .disallow_inserting_into_next_tracks_reasons,\n            disallow_inserting_into_context_tracks_reasons: value\n                .disallow_inserting_into_context_tracks_reasons,\n            disallow_reordering_in_next_tracks_reasons: value\n                .disallow_reordering_in_next_tracks_reasons,\n            disallow_reordering_in_context_tracks_reasons: value\n                .disallow_reordering_in_context_tracks_reasons,\n            disallow_removing_from_next_tracks_reasons: value\n                .disallow_removing_from_next_tracks_reasons,\n            disallow_removing_from_context_tracks_reasons: value\n                .disallow_removing_from_context_tracks_reasons,\n            disallow_updating_context_reasons: value.disallow_updating_context_reasons,\n            disallow_add_to_queue_reasons: value.disallow_add_to_queue_reasons,\n            disallow_setting_playback_speed_reasons: value.disallow_setting_playback_speed,\n            disallow_setting_modes: hashmap_into(value.disallow_setting_modes),\n            disallow_signals: hashmap_into(value.disallow_signals),\n            disallow_playing_reasons: vec![],\n            disallow_stopping_reasons: vec![],\n            special_fields: value.special_fields,\n        }\n    }\n}\n\nimpl From<PlayerModeRestrictions> for ModeRestrictions {\n    fn from(value: PlayerModeRestrictions) -> Self {\n        ModeRestrictions {\n            values: hashmap_into(value.values),\n            special_fields: value.special_fields,\n        }\n    }\n}\n\nimpl From<ModeRestrictions> for PlayerModeRestrictions {\n    fn from(value: ModeRestrictions) -> Self {\n        PlayerModeRestrictions {\n            values: hashmap_into(value.values),\n            special_fields: value.special_fields,\n        }\n    }\n}\n\nimpl From<PlayerRestrictionReasons> for RestrictionReasons {\n    fn from(value: PlayerRestrictionReasons) -> Self {\n        RestrictionReasons {\n            reasons: value.reasons,\n            special_fields: value.special_fields,\n        }\n    }\n}\n\nimpl From<RestrictionReasons> for PlayerRestrictionReasons {\n    fn from(value: RestrictionReasons) -> Self {\n        PlayerRestrictionReasons {\n            reasons: value.reasons,\n            special_fields: value.special_fields,\n        }\n    }\n}\n\nimpl From<PlayOrigin> for PlayerPlayOrigin {\n    fn from(value: PlayOrigin) -> Self {\n        PlayerPlayOrigin {\n            feature_identifier: value.feature_identifier.unwrap_or_default(),\n            feature_version: value.feature_version.unwrap_or_default(),\n            view_uri: value.view_uri.unwrap_or_default(),\n            external_referrer: value.external_referrer.unwrap_or_default(),\n            referrer_identifier: value.referrer_identifier.unwrap_or_default(),\n            device_identifier: value.device_identifier.unwrap_or_default(),\n            feature_classes: value.feature_classes,\n            restriction_identifier: value.restriction_identifier.unwrap_or_default(),\n            special_fields: value.special_fields,\n        }\n    }\n}\n\nimpl From<Suppressions> for PlayerSuppressions {\n    fn from(value: Suppressions) -> Self {\n        PlayerSuppressions {\n            providers: value.providers,\n            special_fields: value.special_fields,\n        }\n    }\n}\n"
  },
  {
    "path": "protocol/src/impl_trait.rs",
    "content": "mod context;\nmod player;\n"
  },
  {
    "path": "protocol/src/lib.rs",
    "content": "// This file is parsed by build.rs\n// Each included module will be compiled from the matching .proto definition.\n\nmod impl_trait;\n\ninclude!(concat!(env!(\"OUT_DIR\"), \"/mod.rs\"));\n"
  },
  {
    "path": "rust-toolchain.toml",
    "content": "[toolchain]\ncomponents = [\"rustfmt\", \"clippy\"]\n"
  },
  {
    "path": "rustfmt.toml",
    "content": "edition = \"2024\"\nstyle_edition = \"2024\"\n"
  },
  {
    "path": "src/lib.rs",
    "content": "#![crate_name = \"librespot\"]\n\npub use librespot_audio as audio;\npub use librespot_connect as connect;\npub use librespot_core as core;\npub use librespot_discovery as discovery;\npub use librespot_metadata as metadata;\npub use librespot_oauth as oauth;\npub use librespot_playback as playback;\npub use librespot_protocol as protocol;\n"
  },
  {
    "path": "src/main.rs",
    "content": "use data_encoding::HEXLOWER;\nuse futures_util::StreamExt;\n#[cfg(feature = \"alsa-backend\")]\nuse librespot::playback::mixer::alsamixer::AlsaMixer;\nuse librespot::{\n    connect::{ConnectConfig, Spirc},\n    core::{\n        Session, SessionConfig, authentication::Credentials, cache::Cache, config::DeviceType,\n        version,\n    },\n    discovery::DnsSdServiceBuilder,\n    playback::{\n        audio_backend::{self, BACKENDS, SinkBuilder},\n        config::{\n            AudioFormat, Bitrate, NormalisationMethod, NormalisationType, PlayerConfig, VolumeCtrl,\n        },\n        dither,\n        mixer::{self, MixerConfig, MixerFn},\n        player::{Player, coefficient_to_duration, duration_to_coefficient},\n    },\n};\nuse librespot_oauth::OAuthClientBuilder;\nuse log::{debug, error, info, trace, warn};\nuse sha1::{Digest, Sha1};\nuse std::{\n    env,\n    ffi::OsStr,\n    fs::create_dir_all,\n    ops::RangeInclusive,\n    path::{Path, PathBuf},\n    pin::Pin,\n    process::exit,\n    str::FromStr,\n    time::{Duration, Instant},\n};\nuse sysinfo::{ProcessesToUpdate, System};\nuse thiserror::Error;\nuse tokio::sync::Semaphore;\nuse url::Url;\n\nmod player_event_handler;\nuse player_event_handler::{EventHandler, run_program_on_sink_events};\n\nfn device_id(name: &str) -> String {\n    HEXLOWER.encode(&Sha1::digest(name.as_bytes()))\n}\n\nfn usage(program: &str, opts: &getopts::Options) -> String {\n    let repo_home = env!(\"CARGO_PKG_REPOSITORY\");\n    let desc = env!(\"CARGO_PKG_DESCRIPTION\");\n    let version = get_version_string();\n    let brief = format!(\"{version}\\n\\n{desc}\\n\\n{repo_home}\\n\\nUsage: {program} [<Options>]\");\n    opts.usage(&brief)\n}\n\nfn setup_logging(quiet: bool, verbose: bool) {\n    let mut builder = env_logger::Builder::new();\n    match env::var(\"RUST_LOG\") {\n        Ok(config) => {\n            builder.parse_filters(&config);\n            builder.init();\n\n            if verbose {\n                warn!(\"`--verbose` flag overidden by `RUST_LOG` environment variable\");\n            } else if quiet {\n                warn!(\"`--quiet` flag overidden by `RUST_LOG` environment variable\");\n            }\n        }\n        Err(_) => {\n            if verbose {\n                builder.parse_filters(\"libmdns=info,librespot=trace\");\n            } else if quiet {\n                builder.parse_filters(\"libmdns=warn,librespot=warn\");\n            } else {\n                builder.parse_filters(\"libmdns=info,librespot=info\");\n            }\n            builder.init();\n\n            if verbose && quiet {\n                warn!(\n                    \"`--verbose` and `--quiet` are mutually exclusive. Logging can not be both verbose and quiet. Using verbose mode.\"\n                );\n            }\n        }\n    }\n}\n\nfn list_backends() {\n    println!(\"Available backends: \");\n    for (&(name, _), idx) in BACKENDS.iter().zip(0..) {\n        if idx == 0 {\n            println!(\"- {name} (default)\");\n        } else {\n            println!(\"- {name}\");\n        }\n    }\n}\n\n#[derive(Debug, Error)]\npub enum ParseFileSizeError {\n    #[error(\"empty argument\")]\n    EmptyInput,\n    #[error(\"invalid suffix\")]\n    InvalidSuffix,\n    #[error(\"invalid number: {0}\")]\n    InvalidNumber(#[from] std::num::ParseFloatError),\n    #[error(\"non-finite number specified\")]\n    NotFinite(f64),\n}\n\npub fn parse_file_size(input: &str) -> Result<u64, ParseFileSizeError> {\n    use ParseFileSizeError::*;\n\n    let mut iter = input.chars();\n    let mut suffix = iter.next_back().ok_or(EmptyInput)?;\n    let mut suffix_len = 0;\n\n    let iec = matches!(suffix, 'i' | 'I');\n\n    if iec {\n        suffix_len += 1;\n        suffix = iter.next_back().ok_or(InvalidSuffix)?;\n    }\n\n    let base: u64 = if iec { 1024 } else { 1000 };\n\n    suffix_len += 1;\n    let exponent = match suffix.to_ascii_uppercase() {\n        '0'..='9' if !iec => {\n            suffix_len -= 1;\n            0\n        }\n        'K' => 1,\n        'M' => 2,\n        'G' => 3,\n        'T' => 4,\n        'P' => 5,\n        'E' => 6,\n        'Z' => 7,\n        'Y' => 8,\n        _ => return Err(InvalidSuffix),\n    };\n\n    let num = {\n        let mut iter = input.chars();\n\n        for _ in (&mut iter).rev().take(suffix_len) {}\n\n        iter.as_str().parse::<f64>()?\n    };\n\n    if !num.is_finite() {\n        return Err(NotFinite(num));\n    }\n\n    Ok((num * base.pow(exponent) as f64) as u64)\n}\n\nfn get_version_string() -> String {\n    #[cfg(debug_assertions)]\n    const BUILD_PROFILE: &str = \"debug\";\n    #[cfg(not(debug_assertions))]\n    const BUILD_PROFILE: &str = \"release\";\n\n    format!(\n        \"librespot {semver} {sha} (Built on {build_date}, Build ID: {build_id}, Profile: {build_profile})\",\n        semver = version::SEMVER,\n        sha = version::SHA_SHORT,\n        build_date = version::BUILD_DATE,\n        build_id = version::BUILD_ID,\n        build_profile = BUILD_PROFILE\n    )\n}\n\n/// Spotify's Desktop app uses these. Some of these are only available when requested with Spotify's client IDs.\nstatic OAUTH_SCOPES: &[&str] = &[\n    //const OAUTH_SCOPES: Vec<&str> = vec![\n    \"app-remote-control\",\n    \"playlist-modify\",\n    \"playlist-modify-private\",\n    \"playlist-modify-public\",\n    \"playlist-read\",\n    \"playlist-read-collaborative\",\n    \"playlist-read-private\",\n    \"streaming\",\n    \"ugc-image-upload\",\n    \"user-follow-modify\",\n    \"user-follow-read\",\n    \"user-library-modify\",\n    \"user-library-read\",\n    \"user-modify\",\n    \"user-modify-playback-state\",\n    \"user-modify-private\",\n    \"user-personalized\",\n    \"user-read-birthdate\",\n    \"user-read-currently-playing\",\n    \"user-read-email\",\n    \"user-read-play-history\",\n    \"user-read-playback-position\",\n    \"user-read-playback-state\",\n    \"user-read-private\",\n    \"user-read-recently-played\",\n    \"user-top-read\",\n];\n\nstruct Setup {\n    format: AudioFormat,\n    backend: SinkBuilder,\n    device: Option<String>,\n    mixer: MixerFn,\n    cache: Option<Cache>,\n    player_config: PlayerConfig,\n    session_config: SessionConfig,\n    connect_config: ConnectConfig,\n    mixer_config: MixerConfig,\n    credentials: Option<Credentials>,\n    enable_oauth: bool,\n    oauth_port: Option<u16>,\n    zeroconf_port: u16,\n    player_event_program: Option<String>,\n    emit_sink_events: bool,\n    zeroconf_ip: Vec<std::net::IpAddr>,\n    zeroconf_backend: Option<DnsSdServiceBuilder>,\n}\n\nasync fn get_setup() -> Setup {\n    const VALID_INITIAL_VOLUME_RANGE: RangeInclusive<u16> = 0..=100;\n    const VALID_VOLUME_RANGE: RangeInclusive<f64> = 0.0..=100.0;\n    const VALID_NORMALISATION_KNEE_RANGE: RangeInclusive<f64> = 0.0..=10.0;\n    const VALID_NORMALISATION_PREGAIN_RANGE: RangeInclusive<f64> = -10.0..=10.0;\n    const VALID_NORMALISATION_THRESHOLD_RANGE: RangeInclusive<f64> = -10.0..=0.0;\n    const VALID_NORMALISATION_ATTACK_RANGE: RangeInclusive<u64> = 1..=500;\n    const VALID_NORMALISATION_RELEASE_RANGE: RangeInclusive<u64> = 1..=1000;\n\n    const ACCESS_TOKEN: &str = \"access-token\";\n    const AP_PORT: &str = \"ap-port\";\n    const AUTOPLAY: &str = \"autoplay\";\n    const BACKEND: &str = \"backend\";\n    const BITRATE: &str = \"bitrate\";\n    const CACHE: &str = \"cache\";\n    const CACHE_SIZE_LIMIT: &str = \"cache-size-limit\";\n    const DEVICE: &str = \"device\";\n    const DEVICE_TYPE: &str = \"device-type\";\n    const DEVICE_IS_GROUP: &str = \"group\";\n    const DISABLE_AUDIO_CACHE: &str = \"disable-audio-cache\";\n    const DISABLE_CREDENTIAL_CACHE: &str = \"disable-credential-cache\";\n    const DISABLE_DISCOVERY: &str = \"disable-discovery\";\n    const DISABLE_GAPLESS: &str = \"disable-gapless\";\n    const DITHER: &str = \"dither\";\n    const EMIT_SINK_EVENTS: &str = \"emit-sink-events\";\n    const ENABLE_OAUTH: &str = \"enable-oauth\";\n    const ENABLE_VOLUME_NORMALISATION: &str = \"enable-volume-normalisation\";\n    const FORMAT: &str = \"format\";\n    const HELP: &str = \"help\";\n    const INITIAL_VOLUME: &str = \"initial-volume\";\n    const MIXER_TYPE: &str = \"mixer\";\n    const ALSA_MIXER_DEVICE: &str = \"alsa-mixer-device\";\n    const ALSA_MIXER_INDEX: &str = \"alsa-mixer-index\";\n    const ALSA_MIXER_CONTROL: &str = \"alsa-mixer-control\";\n    const NAME: &str = \"name\";\n    const NORMALISATION_ATTACK: &str = \"normalisation-attack\";\n    const NORMALISATION_GAIN_TYPE: &str = \"normalisation-gain-type\";\n    const NORMALISATION_KNEE: &str = \"normalisation-knee\";\n    const NORMALISATION_METHOD: &str = \"normalisation-method\";\n    const NORMALISATION_PREGAIN: &str = \"normalisation-pregain\";\n    const NORMALISATION_RELEASE: &str = \"normalisation-release\";\n    const NORMALISATION_THRESHOLD: &str = \"normalisation-threshold\";\n    const OAUTH_PORT: &str = \"oauth-port\";\n    const ONEVENT: &str = \"onevent\";\n    #[cfg(feature = \"passthrough-decoder\")]\n    const PASSTHROUGH: &str = \"passthrough\";\n    const PASSWORD: &str = \"password\";\n    const PROXY: &str = \"proxy\";\n    const QUIET: &str = \"quiet\";\n    const SYSTEM_CACHE: &str = \"system-cache\";\n    const TEMP_DIR: &str = \"tmp\";\n    const USERNAME: &str = \"username\";\n    const VERBOSE: &str = \"verbose\";\n    const VERSION: &str = \"version\";\n    const VOLUME_CTRL: &str = \"volume-ctrl\";\n    const VOLUME_RANGE: &str = \"volume-range\";\n    const VOLUME_STEPS: &str = \"volume-steps\";\n    const ZEROCONF_PORT: &str = \"zeroconf-port\";\n    const ZEROCONF_INTERFACE: &str = \"zeroconf-interface\";\n    const ZEROCONF_BACKEND: &str = \"zeroconf-backend\";\n    const LOCAL_FILE_DIR: &str = \"local-file-dir\";\n\n    // Mostly arbitrary.\n    const AP_PORT_SHORT: &str = \"a\";\n    const AUTOPLAY_SHORT: &str = \"A\";\n    const BACKEND_SHORT: &str = \"B\";\n    const BITRATE_SHORT: &str = \"b\";\n    const SYSTEM_CACHE_SHORT: &str = \"C\";\n    const CACHE_SHORT: &str = \"c\";\n    const DITHER_SHORT: &str = \"D\";\n    const DEVICE_SHORT: &str = \"d\";\n    const VOLUME_CTRL_SHORT: &str = \"E\";\n    const VOLUME_RANGE_SHORT: &str = \"e\";\n    const VOLUME_STEPS_SHORT: &str = \"\"; // no short flag\n    const DEVICE_TYPE_SHORT: &str = \"F\";\n    const FORMAT_SHORT: &str = \"f\";\n    const DISABLE_AUDIO_CACHE_SHORT: &str = \"G\";\n    const DISABLE_GAPLESS_SHORT: &str = \"g\";\n    const DISABLE_CREDENTIAL_CACHE_SHORT: &str = \"H\";\n    const HELP_SHORT: &str = \"h\";\n    const ZEROCONF_INTERFACE_SHORT: &str = \"i\";\n    const ENABLE_OAUTH_SHORT: &str = \"j\";\n    const OAUTH_PORT_SHORT: &str = \"K\";\n    const ACCESS_TOKEN_SHORT: &str = \"k\";\n    const CACHE_SIZE_LIMIT_SHORT: &str = \"M\";\n    const MIXER_TYPE_SHORT: &str = \"m\";\n    const ENABLE_VOLUME_NORMALISATION_SHORT: &str = \"N\";\n    const NAME_SHORT: &str = \"n\";\n    const DISABLE_DISCOVERY_SHORT: &str = \"O\";\n    const ONEVENT_SHORT: &str = \"o\";\n    #[cfg(feature = \"passthrough-decoder\")]\n    const PASSTHROUGH_SHORT: &str = \"P\";\n    const PASSWORD_SHORT: &str = \"p\";\n    const EMIT_SINK_EVENTS_SHORT: &str = \"Q\";\n    const QUIET_SHORT: &str = \"q\";\n    const INITIAL_VOLUME_SHORT: &str = \"R\";\n    const ALSA_MIXER_DEVICE_SHORT: &str = \"S\";\n    const ALSA_MIXER_INDEX_SHORT: &str = \"s\";\n    const ALSA_MIXER_CONTROL_SHORT: &str = \"T\";\n    const TEMP_DIR_SHORT: &str = \"t\";\n    const NORMALISATION_ATTACK_SHORT: &str = \"U\";\n    const USERNAME_SHORT: &str = \"u\";\n    const VERSION_SHORT: &str = \"V\";\n    const VERBOSE_SHORT: &str = \"v\";\n    const NORMALISATION_GAIN_TYPE_SHORT: &str = \"W\";\n    const NORMALISATION_KNEE_SHORT: &str = \"w\";\n    const NORMALISATION_METHOD_SHORT: &str = \"X\";\n    const PROXY_SHORT: &str = \"x\";\n    const NORMALISATION_PREGAIN_SHORT: &str = \"Y\";\n    const NORMALISATION_RELEASE_SHORT: &str = \"y\";\n    const NORMALISATION_THRESHOLD_SHORT: &str = \"Z\";\n    const ZEROCONF_PORT_SHORT: &str = \"z\";\n    const ZEROCONF_BACKEND_SHORT: &str = \"\"; // no short flag\n    const LOCAL_FILE_DIR_SHORT: &str = \"l\";\n\n    // Options that have different descriptions\n    // depending on what backends were enabled at build time.\n    #[cfg(feature = \"alsa-backend\")]\n    const MIXER_TYPE_DESC: &str = \"Mixer to use {alsa|softvol}. Defaults to softvol.\";\n    #[cfg(not(feature = \"alsa-backend\"))]\n    const MIXER_TYPE_DESC: &str = \"Not supported by the included audio backend(s).\";\n    #[cfg(any(\n        feature = \"alsa-backend\",\n        feature = \"rodio-backend\",\n        feature = \"portaudio-backend\"\n    ))]\n    const DEVICE_DESC: &str = \"Audio device to use. Use ? to list options if using alsa, portaudio or rodio. Defaults to the backend's default.\";\n    #[cfg(not(any(\n        feature = \"alsa-backend\",\n        feature = \"rodio-backend\",\n        feature = \"portaudio-backend\"\n    )))]\n    const DEVICE_DESC: &str = \"Not supported by the included audio backend(s).\";\n    #[cfg(feature = \"alsa-backend\")]\n    const ALSA_MIXER_CONTROL_DESC: &str =\n        \"Alsa mixer control, e.g. PCM, Master or similar. Defaults to PCM.\";\n    #[cfg(not(feature = \"alsa-backend\"))]\n    const ALSA_MIXER_CONTROL_DESC: &str = \"Not supported by the included audio backend(s).\";\n    #[cfg(feature = \"alsa-backend\")]\n    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.\";\n    #[cfg(not(feature = \"alsa-backend\"))]\n    const ALSA_MIXER_DEVICE_DESC: &str = \"Not supported by the included audio backend(s).\";\n    #[cfg(feature = \"alsa-backend\")]\n    const ALSA_MIXER_INDEX_DESC: &str = \"Alsa index of the cards mixer. Defaults to 0.\";\n    #[cfg(not(feature = \"alsa-backend\"))]\n    const ALSA_MIXER_INDEX_DESC: &str = \"Not supported by the included audio backend(s).\";\n    #[cfg(feature = \"alsa-backend\")]\n    const INITIAL_VOLUME_DESC: &str = \"Initial volume in % from 0 - 100. Default for softvol: 50. For the alsa mixer: the current volume.\";\n    #[cfg(not(feature = \"alsa-backend\"))]\n    const INITIAL_VOLUME_DESC: &str = \"Initial volume in % from 0 - 100. Defaults to 50.\";\n    #[cfg(feature = \"alsa-backend\")]\n    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.\";\n    #[cfg(not(feature = \"alsa-backend\"))]\n    const VOLUME_RANGE_DESC: &str =\n        \"Range of the volume control (dB) from 0.0 to 100.0. Defaults to 60.0.\";\n    const VOLUME_STEPS_DESC: &str =\n        \"Number of incremental steps when responding to volume control updates. Defaults to 64.\";\n\n    let mut opts = getopts::Options::new();\n    opts.optflag(\n        HELP_SHORT,\n        HELP,\n        \"Print this help menu.\",\n    )\n    .optflag(\n        VERSION_SHORT,\n        VERSION,\n        \"Display librespot version string.\",\n    )\n    .optflag(\n        VERBOSE_SHORT,\n        VERBOSE,\n        \"Enable verbose log output.\",\n    )\n    .optflag(\n        QUIET_SHORT,\n        QUIET,\n        \"Only log warning and error messages.\",\n    )\n    .optflag(\n        DISABLE_AUDIO_CACHE_SHORT,\n        DISABLE_AUDIO_CACHE,\n        \"Disable caching of the audio data.\",\n    )\n    .optflag(\n        DISABLE_CREDENTIAL_CACHE_SHORT,\n        DISABLE_CREDENTIAL_CACHE,\n        \"Disable caching of credentials.\",\n    )\n    .optflag(\n        DISABLE_DISCOVERY_SHORT,\n        DISABLE_DISCOVERY,\n        \"Disable zeroconf discovery mode.\",\n    )\n    .optflag(\n        DISABLE_GAPLESS_SHORT,\n        DISABLE_GAPLESS,\n        \"Disable gapless playback.\",\n    )\n    .optflag(\n        EMIT_SINK_EVENTS_SHORT,\n        EMIT_SINK_EVENTS,\n        \"Run PROGRAM set by `--onevent` before the sink is opened and after it is closed.\",\n    )\n    .optflag(\n        ENABLE_VOLUME_NORMALISATION_SHORT,\n        ENABLE_VOLUME_NORMALISATION,\n        \"Play all tracks at approximately the same apparent volume.\",\n    )\n    .optflag(\n        ENABLE_OAUTH_SHORT,\n        ENABLE_OAUTH,\n        \"Perform interactive OAuth sign in.\",\n    )\n    .optopt(\n        NAME_SHORT,\n        NAME,\n        \"Device name. Defaults to Librespot.\",\n        \"NAME\",\n    )\n    .optopt(\n        BITRATE_SHORT,\n        BITRATE,\n        \"Bitrate (kbps) {96|160|320}. Defaults to 160.\",\n        \"BITRATE\",\n    )\n    .optopt(\n        FORMAT_SHORT,\n        FORMAT,\n        \"Output format {F64|F32|S32|S24|S24_3|S16}. Defaults to S16.\",\n        \"FORMAT\",\n    )\n    .optopt(\n        DITHER_SHORT,\n        DITHER,\n        \"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.\",\n        \"DITHER\",\n    )\n    .optopt(\n        DEVICE_TYPE_SHORT,\n        DEVICE_TYPE,\n        \"Displayed device type. Defaults to speaker.\",\n        \"TYPE\",\n    ).optflag(\n        \"\",\n        DEVICE_IS_GROUP,\n        \"Whether the device represents a group. Defaults to false.\",\n    )\n    .optopt(\n        TEMP_DIR_SHORT,\n        TEMP_DIR,\n        \"Path to a directory where files will be temporarily stored while downloading.\",\n        \"PATH\",\n    )\n    .optopt(\n        CACHE_SHORT,\n        CACHE,\n        \"Path to a directory where files will be cached after downloading.\",\n        \"PATH\",\n    )\n    .optopt(\n        SYSTEM_CACHE_SHORT,\n        SYSTEM_CACHE,\n        \"Path to a directory where system files (credentials, volume) will be cached. May be different from the `--cache` option value.\",\n        \"PATH\",\n    )\n    .optopt(\n        CACHE_SIZE_LIMIT_SHORT,\n        CACHE_SIZE_LIMIT,\n        \"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.\",\n        \"SIZE\"\n    )\n    .optopt(\n        BACKEND_SHORT,\n        BACKEND,\n        \"Audio backend to use. Use ? to list options.\",\n        \"NAME\",\n    )\n    .optopt(\n        USERNAME_SHORT,\n        USERNAME,\n        \"Username used to sign in with.\",\n        \"USERNAME\",\n    )\n    .optopt(\n        PASSWORD_SHORT,\n        PASSWORD,\n        \"Password used to sign in with.\",\n        \"PASSWORD\",\n    )\n    .optopt(\n        ACCESS_TOKEN_SHORT,\n        ACCESS_TOKEN,\n        \"Spotify access token to sign in with.\",\n        \"TOKEN\",\n    )\n    .optopt(\n        OAUTH_PORT_SHORT,\n        OAUTH_PORT,\n        \"The port the oauth redirect server uses 1 - 65535. Ports <= 1024 may require root privileges.\",\n        \"PORT\",\n    )\n    .optopt(\n        ONEVENT_SHORT,\n        ONEVENT,\n        \"Run PROGRAM when a playback event occurs.\",\n        \"PROGRAM\",\n    )\n    .optopt(\n        ALSA_MIXER_CONTROL_SHORT,\n        ALSA_MIXER_CONTROL,\n        ALSA_MIXER_CONTROL_DESC,\n        \"NAME\",\n    )\n    .optopt(\n        ALSA_MIXER_DEVICE_SHORT,\n        ALSA_MIXER_DEVICE,\n        ALSA_MIXER_DEVICE_DESC,\n        \"DEVICE\",\n    )\n    .optopt(\n        ALSA_MIXER_INDEX_SHORT,\n        ALSA_MIXER_INDEX,\n        ALSA_MIXER_INDEX_DESC,\n        \"NUMBER\",\n    )\n    .optopt(\n        MIXER_TYPE_SHORT,\n        MIXER_TYPE,\n        MIXER_TYPE_DESC,\n        \"MIXER\",\n    )\n    .optopt(\n        DEVICE_SHORT,\n        DEVICE,\n        DEVICE_DESC,\n        \"NAME\",\n    )\n    .optopt(\n        INITIAL_VOLUME_SHORT,\n        INITIAL_VOLUME,\n        INITIAL_VOLUME_DESC,\n        \"VOLUME\",\n    )\n    .optopt(\n        VOLUME_CTRL_SHORT,\n        VOLUME_CTRL,\n        \"Volume control scale type {cubic|fixed|linear|log}. Defaults to log.\",\n        \"VOLUME_CTRL\"\n    )\n    .optopt(\n        VOLUME_RANGE_SHORT,\n        VOLUME_RANGE,\n        VOLUME_RANGE_DESC,\n        \"RANGE\",\n    )\n    .optopt(\n        VOLUME_STEPS_SHORT,\n        VOLUME_STEPS,\n        VOLUME_STEPS_DESC,\n        \"STEPS\",\n    )\n    .optopt(\n        NORMALISATION_METHOD_SHORT,\n        NORMALISATION_METHOD,\n        \"Specify the normalisation method to use {basic|dynamic}. Defaults to dynamic.\",\n        \"METHOD\",\n    )\n    .optopt(\n        NORMALISATION_GAIN_TYPE_SHORT,\n        NORMALISATION_GAIN_TYPE,\n        \"Specify the normalisation gain type to use {track|album|auto}. Defaults to auto.\",\n        \"TYPE\",\n    )\n    .optopt(\n        NORMALISATION_PREGAIN_SHORT,\n        NORMALISATION_PREGAIN,\n        \"Pregain (dB) applied by volume normalisation from -10.0 to 10.0. Defaults to 0.0.\",\n        \"PREGAIN\",\n    )\n    .optopt(\n        NORMALISATION_THRESHOLD_SHORT,\n        NORMALISATION_THRESHOLD,\n        \"Threshold (dBFS) at which point the dynamic limiter engages to prevent clipping from 0.0 to -10.0. Defaults to -2.0.\",\n        \"THRESHOLD\",\n    )\n    .optopt(\n        NORMALISATION_ATTACK_SHORT,\n        NORMALISATION_ATTACK,\n        \"Attack time (ms) in which the dynamic limiter reduces gain from 1 to 500. Defaults to 5.\",\n        \"TIME\",\n    )\n    .optopt(\n        NORMALISATION_RELEASE_SHORT,\n        NORMALISATION_RELEASE,\n        \"Release or decay time (ms) in which the dynamic limiter restores gain from 1 to 1000. Defaults to 100.\",\n        \"TIME\",\n    )\n    .optopt(\n        NORMALISATION_KNEE_SHORT,\n        NORMALISATION_KNEE,\n        \"Knee width (dB) of the dynamic limiter from 0.0 to 10.0. Defaults to 5.0.\",\n        \"KNEE\",\n    )\n    .optopt(\n        ZEROCONF_PORT_SHORT,\n        ZEROCONF_PORT,\n        \"The port the internal server advertises over zeroconf 1 - 65535. Ports <= 1024 may require root privileges.\",\n        \"PORT\",\n    )\n    .optopt(\n        PROXY_SHORT,\n        PROXY,\n        \"HTTP proxy to use when connecting.\",\n        \"URL\",\n    )\n    .optopt(\n        AP_PORT_SHORT,\n        AP_PORT,\n        \"Connect to an AP with a specified port 1 - 65535. Available ports are usually 80, 443 and 4070.\",\n        \"PORT\",\n    )\n    .optopt(\n        AUTOPLAY_SHORT,\n        AUTOPLAY,\n        \"Explicitly set autoplay {on|off}. Defaults to following the client setting.\",\n        \"OVERRIDE\",\n    )\n    .optopt(\n        ZEROCONF_INTERFACE_SHORT,\n        ZEROCONF_INTERFACE,\n        \"Comma-separated interface IP addresses on which zeroconf will bind. Defaults to all interfaces. Ignored by DNS-SD.\",\n        \"IP\"\n    )\n    .optopt(\n        ZEROCONF_BACKEND_SHORT,\n        ZEROCONF_BACKEND,\n        \"Zeroconf (MDNS/DNS-SD) backend to use. Valid values are 'avahi', 'dns-sd' and 'libmdns', if librespot is compiled with the corresponding feature flags.\",\n        \"BACKEND\"\n    ).optmulti(\n        LOCAL_FILE_DIR_SHORT,\n        LOCAL_FILE_DIR,\n        \"Directory to search for local file playback. Can be specified multiple times to add multiple search directories\",\n        \"DIRECTORY\"\n    );\n\n    #[cfg(feature = \"passthrough-decoder\")]\n    opts.optflag(\n        PASSTHROUGH_SHORT,\n        PASSTHROUGH,\n        \"Pass a raw stream to the output. Only works with the pipe and subprocess backends.\",\n    );\n\n    let args: Vec<_> = std::env::args_os()\n        .filter_map(|s| match s.into_string() {\n            Ok(valid) => Some(valid),\n            Err(s) => {\n                eprintln!(\n                    \"Command line argument was not valid Unicode and will not be evaluated: {s:?}\"\n                );\n                None\n            }\n        })\n        .collect();\n\n    let matches = match opts.parse(&args[1..]) {\n        Ok(m) => m,\n        Err(e) => {\n            eprintln!(\"Error parsing command line options: {e}\");\n            println!(\"\\n{}\", usage(&args[0], &opts));\n            exit(1);\n        }\n    };\n\n    let stripped_env_key = |k: &str| {\n        k.trim_start_matches(\"LIBRESPOT_\")\n            .replace('_', \"-\")\n            .to_lowercase()\n    };\n\n    let env_vars: Vec<_> = env::vars_os().filter_map(|(k, v)| match k.into_string() {\n        Ok(key) if key.starts_with(\"LIBRESPOT_\") => {\n            let stripped_key = stripped_env_key(&key);\n            // We only care about long option/flag names.\n            if stripped_key.chars().count() > 1 && matches.opt_defined(&stripped_key) {\n                match v.into_string() {\n                    Ok(value) => Some((key, value)),\n                    Err(s) => {\n                        eprintln!(\"Environment variable was not valid Unicode and will not be evaluated: {key}={s:?}\");\n                        None\n                    }\n                }\n            } else {\n                None\n            }\n        },\n        _ => None\n    })\n    .collect();\n\n    let opt_present =\n        |opt| matches.opt_present(opt) || env_vars.iter().any(|(k, _)| stripped_env_key(k) == opt);\n\n    let opt_str = |opt| {\n        if matches.opt_present(opt) {\n            matches.opt_str(opt)\n        } else {\n            env_vars\n                .iter()\n                .find(|(k, _)| stripped_env_key(k) == opt)\n                .map(|(_, v)| v.to_string())\n        }\n    };\n\n    if opt_present(HELP) {\n        println!(\"{}\", usage(&args[0], &opts));\n        exit(0);\n    }\n\n    if opt_present(VERSION) {\n        println!(\"{}\", get_version_string());\n        exit(0);\n    }\n\n    setup_logging(opt_present(QUIET), opt_present(VERBOSE));\n\n    info!(\"{}\", get_version_string());\n\n    if !env_vars.is_empty() {\n        trace!(\"Environment variable(s):\");\n\n        for (k, v) in &env_vars {\n            if matches!(\n                k.as_str(),\n                \"LIBRESPOT_PASSWORD\" | \"LIBRESPOT_USERNAME\" | \"LIBRESPOT_ACCESS_TOKEN\"\n            ) {\n                trace!(\"\\t\\t{k}=\\\"XXXXXXXX\\\"\");\n            } else if v.is_empty() {\n                trace!(\"\\t\\t{k}=\");\n            } else {\n                trace!(\"\\t\\t{k}=\\\"{v}\\\"\");\n            }\n        }\n    }\n\n    let args_len = args.len();\n\n    if args_len > 1 {\n        trace!(\"Command line argument(s):\");\n\n        for (index, key) in args.iter().enumerate() {\n            let opt = {\n                let key = key.trim_start_matches('-');\n\n                if let Some((s, _)) = key.split_once('=') {\n                    s\n                } else {\n                    key\n                }\n            };\n\n            if index > 0\n                && key.starts_with('-')\n                && &args[index - 1] != key\n                && matches.opt_defined(opt)\n                && matches.opt_present(opt)\n            {\n                if matches!(\n                    opt,\n                    PASSWORD\n                        | PASSWORD_SHORT\n                        | USERNAME\n                        | USERNAME_SHORT\n                        | ACCESS_TOKEN\n                        | ACCESS_TOKEN_SHORT\n                ) {\n                    // Don't log creds.\n                    trace!(\"\\t\\t{opt} \\\"XXXXXXXX\\\"\");\n                } else {\n                    let value = matches.opt_str(opt).unwrap_or_default();\n                    if value.is_empty() {\n                        trace!(\"\\t\\t{opt}\");\n                    } else {\n                        trace!(\"\\t\\t{opt} \\\"{value}\\\"\");\n                    }\n                }\n            }\n        }\n    }\n\n    #[cfg(not(feature = \"alsa-backend\"))]\n    for a in &[\n        MIXER_TYPE,\n        ALSA_MIXER_DEVICE,\n        ALSA_MIXER_INDEX,\n        ALSA_MIXER_CONTROL,\n    ] {\n        if opt_present(a) {\n            warn!(\n                \"Alsa specific options have no effect if the alsa backend is not enabled at build time.\"\n            );\n            break;\n        }\n    }\n\n    let backend_name = opt_str(BACKEND);\n    if backend_name == Some(\"?\".into()) {\n        list_backends();\n        exit(0);\n    }\n\n    // Can't use `-> fmt::Arguments` due to https://github.com/rust-lang/rust/issues/92698\n    fn format_flag(long: &str, short: &str) -> String {\n        if short.is_empty() {\n            format!(\"`--{long}`\")\n        } else {\n            format!(\"`--{long}` / `-{short}`\")\n        }\n    }\n\n    let invalid_error_msg =\n        |long: &str, short: &str, invalid: &str, valid_values: &str, default_value: &str| {\n            let flag = format_flag(long, short);\n            error!(\"Invalid {flag}: \\\"{invalid}\\\"\");\n\n            if !valid_values.is_empty() {\n                println!(\"Valid {flag} values: {valid_values}\");\n            }\n\n            if !default_value.is_empty() {\n                println!(\"Default: {default_value}\");\n            }\n        };\n\n    let empty_string_error_msg = |long: &str, short: &str| {\n        error!(\"`--{long}` / `-{short}` can not be an empty string\");\n        exit(1);\n    };\n\n    let backend = audio_backend::find(backend_name).unwrap_or_else(|| {\n        invalid_error_msg(\n            BACKEND,\n            BACKEND_SHORT,\n            &opt_str(BACKEND).unwrap_or_default(),\n            \"\",\n            \"\",\n        );\n\n        list_backends();\n        exit(1);\n    });\n\n    let format = opt_str(FORMAT)\n        .as_deref()\n        .map(|format| {\n            AudioFormat::from_str(format).unwrap_or_else(|_| {\n                let default_value = &format!(\"{:?}\", AudioFormat::default());\n                invalid_error_msg(\n                    FORMAT,\n                    FORMAT_SHORT,\n                    format,\n                    \"F64, F32, S32, S24, S24_3, S16\",\n                    default_value,\n                );\n\n                exit(1);\n            })\n        })\n        .unwrap_or_default();\n\n    let device = opt_str(DEVICE);\n    if let Some(ref value) = device {\n        if value == \"?\" {\n            backend(device, format);\n            exit(0);\n        } else if value.is_empty() {\n            empty_string_error_msg(DEVICE, DEVICE_SHORT);\n        }\n    }\n\n    #[cfg(feature = \"alsa-backend\")]\n    let mixer_type = opt_str(MIXER_TYPE);\n    #[cfg(not(feature = \"alsa-backend\"))]\n    let mixer_type: Option<String> = None;\n\n    let mixer = mixer::find(mixer_type.as_deref()).unwrap_or_else(|| {\n        invalid_error_msg(\n            MIXER_TYPE,\n            MIXER_TYPE_SHORT,\n            &opt_str(MIXER_TYPE).unwrap_or_default(),\n            \"alsa, softvol\",\n            \"softvol\",\n        );\n\n        exit(1);\n    });\n\n    let is_alsa_mixer = match mixer_type.as_deref() {\n        #[cfg(feature = \"alsa-backend\")]\n        Some(AlsaMixer::NAME) => true,\n        _ => false,\n    };\n\n    #[cfg(feature = \"alsa-backend\")]\n    if !is_alsa_mixer {\n        for a in &[ALSA_MIXER_DEVICE, ALSA_MIXER_INDEX, ALSA_MIXER_CONTROL] {\n            if opt_present(a) {\n                warn!(\"Alsa specific mixer options have no effect if not using the alsa mixer.\");\n                break;\n            }\n        }\n    }\n\n    let mixer_config = {\n        let mixer_default_config = MixerConfig::default();\n\n        #[cfg(feature = \"alsa-backend\")]\n        let index = if !is_alsa_mixer {\n            mixer_default_config.index\n        } else {\n            opt_str(ALSA_MIXER_INDEX)\n                .map(|index| {\n                    index.parse::<u32>().unwrap_or_else(|_| {\n                        invalid_error_msg(\n                            ALSA_MIXER_INDEX,\n                            ALSA_MIXER_INDEX_SHORT,\n                            &index,\n                            \"\",\n                            &mixer_default_config.index.to_string(),\n                        );\n\n                        exit(1);\n                    })\n                })\n                .unwrap_or_else(|| match device {\n                    // Look for the dev index portion of --device.\n                    // Specifically <dev index> when --device is <something>:CARD=<card name>,DEV=<dev index>\n                    // or <something>:<card index>,<dev index>.\n\n                    // If --device does not contain a ',' it does not contain a dev index.\n                    // In the case that the dev index is omitted it is assumed to be 0 (mixer_default_config.index).\n                    // Malformed --device values will also fallback to mixer_default_config.index.\n                    Some(ref device_name) if device_name.contains(',') => {\n                        // Turn <something>:CARD=<card name>,DEV=<dev index> or <something>:<card index>,<dev index>\n                        // into DEV=<dev index> or <dev index>.\n                        let dev = &device_name[device_name.find(',').unwrap_or_default()..]\n                            .trim_start_matches(',');\n\n                        // Turn DEV=<dev index> into <dev index> (noop if it's already <dev index>)\n                        // and then parse <dev index>.\n                        // Malformed --device values will fail the parse and fallback to mixer_default_config.index.\n                        dev[dev.find('=').unwrap_or_default()..]\n                            .trim_start_matches('=')\n                            .parse::<u32>()\n                            .unwrap_or(mixer_default_config.index)\n                    }\n                    _ => mixer_default_config.index,\n                })\n        };\n\n        #[cfg(not(feature = \"alsa-backend\"))]\n        let index = mixer_default_config.index;\n\n        #[cfg(feature = \"alsa-backend\")]\n        let device = if !is_alsa_mixer {\n            mixer_default_config.device\n        } else {\n            match opt_str(ALSA_MIXER_DEVICE) {\n                Some(mixer_device) => {\n                    if mixer_device.is_empty() {\n                        empty_string_error_msg(ALSA_MIXER_DEVICE, ALSA_MIXER_DEVICE_SHORT);\n                    }\n\n                    mixer_device\n                }\n                None => match device {\n                    Some(ref device_name) => {\n                        // Look for the card name or card index portion of --device.\n                        // Specifically <card name> when --device is <something>:CARD=<card name>,DEV=<dev index>\n                        // or card index when --device is <something>:<card index>,<dev index>.\n                        // --device values like `pulse`, `default`, `jack` may be valid but there is no way to\n                        // infer automatically what the mixer should be so they fail auto fallback\n                        // so --alsa-mixer-device must be manually specified in those situations.\n                        let start_index = device_name.find(':').unwrap_or_default();\n\n                        let end_index = match device_name.find(',') {\n                            Some(index) if index > start_index => index,\n                            _ => device_name.len(),\n                        };\n\n                        let card = &device_name[start_index..end_index];\n\n                        if card.starts_with(':') {\n                            // mixers are assumed to be hw:CARD=<card name> or hw:<card index>.\n                            \"hw\".to_owned() + card\n                        } else {\n                            error!(\n                                \"Could not find an alsa mixer for \\\"{}\\\", it must be specified with `--{}` / `-{}`\",\n                                &device.unwrap_or_default(),\n                                ALSA_MIXER_DEVICE,\n                                ALSA_MIXER_DEVICE_SHORT\n                            );\n\n                            exit(1);\n                        }\n                    }\n                    None => {\n                        error!(\n                            \"`--{}` / `-{}` or `--{}` / `-{}` \\\n                            must be specified when `--{}` / `-{}` is set to \\\"alsa\\\"\",\n                            DEVICE,\n                            DEVICE_SHORT,\n                            ALSA_MIXER_DEVICE,\n                            ALSA_MIXER_DEVICE_SHORT,\n                            MIXER_TYPE,\n                            MIXER_TYPE_SHORT\n                        );\n\n                        exit(1);\n                    }\n                },\n            }\n        };\n\n        #[cfg(not(feature = \"alsa-backend\"))]\n        let device = mixer_default_config.device;\n\n        #[cfg(feature = \"alsa-backend\")]\n        let control = opt_str(ALSA_MIXER_CONTROL).unwrap_or(mixer_default_config.control);\n\n        #[cfg(feature = \"alsa-backend\")]\n        if control.is_empty() {\n            empty_string_error_msg(ALSA_MIXER_CONTROL, ALSA_MIXER_CONTROL_SHORT);\n        }\n\n        #[cfg(not(feature = \"alsa-backend\"))]\n        let control = mixer_default_config.control;\n\n        let volume_range = opt_str(VOLUME_RANGE)\n            .map(|range| match range.parse::<f64>() {\n                Ok(value) if (VALID_VOLUME_RANGE).contains(&value) => value,\n                _ => {\n                    let valid_values = &format!(\n                        \"{} - {}\",\n                        VALID_VOLUME_RANGE.start(),\n                        VALID_VOLUME_RANGE.end()\n                    );\n\n                    #[cfg(feature = \"alsa-backend\")]\n                    let default_value = &format!(\n                        \"softvol - {}, alsa - what the control supports\",\n                        VolumeCtrl::DEFAULT_DB_RANGE\n                    );\n\n                    #[cfg(not(feature = \"alsa-backend\"))]\n                    let default_value = &VolumeCtrl::DEFAULT_DB_RANGE.to_string();\n\n                    invalid_error_msg(\n                        VOLUME_RANGE,\n                        VOLUME_RANGE_SHORT,\n                        &range,\n                        valid_values,\n                        default_value,\n                    );\n\n                    exit(1);\n                }\n            })\n            .unwrap_or_else(|| {\n                if is_alsa_mixer {\n                    0.0\n                } else {\n                    VolumeCtrl::DEFAULT_DB_RANGE\n                }\n            });\n\n        let volume_ctrl = opt_str(VOLUME_CTRL)\n            .as_deref()\n            .map(|volume_ctrl| {\n                VolumeCtrl::from_str_with_range(volume_ctrl, volume_range).unwrap_or_else(|_| {\n                    invalid_error_msg(\n                        VOLUME_CTRL,\n                        VOLUME_CTRL_SHORT,\n                        volume_ctrl,\n                        \"cubic, fixed, linear, log\",\n                        \"log\",\n                    );\n\n                    exit(1);\n                })\n            })\n            .unwrap_or_else(|| VolumeCtrl::Log(volume_range));\n\n        MixerConfig {\n            device,\n            control,\n            index,\n            volume_ctrl,\n        }\n    };\n\n    let tmp_dir = opt_str(TEMP_DIR).map_or(SessionConfig::default().tmp_dir, |p| {\n        let tmp_dir = PathBuf::from(p);\n        if let Err(e) = create_dir_all(&tmp_dir) {\n            error!(\"could not create or access specified tmp directory: {e}\");\n            exit(1);\n        }\n        tmp_dir\n    });\n\n    let enable_oauth = opt_present(ENABLE_OAUTH);\n\n    let cache = {\n        let volume_dir = opt_str(SYSTEM_CACHE)\n            .or_else(|| opt_str(CACHE))\n            .map(Into::into);\n\n        let cred_dir = if opt_present(DISABLE_CREDENTIAL_CACHE) {\n            None\n        } else {\n            volume_dir.clone()\n        };\n\n        let audio_dir = if opt_present(DISABLE_AUDIO_CACHE) {\n            None\n        } else {\n            opt_str(CACHE)\n                .as_ref()\n                .map(|p| AsRef::<Path>::as_ref(p).join(\"files\"))\n        };\n\n        let limit = if audio_dir.is_some() {\n            opt_str(CACHE_SIZE_LIMIT)\n                .as_deref()\n                .map(parse_file_size)\n                .map(|e| {\n                    e.unwrap_or_else(|e| {\n                        invalid_error_msg(\n                            CACHE_SIZE_LIMIT,\n                            CACHE_SIZE_LIMIT_SHORT,\n                            &e.to_string(),\n                            \"\",\n                            \"\",\n                        );\n\n                        exit(1);\n                    })\n                })\n        } else {\n            None\n        };\n\n        if audio_dir.is_none() && opt_present(CACHE_SIZE_LIMIT) {\n            warn!(\n                \"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.\"\n            );\n        }\n\n        let cache = match Cache::new(cred_dir.clone(), volume_dir, audio_dir, limit) {\n            Ok(cache) => Some(cache),\n            Err(e) => {\n                warn!(\"Cannot create cache: {e}\");\n                None\n            }\n        };\n\n        if enable_oauth && (cache.is_none() || cred_dir.is_none()) {\n            warn!(\"Credential caching is unavailable, but advisable when using OAuth login.\");\n        }\n\n        cache\n    };\n\n    let credentials = {\n        let cached_creds = cache.as_ref().and_then(Cache::credentials);\n\n        if let Some(access_token) = opt_str(ACCESS_TOKEN) {\n            if access_token.is_empty() {\n                empty_string_error_msg(ACCESS_TOKEN, ACCESS_TOKEN_SHORT);\n            }\n            Some(Credentials::with_access_token(access_token))\n        } else if let Some(username) = opt_str(USERNAME) {\n            if username.is_empty() {\n                empty_string_error_msg(USERNAME, USERNAME_SHORT);\n            }\n            if opt_present(PASSWORD) {\n                error!(\n                    \"Invalid `--{PASSWORD}` / `-{PASSWORD_SHORT}`: Password authentication no longer supported, use OAuth\"\n                );\n                exit(1);\n            }\n            match cached_creds {\n                Some(creds) if Some(username) == creds.username => {\n                    trace!(\"Using cached credentials for specified username.\");\n                    Some(creds)\n                }\n                _ => {\n                    trace!(\"No cached credentials for specified username.\");\n                    None\n                }\n            }\n        } else {\n            if cached_creds.is_some() {\n                trace!(\"Using cached credentials.\");\n            }\n            cached_creds\n        }\n    };\n\n    let no_discovery_reason = if !cfg!(any(\n        feature = \"with-libmdns\",\n        feature = \"with-dns-sd\",\n        feature = \"with-avahi\"\n    )) {\n        Some(\"librespot compiled without zeroconf backend\".to_owned())\n    } else if opt_present(DISABLE_DISCOVERY) {\n        Some(format!(\n            \"the `--{DISABLE_DISCOVERY}` / `-{DISABLE_DISCOVERY_SHORT}` flag set\",\n        ))\n    } else {\n        None\n    };\n\n    if credentials.is_none() && no_discovery_reason.is_some() && !enable_oauth {\n        error!(\"Credentials are required if discovery and oauth login are disabled.\");\n        exit(1);\n    }\n\n    let oauth_port = if opt_present(OAUTH_PORT) {\n        if !enable_oauth {\n            warn!(\n                \"Without the `--{ENABLE_OAUTH}` / `-{ENABLE_OAUTH_SHORT}` flag set `--{OAUTH_PORT}` / `-{OAUTH_PORT_SHORT}` has no effect.\"\n            );\n        }\n        opt_str(OAUTH_PORT)\n            .map(|port| match port.parse::<u16>() {\n                Ok(value) => {\n                    if value > 0 {\n                        Some(value)\n                    } else {\n                        None\n                    }\n                }\n                _ => {\n                    let valid_values = &format!(\"1 - {}\", u16::MAX);\n                    invalid_error_msg(OAUTH_PORT, OAUTH_PORT_SHORT, &port, valid_values, \"\");\n\n                    exit(1);\n                }\n            })\n            .unwrap_or(None)\n    } else {\n        Some(5588)\n    };\n\n    if let Some(reason) = no_discovery_reason.as_deref() {\n        if opt_present(ZEROCONF_PORT) {\n            warn!(\"With {reason} `--{ZEROCONF_PORT}` / `-{ZEROCONF_PORT_SHORT}` has no effect.\");\n        }\n    }\n\n    let zeroconf_port = if no_discovery_reason.is_none() {\n        opt_str(ZEROCONF_PORT)\n            .map(|port| match port.parse::<u16>() {\n                Ok(value) if value != 0 => value,\n                _ => {\n                    let valid_values = &format!(\"1 - {}\", u16::MAX);\n                    invalid_error_msg(ZEROCONF_PORT, ZEROCONF_PORT_SHORT, &port, valid_values, \"\");\n\n                    exit(1);\n                }\n            })\n            .unwrap_or(0)\n    } else {\n        0\n    };\n\n    // #1046: not all connections are supplied an `autoplay` user attribute to run statelessly.\n    // This knob allows for a manual override.\n    let autoplay = match opt_str(AUTOPLAY) {\n        Some(value) => match value.as_ref() {\n            \"on\" => Some(true),\n            \"off\" => Some(false),\n            _ => {\n                invalid_error_msg(\n                    AUTOPLAY,\n                    AUTOPLAY_SHORT,\n                    &opt_str(AUTOPLAY).unwrap_or_default(),\n                    \"on, off\",\n                    \"\",\n                );\n                exit(1);\n            }\n        },\n        None => SessionConfig::default().autoplay,\n    };\n\n    if let Some(reason) = no_discovery_reason.as_deref() {\n        if opt_present(ZEROCONF_INTERFACE) {\n            warn!(\n                \"With {} {} has no effect.\",\n                reason,\n                format_flag(ZEROCONF_INTERFACE, ZEROCONF_INTERFACE_SHORT),\n            );\n        }\n    }\n\n    let zeroconf_ip: Vec<std::net::IpAddr> = if opt_present(ZEROCONF_INTERFACE) {\n        if let Some(zeroconf_ip) = opt_str(ZEROCONF_INTERFACE) {\n            zeroconf_ip\n                .split(',')\n                .map(|s| {\n                    s.trim().parse::<std::net::IpAddr>().unwrap_or_else(|_| {\n                        invalid_error_msg(\n                            ZEROCONF_INTERFACE,\n                            ZEROCONF_INTERFACE_SHORT,\n                            s,\n                            \"IPv4 and IPv6 addresses\",\n                            \"\",\n                        );\n                        exit(1);\n                    })\n                })\n                .collect()\n        } else {\n            warn!(\"Unable to use zeroconf-interface option, default to all interfaces.\");\n            vec![]\n        }\n    } else {\n        vec![]\n    };\n\n    if let Some(reason) = no_discovery_reason.as_deref() {\n        if opt_present(ZEROCONF_BACKEND) {\n            warn!(\n                \"With {reason} `--{ZEROCONF_BACKEND}` / `-{ZEROCONF_BACKEND_SHORT}` has no effect.\"\n            );\n        }\n    }\n\n    let zeroconf_backend_name = opt_str(ZEROCONF_BACKEND);\n    let zeroconf_backend = no_discovery_reason.is_none().then(|| {\n        librespot::discovery::find(zeroconf_backend_name.as_deref()).unwrap_or_else(|_| {\n            let available_backends: Vec<_> = librespot::discovery::BACKENDS\n                .iter()\n                .filter_map(|(id, launch_svc)| launch_svc.map(|_| *id))\n                .collect();\n            let default_backend = librespot::discovery::BACKENDS\n                .iter()\n                .find_map(|(id, launch_svc)| launch_svc.map(|_| *id))\n                .unwrap_or(\"<none>\");\n\n            invalid_error_msg(\n                ZEROCONF_BACKEND,\n                ZEROCONF_BACKEND_SHORT,\n                &zeroconf_backend_name.unwrap_or_default(),\n                &available_backends.join(\", \"),\n                default_backend,\n            );\n\n            exit(1);\n        })\n    });\n\n    let local_file_directories = matches\n        .opt_strs(LOCAL_FILE_DIR)\n        .into_iter()\n        .map(PathBuf::from)\n        .collect::<Vec<_>>();\n\n    let connect_config = {\n        let connect_default_config = ConnectConfig::default();\n\n        let name = opt_str(NAME);\n        if matches!(name, Some(ref name) if name.is_empty()) {\n            empty_string_error_msg(NAME, NAME_SHORT);\n            exit(1);\n        }\n\n        #[cfg(feature = \"pulseaudio-backend\")]\n        {\n            if env::var(\"PULSE_PROP_application.name\").is_err() {\n                let op_pulseaudio_name = name\n                    .as_ref()\n                    .map(|name| format!(\"{} - {}\", connect_default_config.name, name));\n\n                let pulseaudio_name = op_pulseaudio_name\n                    .as_deref()\n                    .unwrap_or(&connect_default_config.name);\n\n                set_env_var(\"PULSE_PROP_application.name\", pulseaudio_name).await;\n            }\n\n            if env::var(\"PULSE_PROP_application.version\").is_err() {\n                set_env_var(\"PULSE_PROP_application.version\", version::SEMVER).await;\n            }\n\n            if env::var(\"PULSE_PROP_application.icon_name\").is_err() {\n                set_env_var(\"PULSE_PROP_application.icon_name\", \"audio-x-generic\").await;\n            }\n\n            if env::var(\"PULSE_PROP_application.process.binary\").is_err() {\n                set_env_var(\"PULSE_PROP_application.process.binary\", \"librespot\").await;\n            }\n\n            if env::var(\"PULSE_PROP_stream.description\").is_err() {\n                set_env_var(\"PULSE_PROP_stream.description\", \"Spotify Connect endpoint\").await;\n            }\n\n            if env::var(\"PULSE_PROP_media.software\").is_err() {\n                set_env_var(\"PULSE_PROP_media.software\", \"Spotify\").await;\n            }\n\n            if env::var(\"PULSE_PROP_media.role\").is_err() {\n                set_env_var(\"PULSE_PROP_media.role\", \"music\").await;\n            }\n        }\n\n        let initial_volume = opt_str(INITIAL_VOLUME)\n            .map(|initial_volume| {\n                let volume = match initial_volume.parse::<u16>() {\n                    Ok(value) if (VALID_INITIAL_VOLUME_RANGE).contains(&value) => value,\n                    _ => {\n                        let valid_values = &format!(\n                            \"{} - {}\",\n                            VALID_INITIAL_VOLUME_RANGE.start(),\n                            VALID_INITIAL_VOLUME_RANGE.end()\n                        );\n\n                        #[cfg(feature = \"alsa-backend\")]\n                        let default_value = &format!(\n                            \"{}, or the current value when the alsa mixer is used.\",\n                            connect_default_config.initial_volume\n                        );\n\n                        #[cfg(not(feature = \"alsa-backend\"))]\n                        let default_value = &connect_default_config.initial_volume.to_string();\n\n                        invalid_error_msg(\n                            INITIAL_VOLUME,\n                            INITIAL_VOLUME_SHORT,\n                            &initial_volume,\n                            valid_values,\n                            default_value,\n                        );\n\n                        exit(1);\n                    }\n                };\n\n                (volume as f32 / 100.0 * VolumeCtrl::MAX_VOLUME as f32) as u16\n            })\n            .or_else(|| {\n                if is_alsa_mixer {\n                    None\n                } else {\n                    cache.as_ref().and_then(Cache::volume)\n                }\n            });\n\n        let device_type = opt_str(DEVICE_TYPE).as_deref().map(|device_type| {\n            DeviceType::from_str(device_type).unwrap_or_else(|_| {\n                invalid_error_msg(\n                    DEVICE_TYPE,\n                    DEVICE_TYPE_SHORT,\n                    device_type,\n                    \"computer, tablet, smartphone, \\\n                        speaker, tv, avr, stb, audiodongle, \\\n                        gameconsole, castaudio, castvideo, \\\n                        automobile, smartwatch, chromebook, \\\n                        carthing\",\n                    DeviceType::default().into(),\n                );\n\n                exit(1);\n            })\n        });\n\n        let volume_steps = opt_str(VOLUME_STEPS).map(|steps| match steps.parse::<u16>() {\n            Ok(value) => value,\n            _ => {\n                let default_value = &connect_default_config.volume_steps.to_string();\n\n                invalid_error_msg(\n                    VOLUME_STEPS,\n                    VOLUME_STEPS_SHORT,\n                    &steps,\n                    \"a positive whole number <= 65535\",\n                    default_value,\n                );\n\n                exit(1);\n            }\n        });\n\n        let is_group = opt_present(DEVICE_IS_GROUP);\n\n        // use config defaults if not provided\n        let name = name.unwrap_or(connect_default_config.name);\n        let device_type = device_type.unwrap_or(connect_default_config.device_type);\n        let initial_volume = initial_volume.unwrap_or(connect_default_config.initial_volume);\n        let disable_volume = matches!(mixer_config.volume_ctrl, VolumeCtrl::Fixed);\n        let volume_steps = volume_steps.unwrap_or(connect_default_config.volume_steps);\n\n        ConnectConfig {\n            name,\n            device_type,\n            is_group,\n            initial_volume,\n            disable_volume,\n            volume_steps,\n            emit_set_queue_events: false,\n        }\n    };\n\n    let session_config = SessionConfig {\n        device_id: device_id(&connect_config.name),\n        proxy: opt_str(PROXY).or_else(|| std::env::var(\"http_proxy\").ok()).map(\n            |s| {\n                match Url::parse(&s) {\n                    Ok(url) => {\n                        if url.host().is_none() || url.port_or_known_default().is_none() {\n                            error!(\"Invalid proxy url, only URLs on the format \\\"http(s)://host:port\\\" are allowed\");\n                            exit(1);\n                        }\n\n                        url\n                    },\n                    Err(e) => {\n                        error!(\"Invalid proxy URL: \\\"{e}\\\", only URLs in the format \\\"http(s)://host:port\\\" are allowed\");\n                        exit(1);\n                    }\n                }\n            },\n        ),\n        ap_port: opt_str(AP_PORT).map(|port| match port.parse::<u16>() {\n            Ok(value) if value != 0 => value,\n            _ => {\n                let valid_values = &format!(\"1 - {}\", u16::MAX);\n                invalid_error_msg(AP_PORT, AP_PORT_SHORT, &port, valid_values, \"\");\n\n                exit(1);\n            }\n        }),\n\t\ttmp_dir,\n\t\tautoplay,\n\t\t..SessionConfig::default()\n    };\n\n    let player_config = {\n        let player_default_config = PlayerConfig::default();\n\n        let bitrate = opt_str(BITRATE)\n            .as_deref()\n            .map(|bitrate| {\n                Bitrate::from_str(bitrate).unwrap_or_else(|_| {\n                    invalid_error_msg(BITRATE, BITRATE_SHORT, bitrate, \"96, 160, 320\", \"160\");\n                    exit(1);\n                })\n            })\n            .unwrap_or(player_default_config.bitrate);\n\n        let gapless = !opt_present(DISABLE_GAPLESS);\n\n        let normalisation = opt_present(ENABLE_VOLUME_NORMALISATION);\n\n        let normalisation_method;\n        let normalisation_type;\n        let normalisation_pregain_db;\n        let normalisation_threshold_dbfs;\n        let normalisation_attack_cf;\n        let normalisation_release_cf;\n        let normalisation_knee_db;\n\n        if !normalisation {\n            for a in &[\n                NORMALISATION_METHOD,\n                NORMALISATION_GAIN_TYPE,\n                NORMALISATION_PREGAIN,\n                NORMALISATION_THRESHOLD,\n                NORMALISATION_ATTACK,\n                NORMALISATION_RELEASE,\n                NORMALISATION_KNEE,\n            ] {\n                if opt_present(a) {\n                    warn!(\n                        \"Without the `--{ENABLE_VOLUME_NORMALISATION}` / `-{ENABLE_VOLUME_NORMALISATION_SHORT}` flag normalisation options have no effect.\",\n                    );\n                    break;\n                }\n            }\n\n            normalisation_method = player_default_config.normalisation_method;\n            normalisation_type = player_default_config.normalisation_type;\n            normalisation_pregain_db = player_default_config.normalisation_pregain_db;\n            normalisation_threshold_dbfs = player_default_config.normalisation_threshold_dbfs;\n            normalisation_attack_cf = player_default_config.normalisation_attack_cf;\n            normalisation_release_cf = player_default_config.normalisation_release_cf;\n            normalisation_knee_db = player_default_config.normalisation_knee_db;\n        } else {\n            normalisation_method = opt_str(NORMALISATION_METHOD)\n                .as_deref()\n                .map(|method| {\n                    NormalisationMethod::from_str(method).unwrap_or_else(|_| {\n                        invalid_error_msg(\n                            NORMALISATION_METHOD,\n                            NORMALISATION_METHOD_SHORT,\n                            method,\n                            \"basic, dynamic\",\n                            &format!(\"{:?}\", player_default_config.normalisation_method),\n                        );\n\n                        exit(1);\n                    })\n                })\n                .unwrap_or(player_default_config.normalisation_method);\n\n            normalisation_type = opt_str(NORMALISATION_GAIN_TYPE)\n                .as_deref()\n                .map(|gain_type| {\n                    NormalisationType::from_str(gain_type).unwrap_or_else(|_| {\n                        invalid_error_msg(\n                            NORMALISATION_GAIN_TYPE,\n                            NORMALISATION_GAIN_TYPE_SHORT,\n                            gain_type,\n                            \"track, album, auto\",\n                            &format!(\"{:?}\", player_default_config.normalisation_type),\n                        );\n\n                        exit(1);\n                    })\n                })\n                .unwrap_or(player_default_config.normalisation_type);\n\n            normalisation_pregain_db = opt_str(NORMALISATION_PREGAIN)\n                .map(|pregain| match pregain.parse::<f64>() {\n                    Ok(value) if (VALID_NORMALISATION_PREGAIN_RANGE).contains(&value) => value,\n                    _ => {\n                        let valid_values = &format!(\n                            \"{} - {}\",\n                            VALID_NORMALISATION_PREGAIN_RANGE.start(),\n                            VALID_NORMALISATION_PREGAIN_RANGE.end()\n                        );\n\n                        invalid_error_msg(\n                            NORMALISATION_PREGAIN,\n                            NORMALISATION_PREGAIN_SHORT,\n                            &pregain,\n                            valid_values,\n                            &player_default_config.normalisation_pregain_db.to_string(),\n                        );\n\n                        exit(1);\n                    }\n                })\n                .unwrap_or(player_default_config.normalisation_pregain_db);\n\n            normalisation_threshold_dbfs = opt_str(NORMALISATION_THRESHOLD)\n                .map(|threshold| match threshold.parse::<f64>() {\n                    Ok(value) if (VALID_NORMALISATION_THRESHOLD_RANGE).contains(&value) => value,\n                    _ => {\n                        let valid_values = &format!(\n                            \"{} - {}\",\n                            VALID_NORMALISATION_THRESHOLD_RANGE.start(),\n                            VALID_NORMALISATION_THRESHOLD_RANGE.end()\n                        );\n\n                        invalid_error_msg(\n                            NORMALISATION_THRESHOLD,\n                            NORMALISATION_THRESHOLD_SHORT,\n                            &threshold,\n                            valid_values,\n                            &player_default_config\n                                .normalisation_threshold_dbfs\n                                .to_string(),\n                        );\n\n                        exit(1);\n                    }\n                })\n                .unwrap_or(player_default_config.normalisation_threshold_dbfs);\n\n            normalisation_attack_cf = opt_str(NORMALISATION_ATTACK)\n                .map(|attack| match attack.parse::<u64>() {\n                    Ok(value) if (VALID_NORMALISATION_ATTACK_RANGE).contains(&value) => {\n                        duration_to_coefficient(Duration::from_millis(value))\n                    }\n                    _ => {\n                        let valid_values = &format!(\n                            \"{} - {}\",\n                            VALID_NORMALISATION_ATTACK_RANGE.start(),\n                            VALID_NORMALISATION_ATTACK_RANGE.end()\n                        );\n\n                        invalid_error_msg(\n                            NORMALISATION_ATTACK,\n                            NORMALISATION_ATTACK_SHORT,\n                            &attack,\n                            valid_values,\n                            &coefficient_to_duration(player_default_config.normalisation_attack_cf)\n                                .as_millis()\n                                .to_string(),\n                        );\n\n                        exit(1);\n                    }\n                })\n                .unwrap_or(player_default_config.normalisation_attack_cf);\n\n            normalisation_release_cf = opt_str(NORMALISATION_RELEASE)\n                .map(|release| match release.parse::<u64>() {\n                    Ok(value) if (VALID_NORMALISATION_RELEASE_RANGE).contains(&value) => {\n                        duration_to_coefficient(Duration::from_millis(value))\n                    }\n                    _ => {\n                        let valid_values = &format!(\n                            \"{} - {}\",\n                            VALID_NORMALISATION_RELEASE_RANGE.start(),\n                            VALID_NORMALISATION_RELEASE_RANGE.end()\n                        );\n\n                        invalid_error_msg(\n                            NORMALISATION_RELEASE,\n                            NORMALISATION_RELEASE_SHORT,\n                            &release,\n                            valid_values,\n                            &coefficient_to_duration(\n                                player_default_config.normalisation_release_cf,\n                            )\n                            .as_millis()\n                            .to_string(),\n                        );\n\n                        exit(1);\n                    }\n                })\n                .unwrap_or(player_default_config.normalisation_release_cf);\n\n            normalisation_knee_db = opt_str(NORMALISATION_KNEE)\n                .map(|knee| match knee.parse::<f64>() {\n                    Ok(value) if (VALID_NORMALISATION_KNEE_RANGE).contains(&value) => value,\n                    _ => {\n                        let valid_values = &format!(\n                            \"{} - {}\",\n                            VALID_NORMALISATION_KNEE_RANGE.start(),\n                            VALID_NORMALISATION_KNEE_RANGE.end()\n                        );\n\n                        invalid_error_msg(\n                            NORMALISATION_KNEE,\n                            NORMALISATION_KNEE_SHORT,\n                            &knee,\n                            valid_values,\n                            &player_default_config.normalisation_knee_db.to_string(),\n                        );\n\n                        exit(1);\n                    }\n                })\n                .unwrap_or(player_default_config.normalisation_knee_db);\n        }\n\n        let ditherer_name = opt_str(DITHER);\n        let ditherer = match ditherer_name.as_deref() {\n            Some(value) => match value {\n                \"none\" => None,\n                _ => match format {\n                    AudioFormat::F64 | AudioFormat::F32 => {\n                        error!(\"Dithering is not available with format: {format:?}.\");\n                        exit(1);\n                    }\n                    _ => Some(dither::find_ditherer(ditherer_name).unwrap_or_else(|| {\n                        invalid_error_msg(\n                            DITHER,\n                            DITHER_SHORT,\n                            &opt_str(DITHER).unwrap_or_default(),\n                            \"none, gpdf, tpdf, tpdf_hp for formats S16, S24, S24_3, S32, none for formats F32, F64\",\n                            \"tpdf for formats S16, S24, S24_3 and none for formats S32, F32, F64\",\n                        );\n\n                        exit(1);\n                    })),\n                },\n            },\n            None => match format {\n                AudioFormat::S16 | AudioFormat::S24 | AudioFormat::S24_3 => {\n                    player_default_config.ditherer\n                }\n                _ => None,\n            },\n        };\n\n        #[cfg(feature = \"passthrough-decoder\")]\n        let passthrough = opt_present(PASSTHROUGH);\n        #[cfg(not(feature = \"passthrough-decoder\"))]\n        let passthrough = false;\n\n        PlayerConfig {\n            bitrate,\n            gapless,\n            passthrough,\n            normalisation,\n            normalisation_type,\n            normalisation_method,\n            normalisation_pregain_db,\n            normalisation_threshold_dbfs,\n            normalisation_attack_cf,\n            normalisation_release_cf,\n            normalisation_knee_db,\n            ditherer,\n            position_update_interval: None,\n            local_file_directories,\n        }\n    };\n\n    let player_event_program = opt_str(ONEVENT);\n    let emit_sink_events = opt_present(EMIT_SINK_EVENTS);\n\n    Setup {\n        format,\n        backend,\n        device,\n        mixer,\n        cache,\n        player_config,\n        session_config,\n        connect_config,\n        mixer_config,\n        credentials,\n        enable_oauth,\n        oauth_port,\n        zeroconf_port,\n        player_event_program,\n        emit_sink_events,\n        zeroconf_ip,\n        zeroconf_backend,\n    }\n}\n\n// Initialize a static semaphore with only one permit, which is used to\n// prevent setting environment variables from running in parallel.\nstatic PERMIT: Semaphore = Semaphore::const_new(1);\nasync fn set_env_var<K: AsRef<OsStr>, V: AsRef<OsStr>>(key: K, value: V) {\n    let permit = PERMIT\n        .acquire()\n        .await\n        .expect(\"Failed to acquire semaphore permit\");\n\n    // SAFETY: This is safe because setting the environment variable will wait if the permit is\n    // already acquired by other callers.\n    unsafe { env::set_var(key, value) }\n\n    // Drop the permit manually, so the compiler doesn't optimize it away as unused variable.\n    drop(permit);\n}\n\n#[tokio::main(flavor = \"current_thread\")]\nasync fn main() {\n    const RUST_BACKTRACE: &str = \"RUST_BACKTRACE\";\n    const RECONNECT_RATE_LIMIT_WINDOW: Duration = Duration::from_secs(600);\n    const DISCOVERY_RETRY_TIMEOUT: Duration = Duration::from_secs(10);\n    const RECONNECT_RATE_LIMIT: usize = 5;\n\n    if env::var(RUST_BACKTRACE).is_err() {\n        set_env_var(RUST_BACKTRACE, \"full\").await;\n    }\n\n    let setup = get_setup().await;\n\n    let mut last_credentials = None;\n    let mut spirc: Option<Spirc> = None;\n    let mut spirc_task: Option<Pin<_>> = None;\n    let mut auto_connect_times: Vec<Instant> = vec![];\n    let mut discovery = None;\n    let mut connecting = false;\n    let mut _event_handler: Option<EventHandler> = None;\n\n    let mut session = Session::new(setup.session_config.clone(), setup.cache.clone());\n\n    let mut sys = System::new();\n\n    if let Some(zeroconf_backend) = setup.zeroconf_backend {\n        // When started at boot as a service discovery may fail due to it\n        // trying to bind to interfaces before the network is actually up.\n        // This could be prevented in systemd by starting the service after\n        // network-online.target but it requires that a wait-online.service is\n        // also enabled which is not always the case since a wait-online.service\n        // can potentially hang the boot process until it times out in certain situations.\n        // This allows for discovery to retry every 10 secs in the 1st min of uptime\n        // before giving up thus papering over the issue and not holding up the boot process.\n\n        discovery = loop {\n            let device_id = setup.session_config.device_id.clone();\n            let client_id = setup.session_config.client_id.clone();\n\n            match librespot::discovery::Discovery::builder(device_id, client_id)\n                .name(setup.connect_config.name.clone())\n                .device_type(setup.connect_config.device_type)\n                .is_group(setup.connect_config.is_group)\n                .port(setup.zeroconf_port)\n                .zeroconf_ip(setup.zeroconf_ip.clone())\n                .zeroconf_backend(zeroconf_backend)\n                .launch()\n            {\n                Ok(d) => break Some(d),\n                Err(e) => {\n                    sys.refresh_processes(ProcessesToUpdate::All, true);\n\n                    if System::uptime() <= 1 {\n                        debug!(\"Retrying to initialise discovery: {e}\");\n                        tokio::time::sleep(DISCOVERY_RETRY_TIMEOUT).await;\n                    } else {\n                        debug!(\"System uptime > 1 min, not retrying to initialise discovery\");\n                        warn!(\"Could not initialise discovery: {e}\");\n                        break None;\n                    }\n                }\n            }\n        };\n    }\n\n    if let Some(credentials) = setup.credentials {\n        last_credentials = Some(credentials);\n        connecting = true;\n    } else if setup.enable_oauth {\n        let port_str = match setup.oauth_port {\n            Some(port) => format!(\":{port}\"),\n            _ => String::new(),\n        };\n        let client = OAuthClientBuilder::new(\n            &setup.session_config.client_id,\n            &format!(\"http://127.0.0.1{port_str}/login\"),\n            OAUTH_SCOPES.to_vec(),\n        )\n        .open_in_browser()\n        .build()\n        .unwrap_or_else(|e| {\n            error!(\"Failed to create OAuth client: {e}\");\n            exit(1);\n        });\n        let oauth_token = client.get_access_token().unwrap_or_else(|e| {\n            error!(\"Failed to get Spotify access token: {e}\");\n            exit(1);\n        });\n        last_credentials = Some(Credentials::with_access_token(oauth_token.access_token));\n        connecting = true;\n    } else if discovery.is_none() {\n        error!(\n            \"Discovery is unavailable and no credentials provided. Authentication is not possible.\"\n        );\n        exit(1);\n    }\n\n    let mixer_config = setup.mixer_config.clone();\n    let mixer = match (setup.mixer)(mixer_config) {\n        Ok(mixer) => mixer,\n        Err(why) => {\n            error!(\"{why}\");\n            exit(1)\n        }\n    };\n    let player_config = setup.player_config.clone();\n\n    let soft_volume = mixer.get_soft_volume();\n    let format = setup.format;\n    let backend = setup.backend;\n    let device = setup.device.clone();\n    let player = Player::new(player_config, session.clone(), soft_volume, move || {\n        (backend)(device, format)\n    });\n\n    if let Some(player_event_program) = setup.player_event_program.clone() {\n        _event_handler = Some(EventHandler::new(\n            player.get_player_event_channel(),\n            &player_event_program,\n        ));\n\n        if setup.emit_sink_events {\n            player.set_sink_event_callback(Some(Box::new(move |sink_status| {\n                run_program_on_sink_events(sink_status, &player_event_program)\n            })));\n        }\n    }\n\n    loop {\n        tokio::select! {\n            credentials = async {\n                match discovery.as_mut() {\n                    Some(d) => d.next().await,\n                    _ => None\n                }\n            }, if discovery.is_some() => {\n                match credentials {\n                    Some(credentials) => {\n                        last_credentials = Some(credentials.clone());\n                        auto_connect_times.clear();\n\n                        if let Some(spirc) = spirc.take() {\n                            if let Err(e) = spirc.shutdown() {\n                                error!(\"error sending spirc shutdown message: {e}\");\n                            }\n                        }\n                        if let Some(spirc_task) = spirc_task.take() {\n                            // Continue shutdown in its own task\n                            tokio::spawn(spirc_task);\n                        }\n                        if !session.is_invalid() {\n                            session.shutdown();\n                        }\n\n                        connecting = true;\n                    },\n                    None => {\n                        error!(\"Discovery stopped unexpectedly\");\n                        exit(1);\n                    }\n                }\n            },\n            _ = async {}, if connecting && last_credentials.is_some() => {\n                if session.is_invalid() {\n                    session = Session::new(setup.session_config.clone(), setup.cache.clone());\n                    player.set_session(session.clone());\n                }\n\n                let connect_config = setup.connect_config.clone();\n\n                let (spirc_, spirc_task_) = match Spirc::new(connect_config,\n                                                                session.clone(),\n                                                                last_credentials.clone().unwrap_or_default(),\n                                                                player.clone(),\n                                                                mixer.clone()).await {\n                    Ok((spirc_, spirc_task_)) => (spirc_, spirc_task_),\n                    Err(e) => {\n                        error!(\"could not initialize spirc: {e}\");\n                        exit(1);\n                    }\n                };\n                spirc = Some(spirc_);\n                spirc_task = Some(Box::pin(spirc_task_));\n\n                connecting = false;\n            },\n            _ = async {\n                if let Some(task) = spirc_task.as_mut() {\n                    task.await;\n                }\n            }, if spirc_task.is_some() && !connecting => {\n                spirc_task = None;\n\n                warn!(\"Spirc shut down unexpectedly\");\n\n                let mut reconnect_exceeds_rate_limit = || {\n                    auto_connect_times.retain(|&t| t.elapsed() < RECONNECT_RATE_LIMIT_WINDOW);\n                    auto_connect_times.len() > RECONNECT_RATE_LIMIT\n                };\n\n                if last_credentials.is_some() && !reconnect_exceeds_rate_limit() {\n                    auto_connect_times.push(Instant::now());\n                    if !session.is_invalid() {\n                        session.shutdown();\n                    }\n                    connecting = true;\n                } else {\n                    error!(\"Spirc shut down too often. Not reconnecting automatically.\");\n                    exit(1);\n                }\n            },\n            _ = async {}, if player.is_invalid() => {\n                error!(\"Player shut down unexpectedly\");\n                exit(1);\n            },\n            _ = tokio::signal::ctrl_c() => {\n                break;\n            },\n            else => break,\n        }\n    }\n\n    info!(\"Gracefully shutting down\");\n\n    let mut shutdown_tasks = tokio::task::JoinSet::new();\n\n    // Shutdown spirc if necessary\n    if let Some(spirc) = spirc {\n        if let Err(e) = spirc.shutdown() {\n            error!(\"error sending spirc shutdown message: {e}\");\n        }\n\n        if let Some(spirc_task) = spirc_task {\n            shutdown_tasks.spawn(spirc_task);\n        }\n    }\n\n    if let Some(discovery) = discovery {\n        shutdown_tasks.spawn(discovery.shutdown());\n    }\n\n    tokio::select! {\n        _ = tokio::signal::ctrl_c() => (),\n        _ = shutdown_tasks.join_all() => (),\n    }\n}\n"
  },
  {
    "path": "src/player_event_handler.rs",
    "content": "use log::{debug, error, warn};\n\nuse std::{collections::HashMap, process::Command, thread};\n\nuse librespot::{\n    metadata::audio::UniqueFields,\n    playback::player::{PlayerEvent, PlayerEventChannel, SinkStatus},\n};\n\npub struct EventHandler {\n    thread_handle: Option<thread::JoinHandle<()>>,\n}\n\nimpl EventHandler {\n    pub fn new(mut player_events: PlayerEventChannel, onevent: &str) -> Self {\n        let on_event = onevent.to_string();\n        let thread_handle = Some(thread::spawn(move || {\n            loop {\n                match player_events.blocking_recv() {\n                    None => break,\n                    Some(event) => {\n                        let mut env_vars = HashMap::new();\n\n                        match event {\n                            PlayerEvent::PlayRequestIdChanged { play_request_id } => {\n                                env_vars\n                                    .insert(\"PLAYER_EVENT\", \"play_request_id_changed\".to_string());\n                                env_vars.insert(\"PLAY_REQUEST_ID\", play_request_id.to_string());\n                            }\n                            PlayerEvent::TrackChanged { audio_item } => {\n                                let id = audio_item.track_id.to_id();\n                                env_vars.insert(\"PLAYER_EVENT\", \"track_changed\".to_string());\n                                env_vars.insert(\"TRACK_ID\", id);\n                                env_vars.insert(\"URI\", audio_item.uri);\n                                env_vars.insert(\"NAME\", audio_item.name);\n                                env_vars.insert(\n                                    \"COVERS\",\n                                    audio_item\n                                        .covers\n                                        .into_iter()\n                                        .map(|c| c.url)\n                                        .collect::<Vec<String>>()\n                                        .join(\"\\n\"),\n                                );\n                                env_vars.insert(\"LANGUAGE\", audio_item.language.join(\"\\n\"));\n                                env_vars.insert(\"DURATION_MS\", audio_item.duration_ms.to_string());\n                                env_vars.insert(\"IS_EXPLICIT\", audio_item.is_explicit.to_string());\n\n                                match audio_item.unique_fields {\n                                    UniqueFields::Track {\n                                        artists,\n                                        album,\n                                        album_artists,\n                                        popularity,\n                                        number,\n                                        disc_number,\n                                    } => {\n                                        env_vars.insert(\"ITEM_TYPE\", \"Track\".to_string());\n                                        env_vars.insert(\n                                            \"ARTISTS\",\n                                            artists\n                                                .0\n                                                .into_iter()\n                                                .map(|a| a.name)\n                                                .collect::<Vec<String>>()\n                                                .join(\"\\n\"),\n                                        );\n                                        env_vars.insert(\"ALBUM_ARTISTS\", album_artists.join(\"\\n\"));\n                                        env_vars.insert(\"ALBUM\", album);\n                                        env_vars.insert(\"POPULARITY\", popularity.to_string());\n                                        env_vars.insert(\"NUMBER\", number.to_string());\n                                        env_vars.insert(\"DISC_NUMBER\", disc_number.to_string());\n                                    }\n                                    UniqueFields::Local {\n                                        artists,\n                                        album,\n                                        album_artists,\n                                        number,\n                                        disc_number,\n                                        path,\n                                    } => {\n                                        env_vars.insert(\"ITEM_TYPE\", \"Track\".to_string());\n                                        env_vars.insert(\"ARTISTS\", artists.unwrap_or_default());\n                                        env_vars.insert(\n                                            \"ALBUM_ARTISTS\",\n                                            album_artists.unwrap_or_default(),\n                                        );\n                                        env_vars.insert(\"ALBUM\", album.unwrap_or_default());\n                                        env_vars.insert(\n                                            \"NUMBER\",\n                                            number.map(|n: u32| n.to_string()).unwrap_or_default(),\n                                        );\n                                        env_vars.insert(\n                                            \"DISC_NUMBER\",\n                                            disc_number\n                                                .map(|n: u32| n.to_string())\n                                                .unwrap_or_default(),\n                                        );\n                                        env_vars.insert(\n                                            \"LOCAL_FILE_PATH\",\n                                            path.into_os_string().into_string().unwrap_or_default(),\n                                        );\n                                    }\n                                    UniqueFields::Episode {\n                                        description,\n                                        publish_time,\n                                        show_name,\n                                    } => {\n                                        env_vars.insert(\"ITEM_TYPE\", \"Episode\".to_string());\n                                        env_vars.insert(\"DESCRIPTION\", description);\n                                        env_vars.insert(\n                                            \"PUBLISH_TIME\",\n                                            publish_time.unix_timestamp().to_string(),\n                                        );\n                                        env_vars.insert(\"SHOW_NAME\", show_name);\n                                    }\n                                }\n                            }\n                            PlayerEvent::Stopped { track_id, .. } => {\n                                env_vars.insert(\"PLAYER_EVENT\", \"stopped\".to_string());\n                                env_vars.insert(\"TRACK_ID\", track_id.to_id());\n                            }\n                            PlayerEvent::Playing {\n                                track_id,\n                                position_ms,\n                                ..\n                            } => {\n                                env_vars.insert(\"PLAYER_EVENT\", \"playing\".to_string());\n                                env_vars.insert(\"TRACK_ID\", track_id.to_id());\n                                env_vars.insert(\"POSITION_MS\", position_ms.to_string());\n                            }\n                            PlayerEvent::Paused {\n                                track_id,\n                                position_ms,\n                                ..\n                            } => {\n                                env_vars.insert(\"PLAYER_EVENT\", \"paused\".to_string());\n                                env_vars.insert(\"TRACK_ID\", track_id.to_id());\n                                env_vars.insert(\"POSITION_MS\", position_ms.to_string());\n                            }\n                            PlayerEvent::Loading { track_id, .. } => {\n                                env_vars.insert(\"PLAYER_EVENT\", \"loading\".to_string());\n                                env_vars.insert(\"TRACK_ID\", track_id.to_id());\n                            }\n                            PlayerEvent::Preloading { track_id, .. } => {\n                                env_vars.insert(\"PLAYER_EVENT\", \"preloading\".to_string());\n                                env_vars.insert(\"TRACK_ID\", track_id.to_id());\n                            }\n                            PlayerEvent::TimeToPreloadNextTrack { track_id, .. } => {\n                                env_vars.insert(\"PLAYER_EVENT\", \"preload_next\".to_string());\n                                env_vars.insert(\"TRACK_ID\", track_id.to_id());\n                            }\n                            PlayerEvent::EndOfTrack { track_id, .. } => {\n                                env_vars.insert(\"PLAYER_EVENT\", \"end_of_track\".to_string());\n                                env_vars.insert(\"TRACK_ID\", track_id.to_id());\n                            }\n                            PlayerEvent::Unavailable { track_id, .. } => {\n                                env_vars.insert(\"PLAYER_EVENT\", \"unavailable\".to_string());\n                                env_vars.insert(\"TRACK_ID\", track_id.to_id());\n                            }\n                            PlayerEvent::VolumeChanged { volume } => {\n                                env_vars.insert(\"PLAYER_EVENT\", \"volume_changed\".to_string());\n                                env_vars.insert(\"VOLUME\", volume.to_string());\n                            }\n                            PlayerEvent::Seeked {\n                                track_id,\n                                position_ms,\n                                ..\n                            } => {\n                                env_vars.insert(\"PLAYER_EVENT\", \"seeked\".to_string());\n                                env_vars.insert(\"TRACK_ID\", track_id.to_id());\n                                env_vars.insert(\"POSITION_MS\", position_ms.to_string());\n                            }\n                            PlayerEvent::PositionCorrection {\n                                track_id,\n                                position_ms,\n                                ..\n                            } => {\n                                env_vars.insert(\"PLAYER_EVENT\", \"position_correction\".to_string());\n                                env_vars.insert(\"TRACK_ID\", track_id.to_id());\n                                env_vars.insert(\"POSITION_MS\", position_ms.to_string());\n                            }\n                            PlayerEvent::SessionConnected {\n                                connection_id,\n                                user_name,\n                            } => {\n                                env_vars.insert(\"PLAYER_EVENT\", \"session_connected\".to_string());\n                                env_vars.insert(\"CONNECTION_ID\", connection_id);\n                                env_vars.insert(\"USER_NAME\", user_name);\n                            }\n                            PlayerEvent::SessionDisconnected {\n                                connection_id,\n                                user_name,\n                            } => {\n                                env_vars.insert(\"PLAYER_EVENT\", \"session_disconnected\".to_string());\n                                env_vars.insert(\"CONNECTION_ID\", connection_id);\n                                env_vars.insert(\"USER_NAME\", user_name);\n                            }\n                            PlayerEvent::SessionClientChanged {\n                                client_id,\n                                client_name,\n                                client_brand_name,\n                                client_model_name,\n                            } => {\n                                env_vars\n                                    .insert(\"PLAYER_EVENT\", \"session_client_changed\".to_string());\n                                env_vars.insert(\"CLIENT_ID\", client_id);\n                                env_vars.insert(\"CLIENT_NAME\", client_name);\n                                env_vars.insert(\"CLIENT_BRAND_NAME\", client_brand_name);\n                                env_vars.insert(\"CLIENT_MODEL_NAME\", client_model_name);\n                            }\n                            PlayerEvent::ShuffleChanged { shuffle } => {\n                                env_vars.insert(\"PLAYER_EVENT\", \"shuffle_changed\".to_string());\n                                env_vars.insert(\"SHUFFLE\", shuffle.to_string());\n                            }\n                            PlayerEvent::RepeatChanged { context, track } => {\n                                env_vars.insert(\"PLAYER_EVENT\", \"repeat_changed\".to_string());\n                                env_vars.insert(\"REPEAT\", context.to_string());\n                                env_vars.insert(\"REPEAT_TRACK\", track.to_string());\n                            }\n                            PlayerEvent::AutoPlayChanged { auto_play } => {\n                                env_vars.insert(\"PLAYER_EVENT\", \"auto_play_changed\".to_string());\n                                env_vars.insert(\"AUTO_PLAY\", auto_play.to_string());\n                            }\n\n                            PlayerEvent::FilterExplicitContentChanged { filter } => {\n                                env_vars.insert(\n                                    \"PLAYER_EVENT\",\n                                    \"filter_explicit_content_changed\".to_string(),\n                                );\n                                env_vars.insert(\"FILTER\", filter.to_string());\n                            }\n                            PlayerEvent::SetQueue {\n                                context_uri,\n                                current_track,\n                                next_tracks,\n                                prev_tracks,\n                            } => {\n                                env_vars.insert(\"PLAYER_EVENT\", \"set_queue\".to_string());\n                                env_vars.insert(\"CONTEXT_URI\", context_uri);\n                                if let Some(track) = current_track {\n                                    env_vars.insert(\n                                        \"CURRENT_TRACK\",\n                                        format!(\"{}\\t{}\", track.uri, track.provider),\n                                    );\n                                }\n                                env_vars.insert(\n                                    \"NEXT_TRACKS\",\n                                    next_tracks\n                                        .into_iter()\n                                        .map(|t| format!(\"{}\\t{}\", t.uri, t.provider))\n                                        .collect::<Vec<String>>()\n                                        .join(\"\\n\"),\n                                );\n                                env_vars.insert(\n                                    \"PREV_TRACKS\",\n                                    prev_tracks\n                                        .into_iter()\n                                        .map(|t| format!(\"{}\\t{}\", t.uri, t.provider))\n                                        .collect::<Vec<String>>()\n                                        .join(\"\\n\"),\n                                );\n                            }\n                            // Ignore event irrelevant for standalone binary like PositionChanged\n                            _ => {}\n                        }\n\n                        if !env_vars.is_empty() {\n                            run_program(env_vars, &on_event);\n                        }\n                    }\n                }\n            }\n        }));\n\n        Self { thread_handle }\n    }\n}\n\nimpl Drop for EventHandler {\n    fn drop(&mut self) {\n        debug!(\"Shutting down EventHandler thread ...\");\n        if let Some(handle) = self.thread_handle.take() {\n            if let Err(e) = handle.join() {\n                error!(\"EventHandler thread Error: {e:?}\");\n            }\n        }\n    }\n}\n\npub fn run_program_on_sink_events(sink_status: SinkStatus, onevent: &str) {\n    let mut env_vars = HashMap::new();\n\n    env_vars.insert(\"PLAYER_EVENT\", \"sink\".to_string());\n\n    let sink_status = match sink_status {\n        SinkStatus::Running => \"running\",\n        SinkStatus::TemporarilyClosed => \"temporarily_closed\",\n        SinkStatus::Closed => \"closed\",\n    };\n\n    env_vars.insert(\"SINK_STATUS\", sink_status.to_string());\n\n    run_program(env_vars, onevent);\n}\n\nfn run_program(env_vars: HashMap<&str, String>, onevent: &str) {\n    let mut v: Vec<&str> = onevent.split_whitespace().collect();\n\n    debug!(\"Running {onevent} with environment variables:\\n{env_vars:#?}\");\n\n    match Command::new(v.remove(0))\n        .args(&v)\n        .envs(env_vars.iter())\n        .spawn()\n    {\n        Err(e) => warn!(\"On event program {onevent} failed to start: {e}\"),\n        Ok(mut child) => match child.wait() {\n            Err(e) => warn!(\"On event program {onevent} failed: {e}\"),\n            Ok(e) if e.success() => (),\n            Ok(e) => {\n                if let Some(code) = e.code() {\n                    warn!(\"On event program {onevent} returned exit code {code}\");\n                } else {\n                    warn!(\"On event program {onevent} returned failure: {e}\");\n                }\n            }\n        },\n    }\n}\n"
  },
  {
    "path": "test.sh",
    "content": "#!/bin/sh\n\nset -e\n\nclean() {\n    # some shells will call EXIT after the INT signal\n    # causing EXIT trap to be executed, so we trap EXIT after INT\n    trap '' EXIT\n\n    cargo clean\n}\n\ntrap clean INT QUIT TERM EXIT\n\n# this script runs the tests and checks that also run as part of the`test.yml` github action workflow\ncargo clean\n\ncargo fmt --all -- --check\n\ncargo hack clippy -p librespot-protocol --each-feature\n\ncargo hack clippy -p librespot --each-feature --exclude-all-features --include-features native-tls --exclude-features rustls-tls-native-roots,rustls-tls-webpki-roots\ncargo hack clippy -p librespot --each-feature --exclude-all-features --include-features rustls-tls-native-roots --exclude-features native-tls,rustls-tls-webpki-roots\ncargo hack clippy -p librespot --each-feature --exclude-all-features --include-features rustls-tls-webpki-roots --exclude-features native-tls,rustls-tls-native-roots\n\n\ncargo fetch --locked\ncargo build --frozen --workspace --examples\ncargo test --workspace\n\ncargo hack check -p librespot-protocol --each-feature\ncargo hack check -p librespot --each-feature --exclude-all-features --include-features native-tls --exclude-features rustls-tls-native-roots,rustls-tls-webpki-roots\ncargo hack check -p librespot --each-feature --exclude-all-features --include-features rustls-tls-native-roots --exclude-features native-tls,rustls-tls-webpki-roots\nrun: cargo build --frozen\n"
  }
]