Repository: jpochyla/psst Branch: main Commit: ae4f16dbc9fa Files: 136 Total size: 733.4 KB Directory structure: gitextract_nr4iye45/ ├── .github/ │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ └── feature_request.md │ └── workflows/ │ └── build.yml ├── .gitignore ├── .homebrew/ │ └── generate_formula.sh ├── .pkg/ │ ├── APPIMAGE/ │ │ └── pkg2appimage-ingredients.yml │ ├── DEBIAN/ │ │ └── control │ ├── copyright │ └── psst.desktop ├── .rustfmt.toml ├── Cargo.toml ├── Cross.toml ├── LICENSE.md ├── README.md ├── psst-cli/ │ ├── Cargo.toml │ └── src/ │ └── main.rs ├── psst-core/ │ ├── Cargo.toml │ ├── build.rs │ └── src/ │ ├── actor.rs │ ├── audio/ │ │ ├── decode.rs │ │ ├── decrypt.rs │ │ ├── mod.rs │ │ ├── normalize.rs │ │ ├── output/ │ │ │ ├── cpal.rs │ │ │ ├── cubeb.rs │ │ │ └── mod.rs │ │ ├── probe.rs │ │ ├── resample.rs │ │ └── source.rs │ ├── cache.rs │ ├── cdn.rs │ ├── connection/ │ │ ├── diffie_hellman.rs │ │ ├── mod.rs │ │ └── shannon_codec.rs │ ├── error.rs │ ├── item_id.rs │ ├── lastfm.rs │ ├── lib.rs │ ├── metadata.rs │ ├── oauth.rs │ ├── player/ │ │ ├── file.rs │ │ ├── item.rs │ │ ├── mod.rs │ │ ├── queue.rs │ │ ├── storage.rs │ │ └── worker.rs │ ├── session/ │ │ ├── access_token.rs │ │ ├── audio_key.rs │ │ ├── client_token.rs │ │ ├── login5.rs │ │ ├── mercury.rs │ │ ├── mod.rs │ │ └── token.rs │ ├── system_info.rs │ └── util.rs └── psst-gui/ ├── Cargo.toml ├── assets/ │ ├── logo.afdesign │ └── logo.icns ├── build-icons.sh ├── build.rs └── src/ ├── cmd.rs ├── controller/ │ ├── after_delay.rs │ ├── alert_cleanup.rs │ ├── ex_click.rs │ ├── ex_cursor.rs │ ├── ex_scroll.rs │ ├── input.rs │ ├── mod.rs │ ├── nav.rs │ ├── on_command.rs │ ├── on_command_async.rs │ ├── on_debounce.rs │ ├── on_update.rs │ ├── playback.rs │ ├── session.rs │ └── sort.rs ├── data/ │ ├── album.rs │ ├── artist.rs │ ├── config.rs │ ├── ctx.rs │ ├── find.rs │ ├── id.rs │ ├── mod.rs │ ├── nav.rs │ ├── playback.rs │ ├── playlist.rs │ ├── promise.rs │ ├── recommend.rs │ ├── search.rs │ ├── show.rs │ ├── slider_scroll_scale.rs │ ├── track.rs │ ├── user.rs │ └── utils.rs ├── delegate.rs ├── error.rs ├── main.rs ├── ui/ │ ├── album.rs │ ├── artist.rs │ ├── credits.rs │ ├── episode.rs │ ├── find.rs │ ├── home.rs │ ├── library.rs │ ├── lyrics.rs │ ├── menu.rs │ ├── mod.rs │ ├── playable.rs │ ├── playback.rs │ ├── playlist.rs │ ├── preferences.rs │ ├── recommend.rs │ ├── search.rs │ ├── show.rs │ ├── theme.rs │ ├── track.rs │ ├── user.rs │ └── utils.rs ├── webapi/ │ ├── cache.rs │ ├── client.rs │ ├── local.rs │ └── mod.rs └── widget/ ├── checkbox.rs ├── dispatcher.rs ├── empty.rs ├── fill_between.rs ├── icons.rs ├── link.rs ├── maybe.rs ├── mod.rs ├── overlay.rs ├── promise.rs ├── remote_image.rs ├── theme.rs └── utils.rs ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/FUNDING.yml ================================================ github: jpochyla ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug report about: Create a report to help us improve title: "" labels: bug assignees: "" --- **Describe the bug** A clear and concise description of what the bug is. **To Reproduce** Steps to reproduce the behavior. **Expected behavior** A clear and concise description of what you expected to happen. **Screenshots** If applicable, add screenshots to help explain your problem. **Environment** - OS: - Version: **Additional context** Add any other context about the problem here. ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- name: Feature request about: Suggest an idea for this project title: "" labels: "" assignees: "" --- **Is your feature request related to a problem? Please describe.** A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] **Describe the solution you'd like** A clear and concise description of what you want to happen. **Describe alternatives you've considered** A clear and concise description of any alternative solutions or features you've considered. **Additional context** Add any other context or screenshots about the feature request here. ================================================ FILE: .github/workflows/build.yml ================================================ name: CI on: push: branches: [main] pull_request: branches: [main] permissions: contents: read env: CARGO_TERM_COLOR: always VERSION_DATE: ${{ format('{0}.{1}.{2}', github.run_number, github.run_id, github.run_attempt) }} VERSION_TIMESTAMP: ${{ github.event.repository.updated_at }} jobs: code-style: runs-on: ubuntu-latest steps: - name: Checkout Repository uses: actions/checkout@v4 - name: Setup Cache uses: Swatinem/rust-cache@v2 - name: Install Linux Dependencies if: runner.os == 'Linux' run: sudo apt-get update && sudo apt-get install -y libgtk-3-dev libssl-dev libasound2-dev - name: Check Formatting run: cargo clippy -- -D warnings build: strategy: fail-fast: false matrix: include: - os: ubuntu-latest target: x86_64-unknown-linux-gnu - os: ubuntu-latest target: aarch64-unknown-linux-gnu - os: macos-latest - os: windows-latest runs-on: ${{ matrix.os }} env: MACOSX_DEPLOYMENT_TARGET: 11.0 steps: - name: Checkout Repository uses: actions/checkout@v4 - name: Set Version Info id: version shell: bash env: FULL_SHA: ${{ github.sha }} run: | echo "BUILD_DATE=$(date +'%Y%m%d')" >> $GITHUB_ENV echo "VERSION=0.1.0,$(date +'%Y%m%d.%H%M%S')" >> $GITHUB_ENV SHORT_SHA=${FULL_SHA::7} echo "RELEASE_VERSION=$(date +'%Y.%m.%d')-$SHORT_SHA" >> $GITHUB_ENV - name: Setup Rust Cache uses: Swatinem/rust-cache@v2 with: key: ${{ hashFiles('Cross.toml') }} - name: Install Cross if: runner.os == 'Linux' run: cargo install cross - name: Build (Linux) if: runner.os == 'Linux' run: cross build --release --target ${{ matrix.target }} - name: Build Release (macOS) if: runner.os == 'macOS' run: | rustup target add x86_64-apple-darwin aarch64-apple-darwin cargo build --release --target x86_64-apple-darwin --target aarch64-apple-darwin - name: Build Release (Windows) if: runner.os == 'Windows' run: cargo build --release - name: Cache cargo-bundle and Homebrew id: cache-tools uses: actions/cache@v4 with: path: | ~/.cargo/bin/cargo-bundle ~/Library/Caches/Homebrew key: ${{ runner.os }}-tools-${{ hashFiles('**/Cargo.lock', '.github/workflows/build.yml') }} restore-keys: | ${{ runner.os }}-tools- - name: Install cargo-bundle if: runner.os == 'macOS' && !steps.cache-tools.outputs.cache-hit run: cargo install cargo-bundle - name: Install create-dmg if: runner.os == 'macOS' run: brew install -q create-dmg - name: Create macOS universal binary if: runner.os == 'macOS' run: | mkdir -p target/release lipo -create \ -arch x86_64 target/x86_64-apple-darwin/release/psst-gui \ -arch arm64 target/aarch64-apple-darwin/release/psst-gui \ -output target/release/psst-gui - name: Bundle macOS Release if: runner.os == 'macOS' env: CARGO_BUNDLE_SKIP_BUILD: "true" run: | cargo bundle --release -p psst-gui - name: Create DMG if: runner.os == 'macOS' run: | create-dmg \ --volname "Psst" \ --volicon "psst-gui/assets/logo.icns" \ --window-pos 200 120 \ --window-size 600 400 \ --icon-size 100 \ --icon "Psst.app" 150 160 \ --hide-extension "Psst.app" \ --app-drop-link 450 160 \ "Psst.dmg" \ "target/release/bundle/osx/Psst.app" - name: Upload macOS DMG uses: actions/upload-artifact@v4 if: runner.os == 'macOS' with: name: Psst.dmg path: Psst.dmg - name: Make Linux Binary Executable if: runner.os == 'Linux' run: chmod +x target/${{ matrix.target }}/release/psst-gui - name: Rename Linux Binary if: runner.os == 'Linux' run: mv target/${{ matrix.target }}/release/psst-gui target/${{ matrix.target }}/release/psst - name: Upload Linux Binary uses: actions/upload-artifact@v4 if: runner.os == 'Linux' with: name: psst-${{ matrix.target }} path: target/${{ matrix.target }}/release/psst - name: Upload Windows Executable uses: actions/upload-artifact@v4 if: runner.os == 'Windows' with: name: Psst.exe path: target/release/psst-gui.exe deb: runs-on: ubuntu-latest needs: build strategy: matrix: include: - arch: amd64 target: x86_64-unknown-linux-gnu - arch: arm64 target: aarch64-unknown-linux-gnu steps: - name: Checkout Repository uses: actions/checkout@v4 with: fetch-depth: 0 # Get full history to count number of commits for package version - name: Download Linux Binaries uses: actions/download-artifact@v4 with: name: psst-${{ matrix.target }} path: binaries - name: Move Binary run: | mkdir -p pkg/usr/bin/ mv binaries/psst pkg/usr/bin/ - name: Move Desktop Entry run: mkdir -p pkg/usr/share/applications/; mv .pkg/psst.desktop pkg/usr/share/applications/ - name: Add Icons run: | LOGOS=$(cd ./psst-gui/assets/ && ls logo_*.png) for LOGO in $LOGOS do LOGO_SIZE=$(echo "${LOGO}" | grep -oE '[[:digit:]]{2,}') mkdir -p "pkg/usr/share/icons/hicolor/${LOGO_SIZE}x${LOGO_SIZE}/" cp "./psst-gui/assets/${LOGO}" "pkg/usr/share/icons/hicolor/${LOGO_SIZE}x${LOGO_SIZE}/psst.png" done mkdir -p "pkg/usr/share/icons/hicolor/scalable/apps/" cp "./psst-gui/assets/logo.svg" "pkg/usr/share/icons/hicolor/scalable/apps/psst.svg" - name: Set Permissions run: chmod 755 pkg/usr/bin/psst - name: Move License run: mkdir -p pkg/usr/share/doc/psst-gui/; mv .pkg/copyright pkg/usr/share/doc/psst-gui/ - name: Write Package Config run: | mkdir -p pkg/DEBIAN export ARCHITECTURE=${{ matrix.arch }} SANITIZED_BRANCH="$(echo ${GITHUB_HEAD_REF:+.$GITHUB_HEAD_REF}|tr '_/' '-')" export VERSION=0.1.0"$SANITIZED_BRANCH"+r"$(git rev-list --count HEAD)"-0 envsubst < .pkg/DEBIAN/control > pkg/DEBIAN/control - name: Build Package run: | cat pkg/DEBIAN/control dpkg-deb -b pkg/ psst-${{ matrix.arch }}.deb - name: Upload Debian Package uses: actions/upload-artifact@v4 with: name: psst-deb-${{ matrix.arch }} path: "*.deb" appimage: if: false # Disable temporarily: https://github.com/jpochyla/psst/actions/runs/3897410142/jobs/6655282029 runs-on: ubuntu-latest needs: deb steps: - name: Checkout Repository uses: actions/checkout@v4 - name: Download Debian Package uses: actions/download-artifact@v4 with: name: psst-deb # Downloads to the root of the workspace by default if path is omitted or '.', # so removing explicit path to ${{github.workspace}} - name: Install Dependencies run: sudo apt-get update && sudo apt-get install -y libfuse2 - name: Create Workspace run: mkdir -p appimage - name: Download the Latest pkg2appimage run: | latest_release_appimage_url=$(wget -q https://api.github.com/repos/AppImageCommunity/pkg2appimage/releases/latest -O - | jq -r '.assets[0].browser_download_url') wget --directory-prefix=appimage -c $latest_release_appimage_url - name: Create Path to pkg2appimage run: | pkg2appimage_executable=$(ls appimage) app_path=appimage/${pkg2appimage_executable} chmod +x ${app_path} echo "app_path=${app_path}" >> $GITHUB_ENV - name: Create Path to pkg2appimage's Recipe File run: | recipe_path=psst/.pkg/APPIMAGE/pkg2appimage-ingredients.yml echo "recipe_path=${recipe_path}" >> $GITHUB_ENV - name: Run pkg2appimage run: | ${{env.app_path}} ${{env.recipe_path}} - name: Upload AppImage uses: actions/upload-artifact@v4 with: name: psst-appimage path: out/*.AppImage release: needs: [build, deb] runs-on: ubuntu-latest permissions: contents: write if: github.ref == 'refs/heads/main' && github.event_name == 'push' steps: - name: Set Version Info and Paths id: set_paths env: FULL_SHA: ${{ github.sha }} run: | echo "BUILD_DATE=$(date +'%Y%m%d')" >> $GITHUB_ENV echo "VERSION=0.1.0,$(date +'%Y%m%d.%H%M%S')" >> $GITHUB_ENV SHORT_SHA=${FULL_SHA::7} echo "RELEASE_VERSION=$(date +'%Y.%m.%d')-$SHORT_SHA" >> $GITHUB_ENV - name: Download All Artifacts uses: actions/download-artifact@v4 with: path: artifacts - name: Prepare Release Body Data id: release_data run: | echo "CURRENT_DATE_STR=$(date)" >> $GITHUB_ENV - name: Prepare Release Assets id: prep_assets run: | set -e mkdir -p artifacts_final find artifacts -type f -name 'Psst.dmg' -exec mv {} artifacts_final/Psst.dmg \; find artifacts -type f -name 'psst-gui.exe' -exec mv {} artifacts_final/Psst.exe \; find artifacts -type f -name 'psst-amd64.deb' -exec mv {} artifacts_final/psst-amd64.deb \; find artifacts -type f -name 'psst-arm64.deb' -exec mv {} artifacts_final/psst-arm64.deb \; find artifacts -type f -name 'psst' -path '*/psst-x86_64-unknown-linux-gnu/*' -exec mv {} artifacts_final/psst-linux-x86_64 \; find artifacts -type f -name 'psst' -path '*/psst-aarch64-unknown-linux-gnu/*' -exec mv {} artifacts_final/psst-linux-aarch64 \; rm -rf artifacts mv artifacts_final artifacts ls -l artifacts/ - name: Create Main Release uses: softprops/action-gh-release@v2 with: name: Continuous release (${{ env.RELEASE_VERSION }}) tag_name: rolling make_latest: true prerelease: false body: | This is a rolling release of Psst, published automatically on every commit to main. Version: ${{ env.RELEASE_VERSION }} Commit: ${{ github.sha }} Built: ${{ env.CURRENT_DATE_STR }} Workflow: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} See the release assets for SHA256 checksums. files: artifacts/* generate_release_notes: false ================================================ FILE: .gitignore ================================================ config.json target cache .cargo .idea .DS_Store .env *.iml rust-toolchain *.ico ================================================ FILE: .homebrew/generate_formula.sh ================================================ #!/bin/bash set -eo pipefail REPO_OWNER="jpochyla" REPO_NAME="psst" cat <= :big_sur" app "Psst.app" zap trash: [ "~/Library/Application Support/Psst", "~/Library/Caches/com.jpochyla.psst", "~/Library/Caches/Psst", "~/Library/HTTPStorages/com.jpochyla.psst", "~/Library/Preferences/com.jpochyla.psst.plist", "~/Library/Saved Application State/com.jpochyla.psst.savedState", ] end EOF ================================================ FILE: .pkg/APPIMAGE/pkg2appimage-ingredients.yml ================================================ ingredients: dist: focal sources: - deb http://us.archive.ubuntu.com/ubuntu/ focal main universe debs: - ../*.deb script: - mkdir -p /home/runner/work/psst/psst/.AppDir/ - cp /home/runner/work/psst/psst/psst-gui/assets/logo_256.png /home/runner/work/psst/psst/.AppDir/psst.png ================================================ FILE: .pkg/DEBIAN/control ================================================ Package: psst-gui Version: $VERSION Architecture: $ARCHITECTURE Maintainer: Jan Pochyla Section: sound Priority: optional Homepage: https://github.com/jpochyla/psst Package-Type: deb Depends: libssl3 | libssl1.1, libgtk-3-0, libcairo2 Description: Fast and native Spotify client ================================================ FILE: .pkg/copyright ================================================ Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ Upstream-Name: Psst Source: https://github.com/jpochyla/psst Files: * Copyright: (c) 2020 Jan Pochyla License: MIT License: MIT Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: .pkg/psst.desktop ================================================ [Desktop Entry] Type=Application Name=Psst Comment=Fast and native Spotify client GenericName=Music Player Icon=psst TryExec=psst-gui Exec=psst-gui %U Terminal=false MimeType=x-scheme-handler/psst; Categories=Audio;Music;Player;AudioVideo; StartupWMClass=psst-gui ================================================ FILE: .rustfmt.toml ================================================ imports_granularity = "Crate" wrap_comments = true ================================================ FILE: Cargo.toml ================================================ [workspace] resolver = "2" members = ["psst-core", "psst-cli", "psst-gui"] [profile.dev] opt-level = 1 debug = true lto = false [profile.release] opt-level = 3 strip = true lto = true codegen-units = 1 [profile.dev.package.symphonia] opt-level = 2 [profile.dev.package.libsamplerate] opt-level = 2 ================================================ FILE: Cross.toml ================================================ [build] pre-build = [""" dpkg --add-architecture $CROSS_DEB_ARCH && \ apt-get update && \ apt-get --assume-yes install \ libgtk-3-dev:$CROSS_DEB_ARCH \ libssl-dev:$CROSS_DEB_ARCH \ libasound2-dev:$CROSS_DEB_ARCH """] [target.x86_64-unknown-linux-gnu] image = "ghcr.io/cross-rs/x86_64-unknown-linux-gnu:edge" [target.aarch64-unknown-linux-gnu] image = "ghcr.io/cross-rs/aarch64-unknown-linux-gnu:edge" ================================================ FILE: LICENSE.md ================================================ MIT License Copyright (c) 2020 Jan Pochyla Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # Psst A fast Spotify client with a native GUI written in Rust, without Electron. Psst is still very early in development, lacking in features, stability, and general user experience. It's fully cross-platform, supporting Windows, Linux, and macOS. Contributions are welcome! **Note:** A Spotify Premium account is required. [![Build](https://github.com/jpochyla/psst/actions/workflows/build.yml/badge.svg)](https://github.com/jpochyla/psst/actions) ![Screenshot](./psst-gui/assets/screenshot.png) ## Download GitHub Actions automatically builds and releases new versions when changes are pushed to the `main` branch. You can download the latest release for Windows, Linux, and macOS from the [GitHub Releases page](https://github.com/jpochyla/psst/releases/latest). | Platform | Download Link | | ---------------------- | ---------------------------------------------------------------------------------------- | | Linux (x86_64) | [Download](https://github.com/jpochyla/psst/releases/latest/download/psst-linux-x86_64) | | Linux (aarch64) | [Download](https://github.com/jpochyla/psst/releases/latest/download/psst-linux-aarch64) | | Debian Package (amd64) | [Download](https://github.com/jpochyla/psst/releases/latest/download/psst-amd64.deb) | | Debian Package (arm64) | [Download](https://github.com/jpochyla/psst/releases/latest/download/psst-arm64.deb) | | macOS | [Download](https://github.com/jpochyla/psst/releases/latest/download/Psst.dmg) | | Windows | [Download](https://github.com/jpochyla/psst/releases/latest/download/Psst.exe) | Unofficial builds of Psst are also available through the [AUR](https://aur.archlinux.org/packages/psst-git) and [Homebrew](https://formulae.brew.sh/cask/psst). ## Building On all platforms, the **latest [Rust](https://rustup.rs/) stable** (at least 1.65.0) is required. For platform-specific requirements, see the dropdowns below.
Linux Our user-interface library, Druid, has two possible backends on Linux: GTK and pure X11, with a Wayland backend in the works. The default Linux backend is GTK. Before building on Linux, make sure the required dependencies are installed. ### Debian/Ubuntu ```shell sudo apt-get install libssl-dev libgtk-3-dev libcairo2-dev libasound2-dev ``` ### RHEL/Fedora ```shell sudo dnf install openssl-devel gtk3-devel cairo-devel alsa-lib-devel ```
OpenBSD (WIP) OpenBSD support is still a WIP, and things will likely not function as intended. Similar to Linux, Druid defaults to GTK while also providing a pure X11 backend. Furthermore, bindgen must be able to find LLVM through the expected environment variable. Only OpenBSD/amd64 has been tested so far. ```shell doas pkg_add gtk+3 cairo llvm export LIBCLANG_PATH=/usr/local/lib ``` In case rustc(1) fails building bigger crates ```shell memory allocation of xxxx bytes failed error: could not compile `gtk` Caused by: process didn't exit successfully: `rustc --crate-name gtk [...]` (signal: 6, SIGABRT: process abort signal) warning: build failed, waiting for other jobs to finish... ``` try increasing your user's maximum heap size: ```shell ulimit -d $(( 2 * `ulimit -d` )) ```
--- #### Build from Source ```shell cargo build # Append `--release` for a release build. ``` #### Run from Source ```shell cargo run --bin psst-gui # Append `--release` for a release build. ``` #### Build Installation Bundle (i.e., macOS .app) ```shell cargo install cargo-bundle cargo bundle --release ``` ## Roadmap - [x] Vorbis track playback - [x] Browsing saved albums and tracks - [x] Save / unsave albums and tracks - [x] Browsing followed playlists - [x] Search for artists, albums, and tracks - [x] Podcast support - [x] Media keys control - [x] Open Spotify links through the search bar - [x] Audio volume control - [x] Audio loudness normalization - [x] Genre playlists and "For You" content - [x] Dark theme - [x] Credits support - [ ] Resilience to network errors (automatically retry timed-out requests) - [ ] Managing playlists - Follow/unfollow - Add/remove tracks - Reorder tracks - Rename playlist - Playlist folders - [x] Playback queue - [ ] React to audio output device events - Pause after disconnecting headphones - Transfer playback after connecting headphones - [ ] Better caching - Cache as many WebAPI responses as possible - Visualize cache utilization - Total cache usage in the config dialog - Show time origin of cached data, allow to refresh - [ ] Trivia on the artist page, Wikipedia links - [ ] Downloading encrypted tracks - [ ] Reporting played tracks to Spotify servers - [ ] OS-specific application bundles - UI - [ ] Rethink the current design, consider a two-pane layout - Left pane for browsing - Right pane for current playback - [ ] Detect light/dark OS theme - [ ] Robust error states, ideally with a retry button - [ ] Correct playback highlight - Highlight now-playing track only in the correct album/playlist - Keep highlighted track in viewport - [ ] Paging or virtualized lists for albums and tracks - [ ] Grid for albums and artists - [ ] Robust active/inactive menu visualization - [ ] Save playback state ## Development Contributions are very welcome! Here's the basic project structure: - `/psst-core` - Core library, takes care of Spotify TCP session, audio file retrieval, decoding, audio output, playback queue, etc. - `/psst-gui` - GUI application built with [Druid](https://github.com/linebender/druid) - `/psst-cli` - Example CLI that plays a track. Credentials must be configured in the code. ## Privacy Policy Psst connects only to the official Spotify servers and does not call home. Caches of various things are stored locally and can be deleted anytime. User credentials are not stored at all; instead, a re-usable authentication token from Spotify is used. ## Thanks This project would not exist without the following: - Big thank you to [`librespot`](https://github.com/librespot-org/librespot), the Open Source Spotify client library for Rust. Most of `psst-core` is directly inspired by the ideas and code of `librespot`, although with a few differences: - Spotify Connect (remote control) is not supported yet. - Psst is completely synchronous, without `tokio` or other `async` runtime, although it will probably change in the future. - Psst is using HTTPS-based CDN audio file retrieval, similar to the official Web client or [`librespot-java`](https://github.com/librespot-org/librespot-java), instead of the channel-based approach in `librespot`. - [`druid`](https://github.com/linebender/druid) native GUI library for Rust. - [`ncspot`](https://github.com/hrkfdn/ncspot) cross-platform ncurses Spotify client written in Rust, using `librespot`. - ...and of course other libraries and projects. ================================================ FILE: psst-cli/Cargo.toml ================================================ [package] name = "psst-cli" version = "0.1.0" authors = ["Jan Pochyla "] edition = "2021" [features] default = ["cpal"] cpal = ["psst-core/cpal"] cubeb = ["psst-core/cubeb"] [dependencies] psst-core = { path = "../psst-core" } env_logger = "0.11.5" log = "0.4.22" ================================================ FILE: psst-cli/src/main.rs ================================================ use psst_core::{ audio::{ normalize::NormalizationLevel, output::{AudioOutput, AudioSink, DefaultAudioOutput}, }, cache::{Cache, CacheHandle}, cdn::{Cdn, CdnHandle}, connection::Credentials, error::Error, item_id::{ItemId, ItemIdType}, player::{item::PlaybackItem, PlaybackConfig, Player, PlayerCommand, PlayerEvent}, session::{SessionConfig, SessionService}, }; use std::{env, io, io::BufRead, path::PathBuf, thread}; fn main() { env_logger::init(); let args: Vec = env::args().collect(); let track_id = args .get(1) .expect("Expected in the first parameter"); let login_creds = Credentials::from_username_and_password( env::var("SPOTIFY_USERNAME").unwrap(), env::var("SPOTIFY_PASSWORD").unwrap(), ); let session = SessionService::with_config(SessionConfig { login_creds, proxy_url: None, }); start(track_id, session).unwrap(); } fn start(track_id: &str, session: SessionService) -> Result<(), Error> { let cdn = Cdn::new(session.clone(), None)?; let cache = Cache::new(PathBuf::from("cache"))?; let item_id = ItemId::from_base62(track_id, ItemIdType::Track).unwrap(); play_item( session, cdn, cache, PlaybackItem { item_id, norm_level: NormalizationLevel::Track, }, ) } fn play_item( session: SessionService, cdn: CdnHandle, cache: CacheHandle, item: PlaybackItem, ) -> Result<(), Error> { let output = DefaultAudioOutput::open()?; let config = PlaybackConfig::default(); let mut player = Player::new(session, cdn, cache, config, &output); let _ui_thread = thread::spawn({ let player_sender = player.sender(); player_sender .send(PlayerEvent::Command(PlayerCommand::LoadQueue { items: vec![item, item, item], position: 0, })) .unwrap(); move || { for line in io::stdin().lock().lines() { match line.as_ref().map(|s| s.as_str()) { Ok("p") => { player_sender .send(PlayerEvent::Command(PlayerCommand::Pause)) .unwrap(); } Ok("r") => { player_sender .send(PlayerEvent::Command(PlayerCommand::Resume)) .unwrap(); } Ok("s") => { player_sender .send(PlayerEvent::Command(PlayerCommand::Stop)) .unwrap(); } Ok("<") => { player_sender .send(PlayerEvent::Command(PlayerCommand::Previous)) .unwrap(); } Ok(">") => { player_sender .send(PlayerEvent::Command(PlayerCommand::Next)) .unwrap(); } _ => log::warn!("unknown command"), } } } }); for event in player.receiver() { player.handle(event); } output.sink().close(); Ok(()) } ================================================ FILE: psst-core/Cargo.toml ================================================ [package] name = "psst-core" version = "0.1.0" authors = ["Jan Pochyla "] edition = "2021" [build-dependencies] gix-config = "0.45.1" time = { version = "0.3.36", features = ["local-offset"] } [dependencies] # Common byteorder = { version = "1.5.0" } crossbeam-channel = { version = "0.5.13" } git-version = { version = "0.3.9" } log = { version = "0.4.22" } num-bigint = { version = "0.4.6", features = ["rand"] } num-traits = { version = "0.2.19" } oauth2 = { version = "4.4.2" } parking_lot = { version = "0.12.3" } librespot-protocol = "0.7.1" protobuf = "3" sysinfo = "0.35.0" data-encoding = "2.9" rand = { version = "0.9.1" } rangemap = { version = "1.5.1" } serde = { version = "1.0.210", features = ["derive"] } serde_json = { version = "1.0.132" } socks = { version = "0.3.4" } tempfile = { version = "3.13.0" } rustfm-scrobble = "1.1.1" ureq = { version = "3.0.11", features = ["json"] } url = { version = "2.5.2" } # Cryptography aes = { version = "0.8.4" } ctr = { version = "0.9.2" } hmac = { version = "0.12.1" } sha-1 = { version = "0.10.1" } shannon = { version = "0.2.0" } # Audio audio_thread_priority = "0.33.0" cpal = { version = "0.15.3", optional = true } cubeb = { git = "https://github.com/mozilla/cubeb-rs", optional = true } libsamplerate = { version = "0.1.0" } rb = { version = "0.4.1" } symphonia = { version = "0.5.4", default-features = false, features = [ "ogg", "vorbis", "mp3", ] } [target.'cfg(target_os = "windows")'.dependencies] windows = { version = "0.61.1", features = ["Win32_System_Com"], default-features = false } ================================================ FILE: psst-core/build.rs ================================================ use gix_config::File; use std::{env, fs, io::Write}; use time::OffsetDateTime; fn main() { let outdir = env::var("OUT_DIR").unwrap(); let outfile = format!("{outdir}/build-time.txt"); let mut fh = fs::File::create(outfile).unwrap(); let now = OffsetDateTime::now_local().unwrap_or_else(|_| OffsetDateTime::now_utc()); write!(fh, r#""{now}""#).ok(); let git_config = File::from_git_dir("../.git/".into()).expect("Git Config not found!"); // Get Git's 'Origin' URL let mut remote_url = git_config .raw_value("remote.origin.url") .expect("Couldn't extract origin url!") .to_string(); // Check whether origin is accessed via ssh if remote_url.contains('@') { // If yes, strip the `git@` prefix and split the domain and path let mut split = remote_url .strip_prefix("git@") .unwrap_or(&remote_url) .split(':'); let domain = split .next() .expect("Couldn't extract domain from ssh-style origin"); let path = split .next() .expect("Couldn't expect path from ssh-style origin"); // And construct the http-style url remote_url = format!("https://{domain}/{path}"); } let trimmed_url = remote_url.trim_end_matches(".git"); remote_url.clone_from(&String::from(trimmed_url)); let outfile = format!("{outdir}/remote-url.txt"); let mut file = fs::File::create(outfile).unwrap(); write!(file, r#""{remote_url}""#).ok(); } ================================================ FILE: psst-core/src/actor.rs ================================================ use std::{ fmt::Display, thread::{self, JoinHandle}, time::Duration, }; use crossbeam_channel::{ bounded, unbounded, Receiver, RecvTimeoutError, SendError, Sender, TrySendError, }; pub enum Act { Continue, WaitOr { timeout: Duration, timeout_msg: T::Message, }, Shutdown, } pub trait Actor: Sized { type Message: Send + 'static; type Error: Display; fn handle(&mut self, msg: Self::Message) -> Result, Self::Error>; fn process(mut self, recv: Receiver) { let mut act = Act::Continue; loop { let msg = match act { Act::Continue => match recv.recv() { Ok(msg) => msg, Err(_) => { break; } }, Act::WaitOr { timeout, timeout_msg, } => match recv.recv_timeout(timeout) { Ok(msg) => msg, Err(RecvTimeoutError::Timeout) => timeout_msg, Err(RecvTimeoutError::Disconnected) => { break; } }, Act::Shutdown => { break; } }; act = match self.handle(msg) { Ok(act) => act, Err(err) => { log::error!("error: {err}"); break; } }; } } fn spawn(cap: Capacity, name: &str, factory: F) -> ActorHandle where F: FnOnce(Sender) -> Self + Send + 'static, { let (send, recv) = cap.to_channel(); ActorHandle { sender: send.clone(), thread: thread::Builder::new() .name(name.to_string()) .spawn(move || { factory(send).process(recv); }) .unwrap(), } } fn spawn_with_default_cap(name: &str, factory: F) -> ActorHandle where F: FnOnce(Sender) -> Self + Send + 'static, { Self::spawn(Capacity::Bounded(128), name, factory) } } pub struct ActorHandle { thread: JoinHandle<()>, sender: Sender, } impl ActorHandle { pub fn sender(&self) -> Sender { self.sender.clone() } pub fn join(self) { let _ = self.thread.join(); } pub fn send(&self, msg: M) -> Result<(), SendError> { self.sender.send(msg) } pub fn try_send(&self, msg: M) -> Result<(), TrySendError> { self.sender.try_send(msg) } } pub enum Capacity { Sync, Bounded(usize), Unbounded, } impl Capacity { pub fn to_channel(&self) -> (Sender, Receiver) { match self { Capacity::Sync => bounded(0), Capacity::Bounded(cap) => bounded(*cap), Capacity::Unbounded => unbounded(), } } } ================================================ FILE: psst-core/src/audio/decode.rs ================================================ use std::{io, time::Duration}; use symphonia::{ core::{ audio::{SampleBuffer, SignalSpec}, codecs::{CodecParameters, Decoder, DecoderOptions}, conv::ConvertibleSample, errors::Error as SymphoniaError, formats::{FormatOptions, FormatReader, SeekMode, SeekTo}, io::{MediaSource, MediaSourceStream, MediaSourceStreamOptions}, units::TimeStamp, }, default::{ codecs::{MpaDecoder, VorbisDecoder}, formats::{MpaReader, OggReader}, }, }; use crate::{error::Error, util::FileWithConstSize}; pub enum AudioCodecFormat { Mp3, OggVorbis, } impl AudioCodecFormat { fn format_reader( &self, mss: MediaSourceStream, ) -> Result, SymphoniaError> { match self { Self::Mp3 => Ok(Box::new(MpaReader::try_new( mss, &FormatOptions::default(), )?)), Self::OggVorbis => Ok(Box::new(OggReader::try_new( mss, &FormatOptions::default(), )?)), } } fn decoder(&self, codec_params: &CodecParameters) -> Result, SymphoniaError> { match self { Self::Mp3 => Ok(Box::new(MpaDecoder::try_new( codec_params, &DecoderOptions::default(), )?)), Self::OggVorbis => Ok(Box::new(VorbisDecoder::try_new( codec_params, &DecoderOptions::default(), )?)), } } } pub struct AudioDecoder { track_id: u32, // Internal track index. decoder: Box, format: Box, } impl AudioDecoder { pub fn new(input: T, codec: AudioCodecFormat) -> Result where T: io::Read + io::Seek + Send + Sync + 'static, { let mss = MediaSourceStream::new( Box::new(FileWithConstSize::new(input)), MediaSourceStreamOptions::default(), ); let format = codec.format_reader(mss)?; let track = format.default_track().unwrap(); let decoder = codec.decoder(&track.codec_params)?; Ok(Self { track_id: track.id, decoder, format, }) } pub fn codec_params(&self) -> &CodecParameters { self.decoder.codec_params() } pub fn signal_spec(&self) -> SignalSpec { SignalSpec { rate: self.codec_params().sample_rate.unwrap(), channels: self.codec_params().channels.unwrap(), } } pub fn seek(&mut self, time: Duration) -> Result { let seeked_to = self.format.seek( SeekMode::Accurate, SeekTo::Time { time: time.as_secs_f64().into(), track_id: Some(self.track_id), }, )?; Ok(seeked_to.actual_ts) } /// Read a next packet of audio from this decoder. Returns `None` in case /// of EOF or internal error. pub fn read_packet(&mut self, samples: &mut SampleBuffer) -> Option where S: ConvertibleSample, { loop { // Demux an encoded packet from the media format. let packet = match self.format.next_packet() { Ok(packet) => packet, Err(SymphoniaError::IoError(io)) if io.kind() == io::ErrorKind::UnexpectedEof => { return None; // End of this stream. } Err(err) => { log::error!("format error: {err}"); return None; // We cannot recover from format errors, quit. } }; while !self.format.metadata().is_latest() { // Consume any new metadata that has been read since the last // packet. } // If the packet does not belong to the selected track, skip over it. if packet.track_id() != self.track_id { continue; } // Decode the packet into an audio buffer. match self.decoder.decode(&packet) { Ok(decoded) => { // Interleave the samples into the buffer. samples.copy_interleaved_ref(decoded); return Some(packet.ts()); } Err(SymphoniaError::IoError(err)) => { // The packet failed to decode due to an IO error, skip the packet. log::error!("io decode error: {err}"); continue; } Err(SymphoniaError::DecodeError(err)) => { // The packet failed to decode due to invalid data, skip the packet. log::error!("decode error: {err}"); continue; } Err(err) => { log::error!("fatal decode error: {err}"); return None; } }; } } } impl MediaSource for FileWithConstSize where T: io::Read + io::Seek + Send + Sync, { fn is_seekable(&self) -> bool { true } fn byte_len(&self) -> Option { Some(self.len()) } } impl From for Error { fn from(err: SymphoniaError) -> Error { Error::AudioDecodingError(Box::new(err)) } } ================================================ FILE: psst-core/src/audio/decrypt.rs ================================================ use std::{convert::TryInto, io}; use aes::{ cipher::{generic_array::GenericArray, KeyIvInit, StreamCipher, StreamCipherSeek}, Aes128, }; use ctr::Ctr128BE; const AUDIO_AESIV: [u8; 16] = [ 0x72, 0xe0, 0x67, 0xfb, 0xdd, 0xcb, 0xcf, 0x77, 0xeb, 0xe8, 0xbc, 0x64, 0x3f, 0x63, 0x0d, 0x93, ]; #[derive(Debug, Hash, PartialEq, Eq, Copy, Clone)] pub struct AudioKey(pub [u8; 16]); impl AudioKey { pub fn from_raw(data: &[u8]) -> Option { Some(AudioKey(data.try_into().ok()?)) } } pub struct AudioDecrypt { cipher: Ctr128BE, reader: T, } impl AudioDecrypt { pub fn new(key: AudioKey, reader: T) -> AudioDecrypt { let cipher = Ctr128BE::::new( GenericArray::from_slice(&key.0), GenericArray::from_slice(&AUDIO_AESIV), ); AudioDecrypt { cipher, reader } } } impl io::Read for AudioDecrypt { fn read(&mut self, output: &mut [u8]) -> io::Result { let len = self.reader.read(output)?; self.cipher.apply_keystream(&mut output[..len]); Ok(len) } } impl io::Seek for AudioDecrypt { fn seek(&mut self, pos: io::SeekFrom) -> io::Result { let newpos = self.reader.seek(pos)?; self.cipher.seek(newpos); Ok(newpos) } } ================================================ FILE: psst-core/src/audio/mod.rs ================================================ pub mod decode; pub mod decrypt; pub mod normalize; pub mod output; pub mod probe; pub mod resample; pub mod source; ================================================ FILE: psst-core/src/audio/normalize.rs ================================================ use std::{ io, io::{Read, Seek, SeekFrom}, }; use byteorder::{ReadBytesExt, LE}; #[derive(Debug, Clone, Copy, Eq, PartialEq)] pub enum NormalizationLevel { None, Track, Album, } #[derive(Clone, Copy)] pub struct NormalizationData { track_gain_db: f32, track_peak: f32, album_gain_db: f32, album_peak: f32, } impl NormalizationData { pub fn parse(mut file: impl Read + Seek) -> io::Result { const NORMALIZATION_OFFSET: u64 = 144; file.seek(SeekFrom::Start(NORMALIZATION_OFFSET))?; let track_gain_db = file.read_f32::()?; let track_peak = file.read_f32::()?; let album_gain_db = file.read_f32::()?; let album_peak = file.read_f32::()?; Ok(Self { track_gain_db, track_peak, album_gain_db, album_peak, }) } pub fn factor_for_level(&self, level: NormalizationLevel, pregain: f32) -> f32 { match level { NormalizationLevel::None => 1.0, NormalizationLevel::Track => Self::factor(pregain, self.track_gain_db, self.track_peak), NormalizationLevel::Album => Self::factor(pregain, self.album_gain_db, self.album_peak), } } fn factor(pregain: f32, gain: f32, peak: f32) -> f32 { let mut nf = f32::powf(10.0, (pregain + gain) / 20.0); if nf * peak > 1.0 { nf = 1.0 / peak; } nf } } ================================================ FILE: psst-core/src/audio/output/cpal.rs ================================================ use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; use crossbeam_channel::{bounded, Receiver, Sender}; use num_traits::Pow; use crate::{ actor::{Act, Actor, ActorHandle}, audio::{ output::{AudioOutput, AudioSink}, source::{AudioSource, Empty}, }, error::Error, }; pub struct CpalOutput { _handle: ActorHandle, sink: CpalSink, } impl CpalOutput { pub fn open() -> Result { // Open the default output device. let device = cpal::default_host() .default_output_device() .ok_or(cpal::DefaultStreamConfigError::DeviceNotAvailable)?; if let Ok(name) = device.name() { log::info!("using audio device: {name:?}"); } // Get the default device config, so we know what sample format and sample rate // the device supports. let supported = Self::preferred_output_config(&device)?; let (callback_send, callback_recv) = bounded(16); let handle = Stream::spawn_with_default_cap("audio_output", { let config = supported.config(); // TODO: Support additional sample formats. move |this| Stream::open(device, config, callback_recv, this).unwrap() }); let sink = CpalSink { channel_count: supported.channels(), sample_rate: supported.sample_rate(), stream_send: handle.sender(), callback_send, }; Ok(Self { _handle: handle, sink, }) } fn preferred_output_config( device: &cpal::Device, ) -> Result { const PREFERRED_SAMPLE_FORMAT: cpal::SampleFormat = cpal::SampleFormat::F32; const PREFERRED_SAMPLE_RATE: cpal::SampleRate = cpal::SampleRate(44_100); const PREFERRED_CHANNELS: cpal::ChannelCount = 2; for s in device.supported_output_configs()? { let rates = s.min_sample_rate()..=s.max_sample_rate(); if s.channels() == PREFERRED_CHANNELS && s.sample_format() == PREFERRED_SAMPLE_FORMAT && rates.contains(&PREFERRED_SAMPLE_RATE) { return Ok(s.with_sample_rate(PREFERRED_SAMPLE_RATE)); } } Ok(device.default_output_config()?) } } impl AudioOutput for CpalOutput { type Sink = CpalSink; fn sink(&self) -> Self::Sink { self.sink.clone() } } #[derive(Clone)] pub struct CpalSink { channel_count: cpal::ChannelCount, sample_rate: cpal::SampleRate, callback_send: Sender, stream_send: Sender, } impl CpalSink { fn send_to_callback(&self, msg: CallbackMsg) { if self.callback_send.send(msg).is_err() { log::error!("output stream actor is dead"); } } fn send_to_stream(&self, msg: StreamMsg) { if self.stream_send.send(msg).is_err() { log::error!("output stream actor is dead"); } } } impl AudioSink for CpalSink { fn channel_count(&self) -> usize { self.channel_count as usize } fn sample_rate(&self) -> u32 { self.sample_rate.0 } fn set_volume(&self, volume: f32) { self.send_to_callback(CallbackMsg::SetVolume(volume)); } fn play(&self, source: impl AudioSource) { self.send_to_callback(CallbackMsg::PlaySource(Box::new(source))); } fn pause(&self) { self.send_to_stream(StreamMsg::Pause); self.send_to_callback(CallbackMsg::Pause); } fn resume(&self) { self.send_to_stream(StreamMsg::Resume); self.send_to_callback(CallbackMsg::Resume); } fn stop(&self) { self.play(Empty); self.pause(); } fn close(&self) { self.send_to_stream(StreamMsg::Close); } } struct Stream { stream: cpal::Stream, _device: cpal::Device, } impl Stream { fn open( device: cpal::Device, config: cpal::StreamConfig, callback_recv: Receiver, stream_send: Sender, ) -> Result { let mut callback = StreamCallback { callback_recv, stream_send, source: Box::new(Empty), volume: 1.0, // We start with the full volume. state: CallbackState::Paused, }; log::info!("opening output stream: {config:?}"); let stream = device.build_output_stream( &config, move |output, _| { callback.write_samples(output); }, |err| { log::error!("audio output error: {err}"); }, None, )?; Ok(Self { _device: device, stream, }) } } impl Actor for Stream { type Message = StreamMsg; type Error = Error; fn handle(&mut self, msg: Self::Message) -> Result, Self::Error> { match msg { StreamMsg::Pause => { log::debug!("pausing audio output stream"); if let Err(err) = self.stream.pause() { log::error!("failed to stop stream: {err}"); } Ok(Act::Continue) } StreamMsg::Resume => { log::debug!("resuming audio output stream"); if let Err(err) = self.stream.play() { log::error!("failed to start stream: {err}"); } Ok(Act::Continue) } StreamMsg::Close => { log::debug!("closing audio output stream"); let _ = self.stream.pause(); Ok(Act::Shutdown) } } } } enum StreamMsg { Pause, Resume, Close, } enum CallbackMsg { PlaySource(Box), SetVolume(f32), Pause, Resume, } enum CallbackState { Playing, Paused, } struct StreamCallback { #[allow(unused)] stream_send: Sender, callback_recv: Receiver, source: Box, state: CallbackState, volume: f32, } impl StreamCallback { fn write_samples(&mut self, output: &mut [f32]) { // Process any pending data messages. while let Ok(msg) = self.callback_recv.try_recv() { match msg { CallbackMsg::PlaySource(src) => { self.source = src; } CallbackMsg::SetVolume(volume) => { self.volume = volume; } CallbackMsg::Pause => { self.state = CallbackState::Paused; } CallbackMsg::Resume => { self.state = CallbackState::Playing; } } } let written = if matches!(self.state, CallbackState::Playing) { // Write out as many samples as possible from the audio source to the // output buffer. let written = self.source.write(output); // Apply scaled global volume level. let scaled_volume = self.volume.pow(4); output[..written] .iter_mut() .for_each(|s| *s *= scaled_volume); written } else { 0 }; // Mute any remaining samples. output[written..].iter_mut().for_each(|s| *s = 0.0); } } impl From for Error { fn from(err: cpal::DefaultStreamConfigError) -> Error { Error::AudioOutputError(Box::new(err)) } } impl From for Error { fn from(err: cpal::SupportedStreamConfigsError) -> Error { Error::AudioOutputError(Box::new(err)) } } impl From for Error { fn from(err: cpal::BuildStreamError) -> Error { Error::AudioOutputError(Box::new(err)) } } impl From for Error { fn from(err: cpal::PlayStreamError) -> Error { Error::AudioOutputError(Box::new(err)) } } impl From for Error { fn from(err: cpal::PauseStreamError) -> Error { Error::AudioOutputError(Box::new(err)) } } ================================================ FILE: psst-core/src/audio/output/cubeb.rs ================================================ use std::{env, ffi::CString, ops::Deref}; use crossbeam_channel::{bounded, Receiver, Sender}; use crate::{ actor::{Act, Actor, ActorHandle}, audio::{ output::{AudioOutput, AudioSink}, source::{AudioSource, Empty}, }, error::Error, }; pub struct CubebOutput { #[allow(unused)] handle: ActorHandle, sink: CubebSink, } impl CubebOutput { pub fn open() -> Result { let (callback_send, callback_recv) = bounded(16); let handle = Stream::spawn_with_default_cap("audio_output", { move |_| Stream::open(callback_recv).unwrap() }); let sink = CubebSink { callback_send, stream_send: handle.sender(), }; Ok(Self { handle, sink }) } } impl AudioOutput for CubebOutput { type Sink = CubebSink; fn sink(&self) -> Self::Sink { self.sink.clone() } } type Frame = cubeb::StereoFrame; const STREAM_CHANNELS: usize = 2; const SAMPLE_RATE: u32 = 44_100; const STREAM_LATENCY: u32 = 0x1000; struct Stream { #[allow(unused)] ctx: cubeb::Context, stream: cubeb::Stream, } impl Stream { fn open(callback_recv: Receiver) -> Result { // Call CoInitialize() before any other calls to the API. #[cfg(target_os = "windows")] unsafe { let _ = windows::Win32::System::Com::CoInitialize(0 as *mut _); }; let backend_name = env::var("CUBEB_BACKEND") .ok() .and_then(|s| CString::new(s).ok()); let ctx_name = CString::new("Psst").ok(); let ctx = cubeb::Context::init(ctx_name.as_deref(), backend_name.as_deref())?; let mut callback = StreamCallback { callback_recv, source: Box::new(Empty), state: CallbackState::Paused, buffer: vec![0.0; 1024 * 1024], }; let params = cubeb::StreamParamsBuilder::new() .format(cubeb::SampleFormat::Float32NE) .rate(SAMPLE_RATE) .channels(STREAM_CHANNELS as u32) .layout(cubeb::ChannelLayout::STEREO) .take(); let mut builder = cubeb::StreamBuilder::new(); builder .name("Psst") .default_output(¶ms) .latency(STREAM_LATENCY) .data_callback(move |_, output| { callback.write_samples(output); output.len() as isize }) .state_callback(|state| { log::debug!("stream state: {:?}", state); }); let stream = builder.init(&ctx)?; Ok(Self { ctx, stream }) } } enum StreamMsg { Pause, Resume, Close, SetVolume(f32), } impl Actor for Stream { type Message = StreamMsg; type Error = Error; fn handle(&mut self, msg: Self::Message) -> Result, Self::Error> { match msg { StreamMsg::Pause => { log::debug!("pausing audio output stream"); if let Err(err) = self.stream.stop() { log::error!("failed to stop stream: {}", err); } Ok(Act::Continue) } StreamMsg::Resume => { log::debug!("resuming audio output stream"); if let Err(err) = self.stream.start() { log::error!("failed to start stream: {}", err); } Ok(Act::Continue) } StreamMsg::Close => { log::debug!("closing audio output stream"); let _ = self.stream.stop(); Ok(Act::Shutdown) } StreamMsg::SetVolume(volume) => { log::debug!("setting volume"); if let Err(err) = self.stream.set_volume(volume) { log::error!("failed to set volume: {}", err); } Ok(Act::Continue) } } } } #[derive(Clone)] pub struct CubebSink { callback_send: Sender, stream_send: Sender, } impl AudioSink for CubebSink { fn channel_count(&self) -> usize { STREAM_CHANNELS } fn sample_rate(&self) -> u32 { SAMPLE_RATE } fn set_volume(&self, volume: f32) { self.stream_send.send(StreamMsg::SetVolume(volume)).unwrap(); } fn play(&self, source: impl AudioSource) { self.callback_send .send(CallbackMsg::PlaySource(Box::new(source))) .unwrap() } fn pause(&self) { self.callback_send.send(CallbackMsg::Pause).unwrap(); self.stream_send.send(StreamMsg::Pause).unwrap(); } fn resume(&self) { self.callback_send.send(CallbackMsg::Resume).unwrap(); self.stream_send.send(StreamMsg::Resume).unwrap(); } fn stop(&self) { self.pause(); } fn close(&self) { self.stop(); } } enum CallbackMsg { PlaySource(Box), Pause, Resume, } enum CallbackState { Playing, Paused, } struct StreamCallback { callback_recv: Receiver, source: Box, state: CallbackState, buffer: Vec, } impl StreamCallback { fn write_samples(&mut self, output: &mut [Frame]) { // Process any pending data messages. while let Ok(msg) = self.callback_recv.try_recv() { match msg { CallbackMsg::PlaySource(src) => { self.source = src; } CallbackMsg::Pause => { self.state = CallbackState::Paused; } CallbackMsg::Resume => { self.state = CallbackState::Playing; } } } let written = if matches!(self.state, CallbackState::Playing) { // Write out as many samples as possible from the audio source to the // output buffer. let n_output_frames = output.len(); let n_output_samples = n_output_frames * STREAM_CHANNELS; let n_samples = self.source.write(&mut self.buffer[..n_output_samples]); let mut n_frames = 0; for (i, o) in self.buffer[..n_samples] .chunks(STREAM_CHANNELS) .zip(output.iter_mut()) { o.l = i[0]; o.r = i[1]; n_frames += 1; } n_frames } else { 0 }; // Mute any remaining samples. output[written..].iter_mut().for_each(|s| { s.l = 0.0; s.r = 0.0; }); } } unsafe impl Sync for StreamCallback {} impl From for Error { fn from(err: cubeb::Error) -> Self { Error::AudioOutputError(Box::new(err)) } } ================================================ FILE: psst-core/src/audio/output/mod.rs ================================================ use crate::audio::source::AudioSource; #[cfg(feature = "cpal")] pub mod cpal; #[cfg(feature = "cubeb")] pub mod cubeb; #[cfg(feature = "cubeb")] pub type DefaultAudioOutput = cubeb::CubebOutput; #[cfg(feature = "cpal")] pub type DefaultAudioOutput = cpal::CpalOutput; pub type DefaultAudioSink = ::Sink; pub trait AudioOutput { type Sink: AudioSink; fn sink(&self) -> Self::Sink; } pub trait AudioSink { fn channel_count(&self) -> usize; fn sample_rate(&self) -> u32; fn set_volume(&self, volume: f32); fn play(&self, source: impl AudioSource); fn pause(&self); fn resume(&self); fn stop(&self); fn close(&self); } ================================================ FILE: psst-core/src/audio/probe.rs ================================================ use std::fs::File; use std::path::PathBuf; use std::time::Duration; use symphonia::core::codecs::CodecType; use symphonia::core::formats::FormatOptions; use symphonia::core::io::{MediaSourceStream, MediaSourceStreamOptions}; use symphonia::core::meta::MetadataOptions; use symphonia::core::probe::{Hint, Probe}; use symphonia::default::formats::{MpaReader, OggReader}; use crate::error::Error; pub struct TrackProbe { pub codec: CodecType, pub duration: Option, } macro_rules! probe_err { ($message:tt) => { // This is necessary to work around the fact that the two impls for From<&str> are: // Box // Box // And the trait bound on our error is: // Box // Normally we could just do `$message.into()`, but no impl exists for exactly // `Error + Send`, so we have to be explicit about which we want to use. Error::AudioProbeError(Box::::from($message)) }; } impl TrackProbe { pub fn new(path: &PathBuf) -> Result { // Register all supported file formats for detection. let mut probe = Probe::default(); probe.register_all::(); probe.register_all::(); let mut hint = Hint::new(); if let Some(ext) = path.extension().and_then(|e| e.to_str()) { hint.with_extension(ext); } let file = File::open(path)?; let mss_opts = MediaSourceStreamOptions::default(); let mss = MediaSourceStream::new(Box::new(file), mss_opts); let fmt_opts = FormatOptions::default(); let meta_opts = MetadataOptions::default(); let probe_result = probe .format(&hint, mss, &fmt_opts, &meta_opts) .map_err(|_| probe_err!("failed to probe file"))?; let track = probe_result .format .default_track() .ok_or_else(|| probe_err!("file contained no tracks"))?; let params = &track.codec_params; let duration = if let (Some(time_base), Some(n_frames)) = (params.time_base, params.n_frames) { let time = time_base.calc_time(n_frames); let secs = time.seconds; let ms = (time.frac * 1_000.0).round() as u64; Some(Duration::from_millis(secs * 1_000 + ms)) } else { None }; Ok(Self { codec: params.codec, duration, }) } } ================================================ FILE: psst-core/src/audio/resample.rs ================================================ use crate::error::Error; #[derive(Copy, Clone)] pub enum ResamplingQuality { SincBestQuality = libsamplerate::SRC_SINC_BEST_QUALITY as isize, SincMediumQuality = libsamplerate::SRC_SINC_MEDIUM_QUALITY as isize, SincFastest = libsamplerate::SRC_SINC_FASTEST as isize, ZeroOrderHold = libsamplerate::SRC_ZERO_ORDER_HOLD as isize, Linear = libsamplerate::SRC_LINEAR as isize, } #[derive(Copy, Clone)] pub struct ResamplingSpec { pub input_rate: u32, pub output_rate: u32, pub channels: usize, } impl ResamplingSpec { pub fn output_size(&self, input_size: usize) -> usize { (self.output_rate as f64 / self.input_rate as f64 * input_size as f64) as usize } pub fn input_size(&self, output_size: usize) -> usize { (self.input_rate as f64 / self.output_rate as f64 * output_size as f64) as usize } pub fn ratio(&self) -> f64 { self.output_rate as f64 / self.input_rate as f64 } } pub struct AudioResampler { pub spec: ResamplingSpec, state: *mut libsamplerate::SRC_STATE, } impl AudioResampler { pub fn new(quality: ResamplingQuality, spec: ResamplingSpec) -> Result { let mut error_int = 0i32; let state = unsafe { libsamplerate::src_new( quality as i32, spec.channels as i32, &mut error_int as *mut i32, ) }; if error_int != 0 { Err(Error::ResamplingError(error_int)) } else { Ok(Self { state, spec }) } } pub fn process(&mut self, input: &[f32], output: &mut [f32]) -> Result<(usize, usize), Error> { if self.spec.input_rate == self.spec.output_rate { // Bypass conversion completely in case the sample rates are equal. let output = &mut output[..input.len()]; output.copy_from_slice(input); return Ok((input.len(), output.len())); } let mut src = libsamplerate::SRC_DATA { data_in: input.as_ptr(), data_out: output.as_mut_ptr(), input_frames: (input.len() / self.spec.channels) as _, output_frames: (output.len() / self.spec.channels) as _, src_ratio: self.spec.ratio(), end_of_input: 0, // TODO: Use this. input_frames_used: 0, output_frames_gen: 0, }; let error_int = unsafe { libsamplerate::src_process(self.state, &mut src as *mut _) }; if error_int != 0 { Err(Error::ResamplingError(error_int)) } else { let processed_len = src.input_frames_used as usize * self.spec.channels; let output_len = src.output_frames_gen as usize * self.spec.channels; Ok((processed_len, output_len)) } } } impl Drop for AudioResampler { fn drop(&mut self) { unsafe { libsamplerate::src_delete(self.state) }; } } unsafe impl Send for AudioResampler {} ================================================ FILE: psst-core/src/audio/source.rs ================================================ use crate::audio::resample::ResamplingSpec; use super::resample::{AudioResampler, ResamplingQuality}; /// Types that can produce audio samples in `f32` format. `Send`able across /// threads. pub trait AudioSource: Send + 'static { /// Write at most of `output.len()` samples into the `output`. Returns the /// number of written samples. Should take care to always output a full /// frame, and should _never_ block. fn write(&mut self, output: &mut [f32]) -> usize; fn channel_count(&self) -> usize; fn sample_rate(&self) -> u32; } /// Empty audio source. Does not produce any samples. pub struct Empty; impl AudioSource for Empty { fn write(&mut self, _output: &mut [f32]) -> usize { 0 } fn channel_count(&self) -> usize { 0 } fn sample_rate(&self) -> u32 { 0 } } pub struct StereoMappedSource { source: S, input_channels: usize, output_channels: usize, buffer: Vec, } impl StereoMappedSource where S: AudioSource, { pub fn new(source: S, output_channels: usize) -> Self { const BUFFER_SIZE: usize = 16 * 1024; let input_channels = source.channel_count(); Self { source, input_channels, output_channels, buffer: vec![0.0; BUFFER_SIZE], } } } impl AudioSource for StereoMappedSource where S: AudioSource, { fn write(&mut self, output: &mut [f32]) -> usize { let input_max = (output.len() / self.output_channels) * self.input_channels; let buffer_max = input_max.min(self.buffer.len()); let written = self.source.write(&mut self.buffer[..buffer_max]); let input = &self.buffer[..written]; let input_frames = input.chunks_exact(self.input_channels); let output_frames = output.chunks_exact_mut(self.output_channels); for (i, o) in input_frames.zip(output_frames) { o[0] = i[0]; o[1] = i[1]; // Assume the rest is is implicitly silence. } output.len() } fn channel_count(&self) -> usize { self.output_channels } fn sample_rate(&self) -> u32 { self.source.sample_rate() } } pub struct ResampledSource { source: S, resampler: AudioResampler, inp: Buf, out: Buf, } impl ResampledSource { pub fn new(source: S, output_sample_rate: u32, quality: ResamplingQuality) -> Self where S: AudioSource, { const BUFFER_SIZE: usize = 1024; let spec = ResamplingSpec { channels: source.channel_count(), input_rate: source.sample_rate(), output_rate: output_sample_rate, }; let inp_buf = vec![0.0; BUFFER_SIZE]; let out_buf = vec![0.0; spec.output_size(BUFFER_SIZE)]; Self { resampler: AudioResampler::new(quality, spec).unwrap(), source, inp: Buf { buf: inp_buf, start: 0, end: 0, }, out: Buf { buf: out_buf, start: 0, end: 0, }, } } } impl AudioSource for ResampledSource where S: AudioSource, { fn write(&mut self, output: &mut [f32]) -> usize { let mut total = 0; while total < output.len() { if self.out.is_empty() { if self.inp.is_empty() { let n = self.source.write(&mut self.inp.buf); self.inp.buf[n..].iter_mut().for_each(|s| *s = 0.0); self.inp.start = 0; self.inp.end = self.inp.buf.len(); } let (inp_consumed, out_written) = self .resampler .process(&self.inp.buf[self.inp.start..], &mut self.out.buf) .unwrap(); self.inp.start += inp_consumed; self.out.start = 0; self.out.end = out_written; } let source = self.out.get(); let target = &mut output[total..]; let to_write = self.out.len().min(target.len()); target[..to_write].copy_from_slice(&source[..to_write]); total += to_write; self.out.start += to_write; } total } fn channel_count(&self) -> usize { self.resampler.spec.channels } fn sample_rate(&self) -> u32 { self.resampler.spec.output_rate } } struct Buf { buf: Vec, start: usize, end: usize, } impl Buf { fn get(&self) -> &[f32] { &self.buf[self.start..self.end] } fn len(&self) -> usize { self.end - self.start } fn is_empty(&self) -> bool { self.start >= self.end } } ================================================ FILE: psst-core/src/cache.rs ================================================ use std::{ fs, io, path::{Path, PathBuf}, sync::Arc, }; use crate::{ audio::decrypt::AudioKey, error::Error, item_id::{FileId, ItemId}, }; use librespot_protocol::metadata::{Episode, Track}; use protobuf::Message; pub type CacheHandle = Arc; #[derive(Debug)] pub struct Cache { base: PathBuf, } fn create_cache_dirs(base: &Path) -> io::Result<()> { mkdir_if_not_exists(base)?; mkdir_if_not_exists(&base.join("track"))?; mkdir_if_not_exists(&base.join("episode"))?; mkdir_if_not_exists(&base.join("audio"))?; mkdir_if_not_exists(&base.join("key"))?; Ok(()) } impl Cache { pub fn new(base: PathBuf) -> Result { log::info!("using cache: {base:?}"); // Create the cache structure. create_cache_dirs(&base)?; let cache = Self { base }; Ok(Arc::new(cache)) } pub fn clear(&self) -> io::Result<()> { log::info!("clearing cache: {:?}", self.base); for entry in fs::read_dir(&self.base)? { let entry = entry?; let path = entry.path(); if path.is_dir() { fs::remove_dir_all(path)?; } else { fs::remove_file(path)?; } } // Re-create the essential directory structure. create_cache_dirs(&self.base) } } // Cache of `Track` protobuf structures. impl Cache { pub fn get_track(&self, item_id: ItemId) -> Option { let buf = fs::read(self.track_path(item_id)).ok()?; Track::parse_from_bytes(&buf).ok() } pub fn save_track(&self, item_id: ItemId, track: &Track) -> Result<(), Error> { log::debug!("saving track to cache: {item_id:?}"); fs::write(self.track_path(item_id), track.write_to_bytes()?)?; Ok(()) } fn track_path(&self, item_id: ItemId) -> PathBuf { self.base.join("track").join(item_id.to_base62()) } } // Cache of `Episode` protobuf structures. impl Cache { pub fn get_episode(&self, item_id: ItemId) -> Option { let buf = fs::read(self.episode_path(item_id)).ok()?; Episode::parse_from_bytes(&buf).ok() } pub fn save_episode(&self, item_id: ItemId, episode: &Episode) -> Result<(), Error> { log::debug!("saving episode to cache: {item_id:?}"); fs::write(self.episode_path(item_id), episode.write_to_bytes()?)?; Ok(()) } fn episode_path(&self, item_id: ItemId) -> PathBuf { self.base.join("episode").join(item_id.to_base62()) } } // Cache of `AudioKey`s. impl Cache { pub fn get_audio_key(&self, item_id: ItemId, file_id: FileId) -> Option { let buf = fs::read(self.audio_key_path(item_id, file_id)).ok()?; AudioKey::from_raw(&buf) } pub fn save_audio_key( &self, item_id: ItemId, file_id: FileId, key: &AudioKey, ) -> Result<(), Error> { log::debug!("saving audio key to cache: {item_id:?}:{file_id:?}"); fs::write(self.audio_key_path(item_id, file_id), key.0)?; Ok(()) } fn audio_key_path(&self, item_id: ItemId, file_id: FileId) -> PathBuf { let mut key_id = String::new(); key_id += &item_id.to_base62()[..16]; key_id += &file_id.to_base16()[..16]; self.base.join("key").join(key_id) } } // Cache of encrypted audio file content. impl Cache { pub fn audio_file_path(&self, file_id: FileId) -> PathBuf { self.base.join("audio").join(file_id.to_base16()) } pub fn save_audio_file(&self, file_id: FileId, from_path: PathBuf) -> Result<(), Error> { log::debug!("saving audio file to cache: {file_id:?}"); fs::copy(from_path, self.audio_file_path(file_id))?; Ok(()) } } // Cache of user country code. impl Cache { pub fn get_country_code(&self) -> Option { fs::read_to_string(self.country_code_path()).ok() } pub fn save_country_code(&self, country_code: &str) -> Result<(), Error> { fs::write(self.country_code_path(), country_code)?; Ok(()) } fn country_code_path(&self) -> PathBuf { self.base.join("country_code") } } pub fn mkdir_if_not_exists(path: &Path) -> io::Result<()> { fs::create_dir(path).or_else(|err| { if err.kind() == io::ErrorKind::AlreadyExists { Ok(()) } else { Err(err) } }) } ================================================ FILE: psst-core/src/cdn.rs ================================================ use std::{ io::Read, sync::Arc, time::{Duration, Instant}, }; use serde::Deserialize; use crate::{ error::Error, item_id::FileId, session::{SessionService}, util::default_ureq_agent_builder, }; use crate::session::login5::Login5; pub type CdnHandle = Arc; pub struct Cdn { session: SessionService, agent: ureq::Agent, login5: Login5, } impl Cdn { pub fn new(session: SessionService, proxy_url: Option<&str>) -> Result { let agent = default_ureq_agent_builder(proxy_url).build(); Ok(Arc::new(Self { session, agent: agent.into(), login5: Login5::new(None, proxy_url), })) } pub fn resolve_audio_file_url(&self, id: FileId) -> Result { let locations_uri = format!( "https://api.spotify.com/v1/storage-resolve/files/audio/interactive/{}", id.to_base16() ); let access_token = self.login5.get_access_token(&self.session)?; let response = self .agent .get(&locations_uri) .query("version", "10000000") .query("product", "9") .query("platform", "39") .query("alt", "json") .header("Authorization", &format!("Bearer {}", access_token.access_token)) .call()?; #[derive(Deserialize)] struct AudioFileLocations { cdnurl: Vec, } // Deserialize the response and pick a file URL from the returned CDN list. let locations: AudioFileLocations = response.into_body().read_json()?; let file_uri = locations .cdnurl .into_iter() // TODO: // Now, we always pick the first URL in the list, figure out a better strategy. // Choosing by random seems wrong. .next() // TODO: Avoid panicking here. .expect("No file URI found"); let uri = CdnUrl::new(file_uri); Ok(uri) } pub fn fetch_file_range( &self, uri: &str, offset: u64, length: u64, ) -> Result<(u64, impl Read), Error> { let response = self .agent .get(uri) .header("Range", &range_header(offset, length)) .call()?; let total_length = parse_total_content_length(&response); let data_reader = response.into_body().into_reader(); Ok((total_length, data_reader)) } } #[derive(Clone)] pub struct CdnUrl { pub url: String, pub expires: Instant, } impl CdnUrl { // In case we fail to parse the expiration time from URL, this default is used. const DEFAULT_EXPIRATION: Duration = Duration::from_secs(60 * 30); // Consider URL expired even before the official expiration time. const EXPIRATION_TIME_THRESHOLD: Duration = Duration::from_secs(5); fn new(url: String) -> Self { let expires_in = parse_expiration(&url).unwrap_or_else(|| { log::warn!("failed to parse expiration time from URL {:?}", &url); Self::DEFAULT_EXPIRATION }); let expires = Instant::now() + expires_in; Self { url, expires } } pub fn is_expired(&self) -> bool { self.expires.saturating_duration_since(Instant::now()) < Self::EXPIRATION_TIME_THRESHOLD } } impl From for Error { fn from(err: ureq::Error) -> Self { Error::AudioFetchingError(Box::new(err)) } } /// Constructs a Range header value for given offset and length. fn range_header(offfset: u64, length: u64) -> String { let last_byte = offfset + length - 1; // Offset of the last byte of the range is inclusive. format!("bytes={offfset}-{last_byte}") } /// Parses a total content length from a Content-Range response header. /// /// For example, returns 146515 for a response with header /// "Content-Range: bytes 0-1023/146515". fn parse_total_content_length(response: &ureq::http::response::Response) -> u64 { response .headers() .get("Content-Range") .expect("Content-Range header not found") .to_str() .expect("Failed to parse Content-Range Header") .split('/') .next_back() .expect("Failed to parse Content-Range Header") .parse() .expect("Failed to parse Content-Range Header") } /// Parses an expiration of an audio file URL. fn parse_expiration(url: &str) -> Option { let token_exp = url.split("__token__=exp=").nth(1); let expires_millis = if let Some(token_exp) = token_exp { // Parse from the expiration token param token_exp.split('~').next()? } else if let Some(verify_exp) = url.split("verify=").nth(1) { // Parse from verify parameter (new spotifycdn.com format) verify_exp.split('-').next()? } else { // Parse from the first param let first_param = url.split('?').nth(1)?; first_param.split('_').next()? }; let expires_millis = expires_millis.parse().ok()?; let expires = Duration::from_millis(expires_millis); Some(expires) } ================================================ FILE: psst-core/src/connection/diffie_hellman.rs ================================================ use num_bigint::{BigUint, ToBigUint}; use rand::Rng; pub struct DHLocalKeys { private_key: BigUint, public_key: BigUint, } impl DHLocalKeys { pub fn random() -> DHLocalKeys { let private_key = rand::rng().random::().to_biguint().unwrap(); let public_key = dh_generator().modpow(&private_key, &dh_prime()); DHLocalKeys { private_key, public_key, } } pub fn public_key(&self) -> Vec { self.public_key.to_bytes_be() } pub fn shared_secret(&self, remote_key: &[u8]) -> Vec { let remote_key = BigUint::from_bytes_be(remote_key); let shared_key = remote_key.modpow(&self.private_key, &dh_prime()); shared_key.to_bytes_be() } } fn dh_generator() -> BigUint { BigUint::from(0x2_u64) } fn dh_prime() -> BigUint { BigUint::from_bytes_be(&[ 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xc9, 0x0f, 0xda, 0xa2, 0x21, 0x68, 0xc2, 0x34, 0xc4, 0xc6, 0x62, 0x8b, 0x80, 0xdc, 0x1c, 0xd1, 0x29, 0x02, 0x4e, 0x08, 0x8a, 0x67, 0xcc, 0x74, 0x02, 0x0b, 0xbe, 0xa6, 0x3b, 0x13, 0x9b, 0x22, 0x51, 0x4a, 0x08, 0x79, 0x8e, 0x34, 0x04, 0xdd, 0xef, 0x95, 0x19, 0xb3, 0xcd, 0x3a, 0x43, 0x1b, 0x30, 0x2b, 0x0a, 0x6d, 0xf2, 0x5f, 0x14, 0x37, 0x4f, 0xe1, 0x35, 0x6d, 0x6d, 0x51, 0xc2, 0x45, 0xe4, 0x85, 0xb5, 0x76, 0x62, 0x5e, 0x7e, 0xc6, 0xf4, 0x4c, 0x42, 0xe9, 0xa6, 0x3a, 0x36, 0x20, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, ]) } ================================================ FILE: psst-core/src/connection/mod.rs ================================================ pub mod diffie_hellman; pub mod shannon_codec; use std::{ convert::TryInto, io, io::{Read, Write}, net::{TcpStream, ToSocketAddrs}, }; use byteorder::{ReadBytesExt, BE}; use hmac::{Hmac, Mac}; use serde::{Deserialize, Serialize}; use sha1::Sha1; use socks::Socks5Stream; use url::Url; use crate::{ connection::{ diffie_hellman::DHLocalKeys, shannon_codec::{ShannonDecoder, ShannonEncoder, ShannonMsg}, }, error::Error, util::{default_ureq_agent_builder, NET_CONNECT_TIMEOUT, NET_IO_TIMEOUT}, }; use librespot_protocol::authentication::AuthenticationType; use protobuf::{Enum, Message, MessageField, SpecialFields}; // Device ID used for authentication message. const DEVICE_ID: &str = "Psst"; // URI of access-point resolve endpoint. const AP_RESOLVE_ENDPOINT: &str = "http://apresolve.spotify.com"; // Access-point used in case the resolving fails. const AP_FALLBACK: &str = "ap.spotify.com:443"; #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(from = "SerializedCredentials")] #[serde(into = "SerializedCredentials")] pub struct Credentials { pub username: Option, pub auth_data: Vec, pub auth_type: AuthenticationType, } impl Credentials { pub fn from_username_and_password(username: String, password: String) -> Self { Self { username: Some(username), auth_type: AuthenticationType::AUTHENTICATION_USER_PASS, auth_data: password.into_bytes(), } } pub fn from_access_token(token: String) -> Self { Self { username: None, auth_type: AuthenticationType::AUTHENTICATION_SPOTIFY_TOKEN, auth_data: token.into_bytes(), } } } #[derive(Serialize, Deserialize)] struct SerializedCredentials { username: String, auth_data: String, auth_type: i32, } impl From for Credentials { fn from(value: SerializedCredentials) -> Self { Self { username: Some(value.username), auth_data: value.auth_data.into_bytes(), auth_type: AuthenticationType::from_i32(value.auth_type).unwrap_or_default(), } } } impl From for SerializedCredentials { fn from(value: Credentials) -> Self { Self { username: value.username.unwrap_or_default(), auth_data: String::from_utf8(value.auth_data) .expect("Invalid UTF-8 in serialized credentials"), auth_type: value.auth_type as _, } } } pub struct Transport { pub stream: TcpStream, pub encoder: ShannonEncoder, pub decoder: ShannonDecoder, } impl Transport { pub fn resolve_ap_with_fallback(proxy_url: Option<&str>) -> Vec { match Self::resolve_ap(proxy_url) { Ok(ap_list) => { log::info!("successfully resolved {} access points", ap_list.len()); ap_list } Err(err) => { log::error!("error while resolving APs, using fallback: {err:?}"); vec![AP_FALLBACK.into()] } } } pub fn resolve_ap(proxy_url: Option<&str>) -> Result, Error> { #[derive(Clone, Debug, Deserialize)] struct APResolveData { ap_list: Vec, } let agent: ureq::Agent = default_ureq_agent_builder(proxy_url).build().into(); log::info!("requesting AP list from {AP_RESOLVE_ENDPOINT}"); let data: APResolveData = agent .get(AP_RESOLVE_ENDPOINT) .call()? .into_body() .read_json()?; if data.ap_list.is_empty() { log::warn!("received empty AP list from server"); Err(Error::UnexpectedResponse) } else { log::info!("received {} APs from server", data.ap_list.len()); Ok(data.ap_list) } } pub fn connect(ap_list: &[String], proxy_url: Option<&str>) -> Result { log::info!( "attempting to connect using {} access points", ap_list.len() ); for (index, ap) in ap_list.iter().enumerate() { log::info!("trying AP {} of {}: {}", index + 1, ap_list.len(), ap); let stream = if let Some(url) = proxy_url { match Self::stream_through_proxy(ap, url) { Ok(s) => s, Err(e) => { log::warn!("failed to connect to AP {ap} through proxy: {e:?}"); continue; } } } else { match Self::stream_without_proxy(ap) { Ok(s) => s, Err(e) => { log::warn!("failed to connect to AP {ap} without proxy: {e:?}"); continue; } } }; if let Err(err) = stream.set_write_timeout(Some(NET_IO_TIMEOUT)) { log::warn!("failed to set TCP write timeout: {err:?}"); } log::info!("successfully connected to AP: {ap}"); return Self::exchange_keys(stream); } log::error!("failed to connect to any access point"); Err(Error::ConnectionFailed) } fn stream_without_proxy(ap: &str) -> Result { let mut last_err = None; for addr in ap.to_socket_addrs()? { match TcpStream::connect_timeout(&addr, NET_CONNECT_TIMEOUT) { Ok(stream) => { return Ok(stream); } Err(err) => { last_err.replace(err); } } } Err(last_err.unwrap_or_else(|| { io::Error::new( io::ErrorKind::InvalidInput, "could not resolve to any addresses", ) })) } fn stream_through_proxy(ap: &str, url: &str) -> Result { match Url::parse(url) { Ok(url) if url.scheme() == "socks" || url.scheme() == "socks5" => { // Currently we only support SOCKS5 proxies. Self::stream_through_socks5_proxy(ap, &url) } _ => { // Proxy URL failed to parse or has unsupported scheme. Err(Error::ProxyUrlInvalid) } } } fn stream_through_socks5_proxy(ap: &str, url: &Url) -> Result { let addrs = url.socket_addrs(|| None)?; let username = url.username(); let password = url.password().unwrap_or(""); // TODO: `socks` crate does not support connection timeouts. let proxy = if username.is_empty() { Socks5Stream::connect(&addrs[..], ap)? } else { Socks5Stream::connect_with_password(&addrs[..], ap, username, password)? }; Ok(proxy.into_inner()) } pub fn exchange_keys(mut stream: TcpStream) -> Result { use librespot_protocol::keyexchange::APResponseMessage; let local_keys = DHLocalKeys::random(); // Start by sending the hello message with our public key and nonce. log::trace!("sending client hello"); let client_nonce: [u8; 16] = rand::random(); let hello = client_hello(local_keys.public_key(), client_nonce.into()); let hello_packet = make_packet(&[0, 4], &hello); stream.write_all(&hello_packet)?; log::trace!("sent client hello"); // Wait for the response packet with the remote public key. Note that we are // keeping both the hello packet and the response packet for later (they get // hashed together with the shared secret to make a key pair). log::trace!("waiting for AP response"); let apresp_packet = read_packet(&mut stream)?; let apresp = APResponseMessage::parse_from_bytes(&apresp_packet[4..])?; log::trace!("received AP response"); // Compute the challenge response and the sending/receiving keys. let remote_key = apresp .challenge .login_crypto_challenge .diffie_hellman .gs .as_ref() .expect("Missing data"); let (challenge, send_key, recv_key) = compute_keys( &local_keys.shared_secret(remote_key), &hello_packet, &apresp_packet, ); // Respond with the computed HMAC and finish the handshake. log::trace!("sending client response"); let response = client_response_plaintext(challenge); let response_packet = make_packet(&[], &response); stream.write_all(&response_packet)?; log::trace!("sent client response"); // Use the derived keys to make a codec, wrapping the TCP stream. let encoder = ShannonEncoder::new(stream.try_clone()?, &send_key); let decoder = ShannonDecoder::new(stream.try_clone()?, &recv_key); Ok(Self { stream, encoder, decoder, }) } pub fn authenticate(&mut self, credentials: Credentials) -> Result { use librespot_protocol::{authentication::APWelcome, keyexchange::APLoginFailed}; // Send a login request with the client credentials. let request = client_response_encrypted(credentials); self.encoder.encode(request)?; // Expect an immediate response with the authentication result. let response = self.decoder.decode()?; match response.cmd { ShannonMsg::AP_WELCOME => { let welcome_data = APWelcome::parse_from_bytes(&response.payload).expect("Missing data"); Ok(Credentials { username: Some(welcome_data.canonical_username().to_string()), auth_data: welcome_data.reusable_auth_credentials().to_vec(), auth_type: welcome_data.reusable_auth_credentials_type(), }) } ShannonMsg::AUTH_FAILURE => { let error_data = APLoginFailed::parse_from_bytes(&response.payload).expect("Missing data"); Err(Error::AuthFailed { code: error_data.error_code() as _, }) } _ => { unreachable!("unexpected message"); } } } } fn read_packet(stream: &mut TcpStream) -> io::Result> { let size = stream.read_u32::()?; let mut buf = vec![0_u8; size as usize]; let (size_buf, data_buf) = buf.split_at_mut(4); size_buf.copy_from_slice(&size.to_be_bytes()); stream.read_exact(data_buf)?; Ok(buf) } fn make_packet(prefix: &[u8], data: &[u8]) -> Vec { let size = prefix.len() + 4 + data.len(); let mut buf = Vec::with_capacity(size); let size_u32: u32 = size.try_into().unwrap(); buf.extend(prefix); buf.extend(size_u32.to_be_bytes()); buf.extend(data); buf } fn client_hello(public_key: Vec, nonce: Vec) -> Vec { use librespot_protocol::keyexchange::*; let hello = ClientHello { build_info: MessageField::some(BuildInfo { platform: Some(Platform::PLATFORM_LINUX_X86.into()), product: Some(Product::PRODUCT_PARTNER.into()), product_flags: vec![], version: Some(109_800_078), special_fields: SpecialFields::new(), }), cryptosuites_supported: vec![Cryptosuite::CRYPTO_SUITE_SHANNON.into()], fingerprints_supported: vec![], powschemes_supported: vec![], login_crypto_hello: MessageField::some(LoginCryptoHelloUnion { diffie_hellman: MessageField::some(LoginCryptoDiffieHellmanHello { gc: Some(public_key), server_keys_known: Some(1), special_fields: SpecialFields::new(), }), special_fields: SpecialFields::new(), }), client_nonce: Some(nonce), padding: Some(vec![0x1e]), feature_set: None.into(), special_fields: SpecialFields::new(), }; hello .write_to_bytes() .expect("Failed to serialize client hello") } fn client_response_plaintext(challenge: Vec) -> Vec { use librespot_protocol::keyexchange::*; let response = ClientResponsePlaintext { login_crypto_response: MessageField::some(LoginCryptoResponseUnion { diffie_hellman: MessageField::some(LoginCryptoDiffieHellmanResponse { hmac: Some(challenge), special_fields: SpecialFields::new(), }), special_fields: SpecialFields::new(), }), pow_response: MessageField::some(PoWResponseUnion::default()), crypto_response: MessageField::some(CryptoResponseUnion::default()), special_fields: SpecialFields::new(), }; response .write_to_bytes() .expect("Failed to serialize client response") } fn compute_keys( shared_secret: &[u8], hello_packet: &[u8], apresp_packet: &[u8], ) -> (Vec, Vec, Vec) { let mut data = Vec::with_capacity(5 * 20); for i in 1..6 { let mut mac: Hmac = Hmac::new_from_slice(shared_secret).expect("HMAC can take key of any size"); mac.update(hello_packet); mac.update(apresp_packet); mac.update(&[i]); data.extend(mac.finalize().into_bytes()); } let mut mac: Hmac = Hmac::new_from_slice(&data[..20]).expect("HMAC can take key of any size"); mac.update(hello_packet); mac.update(apresp_packet); let digest = mac.finalize().into_bytes(); ( (*digest).to_vec(), data[20..52].to_vec(), data[52..84].to_vec(), ) } fn client_response_encrypted(credentials: Credentials) -> ShannonMsg { use librespot_protocol::authentication::{ ClientResponseEncrypted, LoginCredentials, SystemInfo, Os, CpuFamily }; let response = ClientResponseEncrypted { login_credentials: MessageField::some(LoginCredentials { username: credentials.username, auth_data: Some(credentials.auth_data), typ: Some(credentials.auth_type.into()), special_fields: SpecialFields::new(), }), system_info: MessageField::some(SystemInfo { device_id: Some(DEVICE_ID.to_string()), system_information_string: Some("librespot_but_actually_psst".to_string()), os: Some(Os::default().into()), cpu_family: Some(CpuFamily::default().into()), ..SystemInfo::default() }), ..ClientResponseEncrypted::default() }; let buf = response.write_to_bytes().expect("Failed to serialize"); ShannonMsg::new(ShannonMsg::LOGIN, buf) } ================================================ FILE: psst-core/src/connection/shannon_codec.rs ================================================ use std::{convert::TryInto, io}; use shannon::Shannon; #[derive(Debug)] pub struct ShannonMsg { pub cmd: u8, pub payload: Vec, } impl ShannonMsg { pub const SECRET_BLOCK: u8 = 0x02; pub const PING: u8 = 0x04; pub const STREAM_CHUNK: u8 = 0x08; pub const STREAM_CHUNK_RES: u8 = 0x09; pub const CHANNEL_ERROR: u8 = 0x0a; pub const CHANNEL_ABORT: u8 = 0x0b; pub const REQUEST_KEY: u8 = 0x0c; pub const AES_KEY: u8 = 0x0d; pub const AES_KEY_ERROR: u8 = 0x0e; pub const IMAGE: u8 = 0x19; pub const COUNTRY_CODE: u8 = 0x1b; pub const PONG: u8 = 0x49; pub const PONG_ACK: u8 = 0x4a; pub const PAUSE: u8 = 0x4b; pub const PRODUCT_INFO: u8 = 0x50; pub const LEGACY_WELCOME: u8 = 0x69; pub const LICENSE_VERSION: u8 = 0x76; pub const LOGIN: u8 = 0xab; pub const AP_WELCOME: u8 = 0xac; pub const AUTH_FAILURE: u8 = 0xad; pub const MERCURY_REQ: u8 = 0xb2; pub const MERCURY_SUB: u8 = 0xb3; pub const MERCURY_UNSUB: u8 = 0xb4; pub const MERCURY_PUB: u8 = 0xb5; pub fn new(cmd: u8, payload: impl Into>) -> Self { Self { cmd, payload: payload.into(), } } } const MAC_SIZE: usize = 4; const HEADER_SIZE: usize = 3; pub struct ShannonEncoder { inner: T, nonce: u32, cipher: Shannon, } impl ShannonEncoder where T: io::Write, { pub fn new(inner: T, send_key: &[u8]) -> Self { Self { inner, nonce: 0, cipher: Shannon::new(send_key), } } pub fn encode(&mut self, item: ShannonMsg) -> io::Result<()> { // Buffer up the whole message. let mut buf = Vec::with_capacity(HEADER_SIZE + item.payload.len() + MAC_SIZE); let len_u16: u16 = item.payload.len().try_into().unwrap(); buf.push(item.cmd); buf.extend(len_u16.to_be_bytes()); buf.extend(item.payload); // Seed the cipher, rotate the nonce, and encrypt the header and payload. self.cipher.nonce_u32(self.nonce); self.nonce += 1; self.cipher.encrypt(&mut buf); // Compute the MAC and append it. let mut mac = [0_u8; MAC_SIZE]; self.cipher.finish(&mut mac); buf.extend(mac); self.inner.write_all(&buf) } pub fn as_inner_mut(&mut self) -> &mut T { &mut self.inner } } pub struct ShannonDecoder { inner: T, nonce: u32, cipher: Shannon, } impl ShannonDecoder where T: io::Read, { pub fn new(inner: T, recv_key: &[u8]) -> Self { Self { inner, nonce: 0, cipher: Shannon::new(recv_key), } } pub fn decode(&mut self) -> io::Result { // Seed the cipher and rotate the nonce. self.cipher.nonce_u32(self.nonce); self.nonce += 1; // Read the whole header. Reading and decrypting byte by byte is not really // reliable, because of a bug in `shannon` crate. let mut header = [0_u8; HEADER_SIZE]; self.inner.read_exact(&mut header)?; self.cipher.decrypt(&mut header); // Parse the header fields. let cmd = header[0]; let size = u16::from_be_bytes([header[1], header[2]]) as usize; // Read and decrypt the payload. let mut payload = vec![0_u8; size]; self.inner.read_exact(&mut payload)?; self.cipher.decrypt(&mut payload); // Read and check the MAC. let mut mac = [0_u8; MAC_SIZE]; self.inner.read_exact(&mut mac)?; self.cipher.check_mac(&mac)?; Ok(ShannonMsg::new(cmd, payload)) } pub fn as_inner(&self) -> &T { &self.inner } } ================================================ FILE: psst-core/src/error.rs ================================================ use std::sync::mpsc::RecvTimeoutError; use std::{error, fmt, io}; #[derive(Debug)] pub enum Error { SessionDisconnected, UnexpectedResponse, MediaFileNotFound, ProxyUrlInvalid, AuthFailed { code: i32 }, ConnectionFailed, JsonError(Box), InvalidStateError(Box), UnimplementedError(Box), AudioFetchingError(Box), AudioDecodingError(Box), AudioOutputError(Box), AudioProbeError(Box), ScrobblerError(Box), ResamplingError(i32), ConfigError(String), IoError(io::Error), SendError, RecvTimeoutError(RecvTimeoutError), JoinError, OAuthError(String), } impl error::Error for Error {} impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::SessionDisconnected => write!(f, "Session disconnected"), Self::UnexpectedResponse => write!(f, "Unknown server response"), Self::MediaFileNotFound => write!(f, "Audio file not found"), Self::ProxyUrlInvalid => write!(f, "Invalid proxy URL"), Self::AuthFailed { code } => match code { 0 => write!(f, "Authentication failed: protocol error"), 2 => write!(f, "Authentication failed: try another AP"), 5 => write!(f, "Authentication failed: bad connection id"), 9 => write!(f, "Authentication failed: travel restriction"), 11 => write!(f, "Authentication failed: premium account required"), 12 => write!(f, "Authentication failed: bad credentials"), 13 => write!(f, "Authentication failed: could not validate credentials"), 14 => write!(f, "Authentication failed: account exists"), 15 => write!(f, "Authentication failed: extra verification required"), 16 => write!(f, "Authentication failed: invalid app key"), 17 => write!(f, "Authentication failed: application banned"), _ => write!(f, "Authentication failed with error code {code}"), }, Self::ConnectionFailed => write!(f, "Failed to connect to any access point"), Self::ResamplingError(code) => { write!(f, "Resampling failed with error code {code}") } Self::ConfigError(msg) => write!(f, "Configuration error: {msg}"), Self::JsonError(err) | Self::AudioFetchingError(err) | Self::AudioDecodingError(err) | Self::AudioOutputError(err) | Self::ScrobblerError(err) | Self::AudioProbeError(err) => err.fmt(f), Self::InvalidStateError(err) | Self::UnimplementedError(err) => err.fmt(f), Self::IoError(err) => err.fmt(f), Self::SendError => write!(f, "Failed to send into a channel"), Self::RecvTimeoutError(err) => write!(f, "Channel receive timeout: {err}"), Self::JoinError => write!(f, "Failed to join thread"), Self::OAuthError(msg) => write!(f, "OAuth error: {msg}"), } } } impl From for Error { fn from(err: io::Error) -> Error { Error::IoError(err) } } impl From> for Error { fn from(_: crossbeam_channel::SendError) -> Self { Error::SendError } } impl From for Error { fn from(err: RecvTimeoutError) -> Self { Error::RecvTimeoutError(err) } } impl From for Error { fn from(err: protobuf::Error) -> Self { Error::InvalidStateError(err.into()) } } ================================================ FILE: psst-core/src/item_id.rs ================================================ use std::{ collections::HashMap, convert::TryInto, fmt, ops::Deref, path::PathBuf, sync::{LazyLock, Mutex}, }; static LOCAL_REGISTRY: LazyLock> = LazyLock::new(|| Mutex::new(LocalItemRegistry::new())); // LocalItemRegistry allows generating IDs for local music files, so they can be // treated similarly to files hosted on Spotify's remote servers. IDs are // easier to pass around since they implement `Copy`, as opposed to passing // around a `PathBuf` or `File` pointing to the file. // // The registry stores two complementary maps for bi-directional lookup. This // allows for quick registration of new tracks and quick lookup of existing // tracks by ID, at the cost of increased memory usage. The ID-to-path lookup // should be prioritized, as that is required to begin playback. Path-to-ID // lookup is helpful to avoid registering the same path under multiple IDs, // but is okay to be a bit slower since it's only done once per track when // (when loading the list of local files from Spotify's config). pub struct LocalItemRegistry { next_id: u128, path_to_id: HashMap, id_to_path: HashMap, } impl LocalItemRegistry { fn new() -> Self { Self { next_id: 1, path_to_id: HashMap::new(), id_to_path: HashMap::new(), } } pub fn get_or_insert(path: PathBuf) -> u128 { let mut registry = LOCAL_REGISTRY.lock().unwrap(); registry.path_to_id.get(&path).copied().unwrap_or_else(|| { let id = registry.next_id; registry.next_id += 1; registry.id_to_path.insert(id, path.clone()); id }) } pub fn get(id: u128) -> Option { let registry = LOCAL_REGISTRY.lock().unwrap(); registry.id_to_path.get(&id).cloned() } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum ItemIdType { Track, Podcast, LocalFile, Unknown, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct ItemId { pub id: u128, pub id_type: ItemIdType, } const BASE62_DIGITS: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; const BASE16_DIGITS: &[u8] = b"0123456789abcdef"; impl ItemId { pub const INVALID: Self = Self::new(0u128, ItemIdType::Unknown); pub const fn new(id: u128, id_type: ItemIdType) -> Self { Self { id, id_type } } pub fn from_base16(id: &str, id_type: ItemIdType) -> Option { let mut n = 0_u128; for c in id.as_bytes() { let d = BASE16_DIGITS.iter().position(|e| e == c)? as u128; n *= 16; n += d; } Some(Self::new(n, id_type)) } pub fn from_base62(id: &str, id_type: ItemIdType) -> Option { let mut n = 0_u128; for c in id.as_bytes() { let d = BASE62_DIGITS.iter().position(|e| e == c)? as u128; n *= 62; n += d; } Some(Self::new(n, id_type)) } pub fn from_raw(data: &[u8], id_type: ItemIdType) -> Option { let n = u128::from_be_bytes(data.try_into().ok()?); Some(Self::new(n, id_type)) } pub fn from_uri(uri: &str) -> Option { let gid = uri.split(':').next_back()?; if uri.contains(":episode:") { Self::from_base62(gid, ItemIdType::Podcast) } else if uri.contains(":track:") { Self::from_base62(gid, ItemIdType::Track) } else { Self::from_base62(gid, ItemIdType::Unknown) } } /// Converts an ID to an URI as described in: https://developer.spotify.com/documentation/web-api/#spotify-uris-and-ids pub fn to_uri(&self) -> Option { let b64 = self.to_base62(); match self.id_type { ItemIdType::Track => Some(format!("spotify:track:{b64}")), ItemIdType::Podcast => Some(format!("spotify:podcast:{b64}")), // TODO: support adding local files to playlists ItemIdType::LocalFile => None, ItemIdType::Unknown => None, } } pub fn to_base16(&self) -> String { format!("{:032x}", self.id) } pub fn to_base62(&self) -> String { let mut n = self.id; let mut data = [0_u8; 22]; for i in 0..22 { data[21 - i] = BASE62_DIGITS[(n % 62) as usize]; n /= 62; } std::str::from_utf8(&data).unwrap().to_string() } pub fn to_raw(&self) -> [u8; 16] { self.id.to_be_bytes() } pub fn from_local(path: PathBuf) -> Self { Self::new( LocalItemRegistry::get_or_insert(path), ItemIdType::LocalFile, ) } pub fn to_local(&self) -> PathBuf { match self.id_type { // local items should only be constructed with `from_local` ItemIdType::LocalFile => LocalItemRegistry::get(self.id).expect("valid item ID"), _ => panic!("expected local file"), } } } impl Default for ItemId { fn default() -> Self { Self::INVALID } } impl From for String { fn from(id: ItemId) -> Self { id.to_base62() } } #[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] pub struct FileId(pub [u8; 20]); impl FileId { pub fn from_raw(data: &[u8]) -> Option { Some(FileId(data.try_into().ok()?)) } pub fn to_base16(&self) -> String { self.0 .iter() .map(|b| format!("{b:02x}")) .collect::>() .concat() } } impl Deref for FileId { type Target = [u8]; fn deref(&self) -> &Self::Target { &self.0 } } impl fmt::Debug for FileId { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { f.debug_tuple("FileId").field(&self.to_base16()).finish() } } impl fmt::Display for FileId { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { f.write_str(&self.to_base16()) } } ================================================ FILE: psst-core/src/lastfm.rs ================================================ use crate::error::Error; use crate::oauth::listen_for_callback_parameter; use rustfm_scrobble::{responses::SessionResponse, Scrobble, Scrobbler, ScrobblerError}; use std::{net::SocketAddr, time::Duration}; use url::Url; pub struct LastFmClient; impl LastFmClient { /// Report a track as "now playing" to Last.fm using an existing Scrobbler instance. pub fn now_playing_song( scrobbler: &Scrobbler, // Requires an authenticated Scrobbler artist: &str, title: &str, album: Option<&str>, ) -> Result<(), Error> { let song = Scrobble::new(artist, title, album.unwrap_or("")); scrobbler .now_playing(&song) .map(|_| ()) .map_err(Error::from) } /// Scrobble a finished track to Last.fm using an existing Scrobbler instance. pub fn scrobble_song( scrobbler: &Scrobbler, // Requires an authenticated Scrobbler artist: &str, title: &str, album: Option<&str>, ) -> Result<(), Error> { let song = Scrobble::new(artist, title, album.unwrap_or("")); scrobbler.scrobble(&song).map(|_| ()).map_err(Error::from) } /// Creates an authenticated Last.fm Scrobbler instance with provided credentials. /// Note: This assumes the session_key is valid. Validity is checked on first API call. pub fn create_scrobbler( api_key: Option<&str>, api_secret: Option<&str>, session_key: Option<&str>, ) -> Result { let (Some(api_key), Some(api_secret), Some(session_key)) = (api_key, api_secret, session_key) else { log::warn!("missing Last.fm API key, secret, or session key for scrobbler creation."); return Err(Error::ConfigError( "Missing Last.fm API key, secret, or session key.".to_string(), )); }; let mut scrobbler = Scrobbler::new(api_key, api_secret); // Associate the session key with the scrobbler instance. scrobbler.authenticate_with_session_key(session_key); log::info!("scrobbler instance created with session key (validity checked on first use)."); Ok(scrobbler) } } impl From for Error { fn from(value: ScrobblerError) -> Self { Self::ScrobblerError(Box::new(value)) } } /// Generate a Last.fm authentication URL pub fn generate_lastfm_auth_url( api_key: &str, callback_url: &str, ) -> Result { let base = "http://www.last.fm/api/auth/"; let url = Url::parse_with_params(base, &[("api_key", api_key), ("cb", callback_url)])?; Ok(url.to_string()) } /// Exchange a token for a Last.fm session key pub fn exchange_token_for_session( api_key: &str, api_secret: &str, token: &str, ) -> Result { let mut scrobbler = Scrobbler::new(api_key, api_secret); scrobbler .authenticate_with_token(token) // Uses auth.getSession API call internally .map(|response: SessionResponse| response.key) // Extract the session key string .map_err(Error::from) // Map ScrobblerError to crate::error::Error } /// Listen for a Last.fm token from the callback pub fn get_lastfm_token_listener( socket_address: SocketAddr, timeout: Duration, ) -> Result { // Use the shared listener function, specifying "token" as the parameter listen_for_callback_parameter(socket_address, timeout, "token") } ================================================ FILE: psst-core/src/lib.rs ================================================ #![allow(clippy::new_without_default)] use git_version::git_version; pub const GIT_VERSION: &str = git_version!(); pub const BUILD_TIME: &str = include!(concat!(env!("OUT_DIR"), "/build-time.txt")); pub const REMOTE_URL: &str = include!(concat!(env!("OUT_DIR"), "/remote-url.txt")); pub mod actor; pub mod audio; pub mod cache; pub mod cdn; pub mod connection; pub mod error; pub mod item_id; pub mod lastfm; pub mod metadata; pub mod oauth; pub mod player; pub mod session; pub mod system_info; pub mod util; ================================================ FILE: psst-core/src/metadata.rs ================================================ use std::time::Duration; use crate::{ error::Error, item_id::{FileId, ItemId, ItemIdType}, player::file::{AudioFormat, MediaFile, MediaPath}, session::SessionService, }; use librespot_protocol::metadata::restriction::Country_restriction; use librespot_protocol::metadata::{AudioFile, Episode, Restriction, Track}; pub trait Fetch: protobuf::Message { fn uri(id: ItemId) -> String; fn fetch(session: &SessionService, id: ItemId) -> Result { session.connected()?.get_mercury_protobuf(Self::uri(id)) } } impl Fetch for Track { fn uri(id: ItemId) -> String { format!("hm://metadata/3/track/{}", id.to_base16()) } } impl Fetch for Episode { fn uri(id: ItemId) -> String { format!("hm://metadata/3/episode/{}", id.to_base16()) } } pub trait ToMediaPath { fn is_restricted_in_region(&self, country: &str) -> bool; fn find_allowed_alternative(&self, country: &str) -> Option; fn to_media_path(&self, preferred_bitrate: usize) -> Option; } impl ToMediaPath for Track { fn is_restricted_in_region(&self, country: &str) -> bool { self.restriction .iter() .any(|rest| is_restricted_in_region(rest, country)) } fn find_allowed_alternative(&self, country: &str) -> Option { let alt_track = self .alternative .iter() .find(|alt_track| !alt_track.is_restricted_in_region(country))?; ItemId::from_raw(alt_track.gid.as_ref()?, ItemIdType::Track) } fn to_media_path(&self, preferred_bitrate: usize) -> Option { let file = select_preferred_file(&self.file, preferred_bitrate)?; Some(MediaPath { item_id: ItemId::from_raw(self.gid.as_ref()?, ItemIdType::Track)?, file_id: FileId::from_raw(file.file_id.as_ref()?)?, file_format: AudioFormat::from_protocol(file.format()), duration: Duration::from_millis(self.duration? as u64), }) } } impl ToMediaPath for Episode { fn is_restricted_in_region(&self, country: &str) -> bool { self.restriction .iter() .any(|rest| is_restricted_in_region(rest, country)) } fn find_allowed_alternative(&self, _country: &str) -> Option { None } fn to_media_path(&self, preferred_bitrate: usize) -> Option { let file = select_preferred_file(&self.audio, preferred_bitrate)?; Some(MediaPath { item_id: ItemId::from_raw(self.gid.as_ref()?, ItemIdType::Podcast)?, file_id: FileId::from_raw(file.file_id.as_ref()?)?, file_format: AudioFormat::from_protocol(file.format()), duration: Duration::from_millis(self.duration? as u64), }) } } fn select_preferred_file(files: &[AudioFile], preferred_bitrate: usize) -> Option<&AudioFile> { MediaFile::supported_audio_formats_for_bitrate(preferred_bitrate) .iter() .find_map(|&preferred_format| { files .iter() .find(|file| file.format == Some(preferred_format.into())) }) } fn is_restricted_in_region(restriction: &Restriction, country: &str) -> bool { if let Some(list) = &restriction.country_restriction { return match list { Country_restriction::CountriesAllowed(allowed) => { !is_country_in_list(allowed.as_bytes(), country.as_bytes()) } Country_restriction::CountriesForbidden(forbidden) => { is_country_in_list(forbidden.as_bytes(), country.as_bytes()) } _ => false, }; } false } fn is_country_in_list(countries: &[u8], country: &[u8]) -> bool { countries.chunks(2).any(|code| code == country) } ================================================ FILE: psst-core/src/oauth.rs ================================================ use crate::error::Error; use oauth2::{ basic::BasicClient, reqwest::http_client, AuthUrl, AuthorizationCode, ClientId, CsrfToken, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, Scope, TokenResponse, TokenUrl, }; use std::{ io::{BufRead, BufReader, Write}, net::TcpStream, net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener}, sync::mpsc, time::Duration, }; use url::Url; pub fn listen_for_callback_parameter( socket_address: SocketAddr, timeout: Duration, parameter_name: &'static str, ) -> Result { log::info!( "starting callback listener for '{parameter_name}' on {socket_address:?}", ); // Create a simpler, linear flow // 1. Bind the listener let listener = match TcpListener::bind(socket_address) { Ok(l) => { log::info!("listener bound successfully"); l } Err(e) => { log::error!("Failed to bind listener: {e}"); return Err(Error::IoError(e)); } }; // 2. Set up the channel for communication let (tx, rx) = mpsc::channel::>(); // 3. Spawn the thread let handle = std::thread::spawn(move || { if let Ok((mut stream, _)) = listener.accept() { handle_callback_connection(&mut stream, &tx, parameter_name); } else { log::error!("Failed to accept connection on callback listener"); let _ = tx.send(Err(Error::IoError(std::io::Error::other( "Failed to accept connection", )))); } }); // 4. Wait for the result with timeout let result = match rx.recv_timeout(timeout) { Ok(r) => r, Err(e) => { log::error!("Timed out or channel error: {e}"); return Err(Error::from(e)); } }; // 5. Wait for thread completion if handle.join().is_err() { log::warn!("thread join failed, but continuing with result"); } // 6. Return the result result } /// Handles an incoming TCP connection for a generic OAuth callback. fn handle_callback_connection( stream: &mut TcpStream, tx: &mpsc::Sender>, parameter_name: &'static str, ) { let mut reader = BufReader::new(&mut *stream); let mut request_line = String::new(); if reader.read_line(&mut request_line).is_ok() { match extract_parameter_from_request(&request_line, parameter_name) { Some(value) => { log::info!("received callback parameter '{parameter_name}'."); send_success_response(stream); let _ = tx.send(Ok(value)); } None => { let err_msg = format!( "Failed to extract parameter '{parameter_name}' from request: {request_line}", ); log::error!("{err_msg}"); let _ = tx.send(Err(Error::OAuthError(err_msg))); } } } else { log::error!("Failed to read request line from callback."); let _ = tx.send(Err(Error::IoError(std::io::Error::other( "Failed to read request line", )))); } } /// Extracts a specified query parameter from an HTTP request line. fn extract_parameter_from_request(request_line: &str, parameter_name: &str) -> Option { request_line .split_whitespace() .nth(1) .and_then(|path| Url::parse(&format!("http://localhost{path}")).ok()) .and_then(|url| { url.query_pairs() .find(|(key, _)| key == parameter_name) .map(|(_, value)| value.into_owned()) }) } pub fn get_authcode_listener( socket_address: SocketAddr, timeout: Duration, ) -> Result { listen_for_callback_parameter(socket_address, timeout, "code").map(AuthorizationCode::new) } pub fn send_success_response(stream: &mut TcpStream) { let response = "HTTP/1.1 200 OK\r\n\r\n\ \ \ \ \ \
Successfully authenticated! You can close this window now.
\ \ "; let _ = stream.write_all(response.as_bytes()); } fn create_spotify_oauth_client(redirect_port: u16) -> BasicClient { let redirect_address = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), redirect_port); let redirect_uri = format!("http://{redirect_address}/login"); BasicClient::new( ClientId::new(crate::session::access_token::CLIENT_ID.to_string()), None, AuthUrl::new("https://accounts.spotify.com/authorize".to_string()).unwrap(), Some(TokenUrl::new("https://accounts.spotify.com/api/token".to_string()).unwrap()), ) .set_redirect_uri(RedirectUrl::new(redirect_uri).expect("Invalid redirect URL")) } pub fn generate_auth_url(redirect_port: u16) -> (String, PkceCodeVerifier) { let client = create_spotify_oauth_client(redirect_port); let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256(); let (auth_url, _) = client .authorize_url(CsrfToken::new_random) .add_scopes(get_scopes()) .set_pkce_challenge(pkce_challenge) .url(); (auth_url.to_string(), pkce_verifier) } pub fn exchange_code_for_token( redirect_port: u16, code: AuthorizationCode, pkce_verifier: PkceCodeVerifier, ) -> String { let client = create_spotify_oauth_client(redirect_port); let token_response = client .exchange_code(code) .set_pkce_verifier(pkce_verifier) .request(http_client) .expect("Failed to exchange code for token"); token_response.access_token().secret().to_string() } fn get_scopes() -> Vec { crate::session::access_token::ACCESS_SCOPES .split(',') .map(|s| Scope::new(s.trim().to_string())) .collect() } ================================================ FILE: psst-core/src/player/file.rs ================================================ use std::{ fs, io, io::{Seek, SeekFrom}, path::PathBuf, sync::Arc, thread, thread::JoinHandle, time::Duration, }; use symphonia::core::codecs::CodecType; use crate::{ audio::{ decode::{AudioCodecFormat, AudioDecoder}, decrypt::{AudioDecrypt, AudioKey}, normalize::NormalizationData, }, cache::CacheHandle, cdn::{CdnHandle, CdnUrl}, error::Error, item_id::{FileId, ItemId}, util::OffsetFile, }; use librespot_protocol::metadata::audio_file::Format; use super::storage::{StreamRequest, StreamStorage, StreamWriter}; #[derive(Debug, Clone, Copy)] pub struct MediaPath { pub item_id: ItemId, pub file_id: FileId, pub file_format: AudioFormat, pub duration: Duration, } // possibly should be combined with AudioCodecFormat? #[derive(Debug, Clone, Copy)] pub enum AudioFormat { Mp3, OggVorbis, Unsupported, } impl AudioFormat { pub fn from_protocol(format: Format) -> Self { use Format::*; match format { MP3_256 | MP3_320 | MP3_160 | MP3_96 | MP3_160_ENC => Self::Mp3, OGG_VORBIS_96 | OGG_VORBIS_160 | OGG_VORBIS_320 => Self::OggVorbis, _ => Self::Unsupported, } } pub fn from_codec(codec: CodecType) -> Self { use symphonia::core::codecs::*; if codec == CODEC_TYPE_MP3 { Self::Mp3 } else if codec == CODEC_TYPE_VORBIS { Self::OggVorbis } else { Self::Unsupported } } } pub enum MediaFile { Streamed { streamed_file: Arc, servicing_handle: JoinHandle<()>, }, Cached { cached_file: CachedFile, }, Local { path: MediaPath, }, } impl MediaFile { pub fn supported_audio_formats_for_bitrate(bitrate: usize) -> &'static [Format] { match bitrate { 96 => &[ Format::OGG_VORBIS_96, Format::MP3_96, Format::OGG_VORBIS_160, Format::MP3_160, Format::MP3_160_ENC, Format::MP3_256, Format::OGG_VORBIS_320, Format::MP3_320, ], 160 => &[ Format::OGG_VORBIS_160, Format::MP3_160, Format::MP3_160_ENC, Format::MP3_256, Format::OGG_VORBIS_320, Format::MP3_320, Format::OGG_VORBIS_96, Format::MP3_96, ], 320 => &[ Format::OGG_VORBIS_320, Format::MP3_320, Format::MP3_256, Format::OGG_VORBIS_160, Format::MP3_160, Format::MP3_160_ENC, Format::OGG_VORBIS_96, Format::MP3_96, ], _ => unreachable!(), } } pub fn open(path: MediaPath, cdn: CdnHandle, cache: CacheHandle) -> Result { let cached_path = cache.audio_file_path(path.file_id); if cached_path.exists() { let cached_file = CachedFile::open(path, cached_path)?; Ok(Self::Cached { cached_file }) } else { let streamed_file = Arc::new(StreamedFile::open(path, cdn, cache)?); let servicing_handle = thread::spawn({ let streamed_file = Arc::clone(&streamed_file); move || { streamed_file .service_streaming() .expect("Streaming thread failed"); } }); Ok(Self::Streamed { streamed_file, servicing_handle, }) } } pub fn local(path: MediaPath) -> Self { Self::Local { path } } pub fn path(&self) -> MediaPath { match self { Self::Streamed { streamed_file, .. } => streamed_file.path, Self::Cached { cached_file, .. } => cached_file.path, Self::Local { path } => *path, } } pub fn storage(&self) -> Option<&StreamStorage> { match self { Self::Streamed { streamed_file, .. } => Some(&streamed_file.storage), Self::Cached { cached_file, .. } => Some(&cached_file.storage), Self::Local { .. } => None, } } pub fn remote_audio_source( &self, key: AudioKey, ) -> Result<(AudioDecoder, NormalizationData), Error> { let reader = self .storage() .expect("storage always set for remote files") .reader()?; let mut decrypted = AudioDecrypt::new(key, reader); let normalization = NormalizationData::parse(&mut decrypted)?; let encoded = OffsetFile::new(decrypted, self.header_length())?; let decoded = AudioDecoder::new(encoded, self.codec_format())?; Ok((decoded, normalization)) } pub fn local_audio_source(&self) -> Result<(AudioDecoder, NormalizationData), Error> { let mut reader = fs::File::open(self.path().item_id.to_local())?; let normalization = NormalizationData::parse(&mut reader)?; let encoded = OffsetFile::new(reader, self.header_length())?; let decoded = AudioDecoder::new(encoded, self.codec_format())?; Ok((decoded, normalization)) } fn header_length(&self) -> u64 { match self.path().file_format { AudioFormat::OggVorbis => 167, _ => 0, } } fn codec_format(&self) -> AudioCodecFormat { match self.path().file_format { AudioFormat::OggVorbis => AudioCodecFormat::OggVorbis, AudioFormat::Mp3 => AudioCodecFormat::Mp3, AudioFormat::Unsupported => unreachable!("unsupported codec"), } } } pub struct StreamedFile { path: MediaPath, storage: StreamStorage, url: CdnUrl, cdn: CdnHandle, cache: CacheHandle, } impl StreamedFile { fn open(path: MediaPath, cdn: CdnHandle, cache: CacheHandle) -> Result { // First, we need to resolve URL of the file contents. let url = cdn.resolve_audio_file_url(path.file_id)?; log::debug!("resolved file URL: {:?}", url.url); // How many bytes we request in the first chunk. const INITIAL_REQUEST_LENGTH: u64 = 1024 * 6; // Send the initial request, that gives us the total file length and the // beginning of the contents. Use the total length for creating the backing // data storage. let (total_length, mut initial_data) = cdn.fetch_file_range(&url.url, 0, INITIAL_REQUEST_LENGTH)?; let storage = StreamStorage::new(total_length)?; // Pipe the initial data from the request body into storage. io::copy(&mut initial_data, &mut storage.writer()?)?; Ok(StreamedFile { path, storage, url, cdn, cache, }) } fn service_streaming(&self) -> Result<(), Error> { let mut last_url = self.url.clone(); let mut fresh_url = || -> Result { if last_url.is_expired() { last_url = self.cdn.resolve_audio_file_url(self.path.file_id)?; } Ok(last_url.clone()) }; let mut download_range = |offset, length| -> Result<(), Error> { let thread_name = format!( "cdn-{}-{}..{}", self.path.file_id.to_base16(), offset, offset + length ); // TODO: We spawn threads here without any accounting. Seems wrong. thread::Builder::new().name(thread_name).spawn({ let url = fresh_url()?.url; let cdn = self.cdn.clone(); let cache = self.cache.clone(); let mut writer = self.storage.writer()?; let file_path = self.storage.path().to_path_buf(); let file_id = self.path.file_id; move || { match load_range(&mut writer, &cdn, &url, offset, length) { Ok(_) => { // If the file is completely downloaded, copy it to cache. if writer.is_complete() && !cache.audio_file_path(file_id).exists() { // TODO: We should do this atomically. if let Err(err) = cache.save_audio_file(file_id, file_path) { log::warn!("failed to save audio file to cache: {err:?}"); } } } Err(err) => { log::error!("failed to download: {err}"); // Range failed to download, remove it from the requested set. writer.mark_as_not_requested(offset, length); } } } })?; Ok(()) }; while let Ok(req) = self.storage.receiver().recv() { match req { StreamRequest::Preload { offset, length } => { if let Err(err) = download_range(offset, length) { log::error!("failed to request audio range: {err:?}"); } } StreamRequest::Blocked { offset } => { log::info!("blocked at {offset}"); } } } Ok(()) } } pub struct CachedFile { path: MediaPath, storage: StreamStorage, } impl CachedFile { fn open(path: MediaPath, file_path: PathBuf) -> Result { Ok(Self { path, storage: StreamStorage::from_complete_file(file_path)?, }) } } fn load_range( writer: &mut StreamWriter, cdn: &CdnHandle, url: &str, offset: u64, length: u64, ) -> Result<(), Error> { log::trace!("downloading {}..{}", offset, offset + length); // Download range of data from the CDN. Block until we a have reader of the // request body. let (_total_length, mut reader) = cdn.fetch_file_range(url, offset, length)?; // Pipe it into storage. Blocks until fully written, but readers sleeping on // this file should be notified as soon as their offset is covered. writer.seek(SeekFrom::Start(offset))?; io::copy(&mut reader, writer)?; Ok(()) } ================================================ FILE: psst-core/src/player/item.rs ================================================ use std::time::Duration; use crate::{ audio::{ decode::AudioDecoder, decrypt::AudioKey, normalize::NormalizationLevel, probe::TrackProbe, }, cache::CacheHandle, cdn::CdnHandle, error::Error, item_id::{ItemId, ItemIdType, LocalItemRegistry}, metadata::{Fetch, ToMediaPath}, session::SessionService, }; use librespot_protocol::metadata::{Episode, Track}; use super::{ file::{AudioFormat, MediaFile, MediaPath}, PlaybackConfig, }; pub struct LoadedPlaybackItem { pub file: MediaFile, pub source: AudioDecoder, pub norm_factor: f32, } #[derive(Debug, Clone, Copy, Eq, PartialEq)] pub struct PlaybackItem { pub item_id: ItemId, pub norm_level: NormalizationLevel, } impl PlaybackItem { pub fn load( &self, session: &SessionService, cdn: CdnHandle, cache: CacheHandle, config: &PlaybackConfig, ) -> Result { let path = load_media_path(self.item_id, session, &cache, config)?; let (file, source, norm_data) = match self.item_id.id_type { ItemIdType::LocalFile => { let file = MediaFile::local(path); let (source, norm_data) = file.local_audio_source()?; (file, source, norm_data) } _ => { let key = load_audio_key(&path, session, &cache)?; let file = MediaFile::open(path, cdn, cache)?; let (source, norm_data) = file.remote_audio_source(key)?; (file, source, norm_data) } }; let norm_factor = norm_data.factor_for_level(self.norm_level, config.pregain); Ok(LoadedPlaybackItem { file, source, norm_factor, }) } } fn load_media_path( item_id: ItemId, session: &SessionService, cache: &CacheHandle, config: &PlaybackConfig, ) -> Result { match item_id.id_type { ItemIdType::Track => { load_media_path_from_track_or_alternative(item_id, session, cache, config) } ItemIdType::Podcast => load_media_path_from_episode(item_id, session, cache, config), ItemIdType::LocalFile => load_media_path_from_local(item_id), ItemIdType::Unknown => unimplemented!(), } } fn load_media_path_from_track_or_alternative( item_id: ItemId, session: &SessionService, cache: &CacheHandle, config: &PlaybackConfig, ) -> Result { let track = load_track(item_id, session, cache)?; let country = get_country_code(session, cache); let path = match country { Some(user_country) if track.is_restricted_in_region(&user_country) => { // The track is regionally restricted and is unavailable. Let's try to find an // alternative track. let alt_id = track .find_allowed_alternative(&user_country) .ok_or(Error::MediaFileNotFound)?; let alt_track = load_track(alt_id, session, cache)?; let alt_path = alt_track .to_media_path(config.bitrate) .ok_or(Error::MediaFileNotFound)?; // We've found an alternative track with a fitting audio file. Let's cheat a // little and pretend we've obtained it from the requested track. // TODO: We should be honest and display the real track information. MediaPath { item_id, ..alt_path } } _ => { // Either we do not have a country code loaded or the track is available, return // it. track .to_media_path(config.bitrate) .ok_or(Error::MediaFileNotFound)? } }; Ok(path) } fn load_media_path_from_episode( item_id: ItemId, session: &SessionService, cache: &CacheHandle, config: &PlaybackConfig, ) -> Result { let episode = load_episode(item_id, session, cache)?; let country = get_country_code(session, cache); let path = match country { Some(user_country) if episode.is_restricted_in_region(&user_country) => { // Episode is restricted, and doesn't have any alternatives. return Err(Error::MediaFileNotFound); } _ => episode .to_media_path(config.bitrate) .ok_or(Error::MediaFileNotFound)?, }; Ok(path) } fn load_media_path_from_local(item_id: ItemId) -> Result { let path = LocalItemRegistry::get(item_id.id).expect("valid local item ID"); let probe = TrackProbe::new(&path)?; Ok(MediaPath { item_id, file_id: Default::default(), file_format: AudioFormat::from_codec(probe.codec), // It's possible (though unlikely) that we're unable to determine the track // duration from the codec params; in that case, default to 0 and let it // be calculated at runtime as we play the track. duration: probe.duration.unwrap_or(Duration::from_millis(0)), }) } fn get_country_code(session: &SessionService, cache: &CacheHandle) -> Option { if let Some(cached_country_code) = cache.get_country_code() { Some(cached_country_code) } else { let country_code = session.connected().ok()?.get_country_code()?; if let Err(err) = cache.save_country_code(&country_code) { log::warn!("failed to save country code to cache: {err:?}"); } Some(country_code) } } fn load_track( item_id: ItemId, session: &SessionService, cache: &CacheHandle, ) -> Result { if let Some(cached_track) = cache.get_track(item_id) { Ok(cached_track) } else { let track = Track::fetch(session, item_id)?; if let Err(err) = cache.save_track(item_id, &track) { log::warn!("failed to save track to cache: {err:?}"); } Ok(track) } } fn load_episode( item_id: ItemId, session: &SessionService, cache: &CacheHandle, ) -> Result { if let Some(cached_episode) = cache.get_episode(item_id) { Ok(cached_episode) } else { let episode = Episode::fetch(session, item_id)?; if let Err(err) = cache.save_episode(item_id, &episode) { log::warn!("failed to save episode to cache: {err:?}"); } Ok(episode) } } fn load_audio_key( path: &MediaPath, session: &SessionService, cache: &CacheHandle, ) -> Result { if let Some(cached_key) = cache.get_audio_key(path.item_id, path.file_id) { Ok(cached_key) } else { let key = session .connected()? .get_audio_key(path.item_id, path.file_id)?; if let Err(err) = cache.save_audio_key(path.item_id, path.file_id, &key) { log::warn!("failed to save audio key to cache: {err:?}"); } Ok(key) } } ================================================ FILE: psst-core/src/player/mod.rs ================================================ pub mod file; pub mod item; pub mod queue; mod storage; mod worker; use std::{mem, thread, thread::JoinHandle, time::Duration}; use crossbeam_channel::{unbounded, Receiver, Sender}; use crate::{ audio::output::{AudioOutput, AudioSink, DefaultAudioOutput, DefaultAudioSink}, cache::CacheHandle, cdn::CdnHandle, error::Error, session::SessionService, }; use self::{ file::MediaPath, item::{LoadedPlaybackItem, PlaybackItem}, queue::{Queue, QueueBehavior}, worker::PlaybackManager, }; const PREVIOUS_TRACK_THRESHOLD: Duration = Duration::from_secs(3); const STOP_AFTER_CONSECUTIVE_LOADING_FAILURES: usize = 3; #[derive(Clone)] pub struct PlaybackConfig { pub bitrate: usize, pub pregain: f32, } impl Default for PlaybackConfig { fn default() -> Self { Self { bitrate: 320, pregain: 3.0, } } } pub struct Player { state: PlayerState, preload: PreloadState, session: SessionService, cdn: CdnHandle, cache: CacheHandle, config: PlaybackConfig, queue: Queue, sender: Sender, receiver: Receiver, audio_output_sink: DefaultAudioSink, playback_mgr: PlaybackManager, consecutive_loading_failures: usize, } impl Player { pub fn new( session: SessionService, cdn: CdnHandle, cache: CacheHandle, config: PlaybackConfig, audio_output: &DefaultAudioOutput, ) -> Self { let (sender, receiver) = unbounded(); Self { playback_mgr: PlaybackManager::new(audio_output.sink(), sender.clone()), session, cdn, cache, config, sender, receiver, audio_output_sink: audio_output.sink(), state: PlayerState::Stopped, preload: PreloadState::None, queue: Queue::new(), consecutive_loading_failures: 0, } } pub fn sender(&self) -> Sender { self.sender.clone() } pub fn receiver(&self) -> Receiver { self.receiver.clone() } pub fn handle(&mut self, event: PlayerEvent) { match event { PlayerEvent::Command(cmd) => self.handle_command(cmd), PlayerEvent::Loaded { item, result } => self.handle_loaded(item, result), PlayerEvent::Preloaded { item, result } => self.handle_preloaded(item, result), PlayerEvent::Position { position, path } => self.handle_position(position, path), PlayerEvent::EndOfTrack => self.handle_end_of_track(), PlayerEvent::Loading { .. } | PlayerEvent::Playing { .. } | PlayerEvent::Pausing { .. } | PlayerEvent::Resuming { .. } | PlayerEvent::Stopped | PlayerEvent::Blocked { .. } => {} }; } fn handle_command(&mut self, cmd: PlayerCommand) { match cmd { PlayerCommand::LoadQueue { items, position } => self.load_queue(items, position), PlayerCommand::LoadAndPlay { item } => self.load_and_play(item), PlayerCommand::Preload { item } => self.preload(item), PlayerCommand::Pause => self.pause(), PlayerCommand::Resume => self.resume(), PlayerCommand::PauseOrResume => self.pause_or_resume(), PlayerCommand::Previous => self.previous(), PlayerCommand::Next => self.next(), PlayerCommand::Stop => self.stop(), PlayerCommand::Seek { position } => self.seek(position), PlayerCommand::Configure { config } => self.configure(config), PlayerCommand::SetQueueBehavior { behavior } => self.queue.set_behaviour(behavior), PlayerCommand::AddToQueue { item } => self.queue.add(item), PlayerCommand::SetVolume { volume } => self.set_volume(volume), } } fn handle_loaded(&mut self, item: PlaybackItem, result: Result) { match self.state { PlayerState::Loading { item: requested_item, .. } if item == requested_item => match result { Ok(loaded_item) => { self.consecutive_loading_failures = 0; self.play_loaded(loaded_item); } Err(err) => { self.consecutive_loading_failures += 1; if self.consecutive_loading_failures < STOP_AFTER_CONSECUTIVE_LOADING_FAILURES { log::error!("skipping, error while loading: {err}"); self.next(); } else { log::error!("stopping, error while loading: {err}"); self.stop(); } } }, _ => { log::info!("stale load result received, ignoring"); } } } fn handle_preloaded(&mut self, item: PlaybackItem, result: Result) { match self.preload { PreloadState::Preloading { item: requested_item, .. } if item == requested_item => match result { Ok(loaded_item) => { log::info!("preloaded audio file"); self.preload = PreloadState::Preloaded { item, loaded_item }; } Err(err) => { log::error!("failed to preload audio file, error while opening: {err}"); self.preload = PreloadState::None; } }, _ => { log::info!("stale preload result received, ignoring"); // We are not preloading this item, but because we sometimes extract the // preloading thread and use it for loading, let's check if the item is not // being loaded now. self.handle_loaded(item, result); } } } fn handle_position(&mut self, new_position: Duration, path: MediaPath) { match &mut self.state { PlayerState::Playing { position, .. } | PlayerState::Paused { position, .. } => { *position = new_position; } _ => { log::warn!("received unexpected position report"); } } const PRELOAD_BEFORE_END_OF_TRACK: Duration = Duration::from_secs(30); let time_until_end_of_track = path.duration.checked_sub(new_position).unwrap_or_default(); if time_until_end_of_track <= PRELOAD_BEFORE_END_OF_TRACK { if let Some(&item_to_preload) = self.queue.get_following() { self.preload(item_to_preload); } } } fn handle_end_of_track(&mut self) { self.queue.skip_to_following(); if let Some(&item) = self.queue.get_current() { self.load_and_play(item); } else { self.stop(); } } fn load_queue(&mut self, items: Vec, position: usize) { self.queue.fill(items, position); if let Some(&item) = self.queue.get_current() { self.load_and_play(item); } else { self.stop(); } } fn load_and_play(&mut self, item: PlaybackItem) { // Make sure to stop the sink, so any current audio source is cleared and the // playback stopped. self.audio_output_sink.stop(); // Check if the item is already in the preloader state. let loading_handle = match mem::replace(&mut self.preload, PreloadState::None) { PreloadState::Preloaded { item: preloaded_item, loaded_item, } if preloaded_item == item => { // This item is already loaded in the preloader state. self.play_loaded(loaded_item); return; } PreloadState::Preloading { item: preloaded_item, loading_handle, } if preloaded_item == item => { // This item is being preloaded. Take it out of the preloader state. loading_handle } preloading_other_file_or_none => { self.preload = preloading_other_file_or_none; // Item is not preloaded yet, load it in a background thread. thread::spawn({ let sender = self.sender.clone(); let session = self.session.clone(); let cdn = self.cdn.clone(); let cache = self.cache.clone(); let config = self.config.clone(); move || { let result = item.load(&session, cdn, cache, &config); sender.send(PlayerEvent::Loaded { item, result }).unwrap(); } }) } }; self.sender.send(PlayerEvent::Loading { item }).unwrap(); self.state = PlayerState::Loading { item, _loading_handle: loading_handle, }; } fn preload(&mut self, item: PlaybackItem) { if self.is_in_preload(item) { return; } let loading_handle = thread::spawn({ let sender = self.sender.clone(); let session = self.session.clone(); let cdn = self.cdn.clone(); let cache = self.cache.clone(); let config = self.config.clone(); move || { let result = item.load(&session, cdn, cache, &config); sender .send(PlayerEvent::Preloaded { item, result }) .unwrap(); } }); self.preload = PreloadState::Preloading { item, loading_handle, }; } fn set_volume(&mut self, volume: f64) { self.audio_output_sink.set_volume(volume as f32); } fn play_loaded(&mut self, loaded_item: LoadedPlaybackItem) { log::info!("starting playback"); let path = loaded_item.file.path(); let position = Duration::default(); self.playback_mgr.play(loaded_item); self.state = PlayerState::Playing { path, position }; self.sender .send(PlayerEvent::Playing { path, position }) .unwrap(); } fn pause(&mut self) { match mem::replace(&mut self.state, PlayerState::Invalid) { PlayerState::Playing { path, position } | PlayerState::Paused { path, position } => { log::info!("pausing playback"); self.audio_output_sink.pause(); self.sender .send(PlayerEvent::Pausing { path, position }) .unwrap(); self.state = PlayerState::Paused { path, position }; } _ => { log::warn!("invalid state transition"); } } } fn resume(&mut self) { match mem::replace(&mut self.state, PlayerState::Invalid) { PlayerState::Playing { path, position } | PlayerState::Paused { path, position } => { log::info!("resuming playback"); self.audio_output_sink.resume(); self.sender .send(PlayerEvent::Resuming { path, position }) .unwrap(); self.state = PlayerState::Playing { path, position }; } _ => { log::warn!("invalid state transition"); } } } fn pause_or_resume(&mut self) { match &self.state { PlayerState::Playing { .. } => self.pause(), PlayerState::Paused { .. } => self.resume(), _ => { // Do nothing. } } } fn previous(&mut self) { if self.is_near_playback_start() { self.queue.skip_to_previous(); if let Some(&item) = self.queue.get_current() { self.load_and_play(item); } else { self.stop(); } } else { self.seek(Duration::default()); } } fn next(&mut self) { self.queue.skip_to_next(); if let Some(&item) = self.queue.get_current() { self.load_and_play(item); } else { self.stop(); } } fn stop(&mut self) { self.sender.send(PlayerEvent::Stopped).unwrap(); self.audio_output_sink.stop(); self.state = PlayerState::Stopped; self.queue.clear(); self.consecutive_loading_failures = 0; } fn seek(&mut self, position: Duration) { self.playback_mgr.seek(position); } fn configure(&mut self, config: PlaybackConfig) { self.config = config; } fn is_near_playback_start(&self) -> bool { match self.state { PlayerState::Playing { position, .. } | PlayerState::Paused { position, .. } => { position < PREVIOUS_TRACK_THRESHOLD } _ => false, } } fn is_in_preload(&self, item: PlaybackItem) -> bool { match self.preload { PreloadState::Preloading { item: p_item, .. } | PreloadState::Preloaded { item: p_item, .. } => p_item == item, _ => false, } } } pub enum PlayerCommand { LoadQueue { items: Vec, position: usize, }, LoadAndPlay { item: PlaybackItem, }, Preload { item: PlaybackItem, }, Pause, Resume, PauseOrResume, Previous, Next, Stop, Seek { position: Duration, }, Configure { config: PlaybackConfig, }, SetQueueBehavior { behavior: QueueBehavior, }, AddToQueue { item: PlaybackItem, }, /// Change playback volume to a value in 0.0..=1.0 range. SetVolume { volume: f64, }, } pub enum PlayerEvent { Command(PlayerCommand), /// Track has started loading. `Loaded` follows. Loading { item: PlaybackItem, }, /// Track loading either succeeded or failed. `Playing` follows in case of /// success. Loaded { item: PlaybackItem, result: Result, }, /// Next item in queue has been either successfully preloaded or failed to /// preload. Preloaded { item: PlaybackItem, result: Result, }, /// Player has started playing new track. `Position` events will follow. Playing { path: MediaPath, position: Duration, }, /// Player is in a paused state. `Resuming` might follow. Pausing { path: MediaPath, position: Duration, }, /// Player is resuming playback of a track. `Position` events will follow. Resuming { path: MediaPath, position: Duration, }, /// Position of the playback head has changed. Position { path: MediaPath, position: Duration, }, /// Player would like to continue playing, but is blocked, waiting for I/O. Blocked { path: MediaPath, position: Duration, }, /// Player has finished playing a track. `Loading` or `Playing` might /// follow if the queue is not empty, `Stopped` will follow if it is. EndOfTrack, /// The queue is empty. Stopped, } enum PlayerState { Loading { item: PlaybackItem, _loading_handle: JoinHandle<()>, }, Playing { path: MediaPath, position: Duration, }, Paused { path: MediaPath, position: Duration, }, Stopped, Invalid, } enum PreloadState { Preloading { item: PlaybackItem, loading_handle: JoinHandle<()>, }, Preloaded { item: PlaybackItem, loaded_item: LoadedPlaybackItem, }, None, } ================================================ FILE: psst-core/src/player/queue.rs ================================================ use rand::prelude::SliceRandom; use super::PlaybackItem; #[derive(Default, Debug)] pub enum QueueBehavior { #[default] Sequential, Random, LoopTrack, LoopAll, } pub struct Queue { items: Vec, user_items: Vec, position: usize, user_items_position: usize, positions: Vec, behavior: QueueBehavior, } impl Queue { pub fn new() -> Self { Self { items: Vec::new(), user_items: Vec::new(), position: 0, user_items_position: 0, positions: Vec::new(), behavior: QueueBehavior::default(), } } pub fn clear(&mut self) { self.items.clear(); self.positions.clear(); self.position = 0; } pub fn fill(&mut self, items: Vec, position: usize) { self.positions.clear(); self.items = items; self.position = position; self.compute_positions(); } pub fn add(&mut self, item: PlaybackItem) { self.user_items.push(item); } fn handle_added_queue(&mut self) { if self.user_items.len() > self.user_items_position { self.items.insert( self.positions.len(), self.user_items[self.user_items_position], ); self.positions .insert(self.position + 1, self.positions.len()); self.user_items_position += 1; } } pub fn set_behaviour(&mut self, behavior: QueueBehavior) { self.behavior = behavior; self.compute_positions(); } fn compute_positions(&mut self) { // In the case of switching away from shuffle, the position should be set back to // where it appears in the actual playlist order. let playlist_position = if self.positions.len() > 1 { self.positions[self.position] } else { self.position }; // Start with an ordered 1:1 mapping. self.positions = (0..self.items.len()).collect(); if let QueueBehavior::Random = self.behavior { // Swap the current position with the first item, so we will start from the // beginning, with the full queue ahead of us. Then shuffle the rest of the // items and set the position to 0. if self.positions.len() > 1 { self.positions.swap(0, self.position); self.positions[1..].shuffle(&mut rand::rng()); } self.position = 0; } else { self.position = playlist_position; } } pub fn skip_to_previous(&mut self) { self.position = self.previous_position(); } pub fn skip_to_next(&mut self) { self.handle_added_queue(); self.position = self.next_position(); } pub fn skip_to_following(&mut self) { self.handle_added_queue(); self.position = self.following_position(); } pub fn get_current(&self) -> Option<&PlaybackItem> { let position = self.positions.get(self.position).copied()?; self.items.get(position) } pub fn get_following(&self) -> Option<&PlaybackItem> { if let Some(position) = self.positions.get(self.position).copied() { if let Some(item) = self.items.get(position) { return Some(item); } } else { return self.user_items.first(); } None } fn previous_position(&self) -> usize { match self.behavior { QueueBehavior::Sequential | QueueBehavior::Random | QueueBehavior::LoopTrack | QueueBehavior::LoopAll => self.position.saturating_sub(1), } } fn next_position(&self) -> usize { match self.behavior { QueueBehavior::Sequential | QueueBehavior::Random | QueueBehavior::LoopTrack => { self.position + 1 } QueueBehavior::LoopAll => (self.position + 1) % self.items.len(), } } fn following_position(&self) -> usize { match self.behavior { QueueBehavior::Sequential | QueueBehavior::Random => self.position + 1, QueueBehavior::LoopTrack => self.position, QueueBehavior::LoopAll => (self.position + 1) % self.items.len(), } } } ================================================ FILE: psst-core/src/player/storage.rs ================================================ use std::{ fs::File, io, io::{Read, Seek, SeekFrom, Write}, ops::Range, path::{Path, PathBuf}, sync::Arc, }; use crossbeam_channel::{unbounded, Receiver, Sender}; use parking_lot::{Condvar, Mutex}; use rangemap::RangeSet; use tempfile::NamedTempFile; pub enum StreamRequest { Preload { offset: u64, length: u64 }, Blocked { offset: u64 }, } pub struct StreamStorage { file: StreamFile, data_map: Arc, req_receiver: Receiver, req_sender: Sender, } pub struct StreamReader { reader: File, data_map: Arc, req_sender: Sender, } pub struct StreamWriter { writer: File, data_map: Arc, } impl StreamStorage { pub fn new(total_size: u64) -> io::Result { // Use a temporary file for the backing storage, stretched to the full size, so // we can seek freely. let tmp_file = NamedTempFile::new()?; tmp_file.as_file().set_len(total_size)?; // Create a channel for requesting downloads of data. let (data_req_sender, data_req_receiver) = unbounded(); Ok(StreamStorage { file: StreamFile::Temporary(tmp_file), req_receiver: data_req_receiver, req_sender: data_req_sender, data_map: Arc::new(StreamDataMap { total_size, downloaded: Mutex::new(RangeSet::new()), requested: Mutex::new(RangeSet::new()), condvar: Condvar::new(), }), }) } pub fn from_complete_file(path: PathBuf) -> io::Result { // Query for the total file size. let total_size = path.metadata()?.len(); // Create the data channel even though it will not be used, as the file should // be complete. We could also turn these into `Option`s. let (data_req_sender, data_req_receiver) = unbounded(); // Because the file is complete, let's mark the full range of data as // downloaded. We mark it as requested as well, because the downloaded set is // always ⊆ the requested set. let mut downloaded_set = RangeSet::new(); downloaded_set.insert(0..total_size); let requested_set = downloaded_set.clone(); Ok(StreamStorage { file: StreamFile::Persisted(path), req_receiver: data_req_receiver, req_sender: data_req_sender, data_map: Arc::new(StreamDataMap { total_size, downloaded: Mutex::new(downloaded_set), requested: Mutex::new(requested_set), condvar: Condvar::new(), }), }) } pub fn reader(&self) -> io::Result { Ok(StreamReader { reader: self.file.reopen()?, // Re-opened files have a starting seek position. data_map: self.data_map.clone(), req_sender: self.req_sender.clone(), }) } pub fn writer(&self) -> io::Result { Ok(StreamWriter { writer: self.file.reopen()?, // Re-opened files have a starting seek position. data_map: self.data_map.clone(), }) } pub fn receiver(&self) -> &Receiver { &self.req_receiver } pub fn path(&self) -> &Path { self.file.path() } } enum StreamFile { Temporary(NamedTempFile), Persisted(PathBuf), } impl StreamFile { fn reopen(&self) -> io::Result { match self { StreamFile::Temporary(tmp_file) => tmp_file.reopen(), StreamFile::Persisted(path) => File::open(path), } } fn path(&self) -> &Path { match self { StreamFile::Temporary(tmp_file) => tmp_file.path(), StreamFile::Persisted(path) => path, } } } impl StreamWriter { pub fn is_complete(&self) -> bool { self.data_map.is_complete() } pub fn mark_as_not_requested(&self, offset: u64, length: u64) { self.data_map.mark_as_not_requested(offset, length); } } impl Write for StreamWriter { fn write(&mut self, buf: &[u8]) -> io::Result { let position = self.writer.stream_position()?; let written = self.writer.write(buf)?; self.data_map.mark_as_downloaded(position, written as u64); Ok(written) } fn flush(&mut self) -> io::Result<()> { self.writer.flush() } } impl Seek for StreamWriter { fn seek(&mut self, pos: SeekFrom) -> io::Result { self.writer.seek(pos) } } const MINIMUM_READ_LENGTH: u64 = 1024 * 64; const PREFETCH_READ_LENGTH: u64 = 1024 * 256; impl Read for StreamReader { fn read(&mut self, buf: &mut [u8]) -> io::Result { let position = self.reader.stream_position()?; let remaining_len = self.data_map.remaining(position); if remaining_len == 0 { return Ok(0); // We're at the end of the file. } let needed_len = remaining_len.min(buf.len() as u64); // Make sure that at least `PREFETCH_READ_LENGTH` bytes in front of the reading // head is requested. let prefetch_len = needed_len.max(PREFETCH_READ_LENGTH).min(remaining_len); for (pos, len) in self.data_map.not_yet_requested(position, prefetch_len) { let req_len = len.max(MINIMUM_READ_LENGTH); self.data_map.mark_as_requested(pos, req_len); self.req_sender .send(StreamRequest::Preload { offset: pos, length: req_len, }) .expect("Data request channel was closed"); } // Block and wait until at least a part of the range is available, and read it. let ready_to_read_len = self.data_map.wait_for(position, |offset| { // Notify the servicing thread we are blocked, so it can possibly prioritize the // blocked offset. self.req_sender .send(StreamRequest::Blocked { offset }) .expect("Data request channel was closed"); }); assert!(ready_to_read_len > 0); self.reader .read(&mut buf[..ready_to_read_len.min(needed_len) as usize]) } } impl Seek for StreamReader { fn seek(&mut self, pos: SeekFrom) -> io::Result { self.reader.seek(pos) } } #[derive(Debug)] struct StreamDataMap { total_size: u64, // Contains ranges of data requested from the server. Downloaded ranges are not removed from // this set. requested: Mutex>, // Contains ranges of data sure to be present in the backing storage. Always a subset of the // requested ranges. downloaded: Mutex>, condvar: Condvar, } impl StreamDataMap { /// Return the number of bytes from offset until the end of the file. fn remaining(&self, offset: u64) -> u64 { self.total_size.saturating_sub(offset) } /// Return a vector of sub-ranges of `offset..offset+length` that have not /// yet been requested from the backend. fn not_yet_requested(&self, offset: u64, length: u64) -> Vec<(u64, u64)> { self.requested .lock() .gaps(&(offset..offset + length)) .map(|r| range_to_offset_and_length(&r)) .collect() } /// Mark given range as requested from the backend, so we can avoid /// requesting it more than once. fn mark_as_requested(&self, offset: u64, length: u64) { self.requested.lock().insert(offset..offset + length); } /// Remove range previously marked as requested. fn mark_as_not_requested(&self, offset: u64, length: u64) { self.requested.lock().remove(offset..offset + length); } /// Mark the range as downloaded and notify the `self.condvar`, so tasks /// currently blocked in `self.wait_for` are woken up. fn mark_as_downloaded(&self, offset: u64, length: u64) { self.downloaded.lock().insert(offset..offset + length); self.condvar.notify_all(); } /// Block, waiting until at least some data at given offset is downloaded. /// Returns length that is available. See `self.mark_as_downloaded`. fn wait_for(&self, offset: u64, blocking_callback: impl Fn(u64)) -> u64 { let mut downloaded = self.downloaded.lock(); let mut called_callback = false; loop { if let Some(range) = downloaded.get(&offset) { let (over_ofs, over_len) = range_to_offset_and_length(range); let offset_from_overlapping = offset - over_ofs; let available_len = over_len - offset_from_overlapping; // There is `available_len` bytes of data downloaded, stop waiting. break available_len; } else { // Call the blocking callback, but only the first time we are waiting. if !called_callback { called_callback = true; blocking_callback(offset); } // There are no overlaps, wait. self.condvar.wait(&mut downloaded); } } } // Returns true if data is completely downloaded. fn is_complete(&self) -> bool { self.downloaded .lock() .gaps(&(0..self.total_size)) .next() .is_none() } } fn range_to_offset_and_length(range: &Range) -> (u64, u64) { (range.start, range.end - range.start) } ================================================ FILE: psst-core/src/player/worker.rs ================================================ use std::{ ops::Range, sync::{ atomic::{AtomicU64, Ordering}, Arc, }, time::Duration, }; use crossbeam_channel::Sender; use rb::{Consumer, Producer, RbConsumer, RbProducer, SpscRb, RB}; use symphonia::core::{ audio::{SampleBuffer, SignalSpec}, units::TimeBase, }; use crate::{ actor::{Act, Actor, ActorHandle}, audio::{ decode::AudioDecoder, output::{AudioSink, DefaultAudioSink}, resample::ResamplingQuality, source::{AudioSource, ResampledSource, StereoMappedSource}, }, error::Error, }; use super::{ file::{MediaFile, MediaPath}, LoadedPlaybackItem, PlayerEvent, }; pub struct PlaybackManager { sink: DefaultAudioSink, event_send: Sender, current: Option<(MediaPath, Sender)>, } impl PlaybackManager { pub fn new(sink: DefaultAudioSink, event_send: Sender) -> Self { Self { sink, event_send, current: None, } } pub fn play(&mut self, loaded: LoadedPlaybackItem) { let path = loaded.file.path(); let source = DecoderSource::new( loaded.file, loaded.source, loaded.norm_factor, self.event_send.clone(), ); self.current = Some((path, source.actor.sender())); if source.sample_rate() == self.sink.sample_rate() && source.channel_count() == self.sink.channel_count() { // We can start playing the source right away. self.sink.play(source); } else { // Some output streams have different sample rate than the source, so we need to // resample before pushing to the sink. let source = ResampledSource::new( source, self.sink.sample_rate(), ResamplingQuality::SincMediumQuality, ); // Source output streams also have a different channel count. Map the stereo // channels and silence the others. let source = StereoMappedSource::new(source, self.sink.channel_count()); self.sink.play(source); } self.sink.resume(); } pub fn seek(&self, position: Duration) { if let Some((path, worker)) = &self.current { let _ = worker.send(Msg::Seek(position)); // Because the position events are sent in the `DecoderSource`, doing this here // is slightly hacky. The alternative would be propagating `event_send` into the // worker. let _ = self.event_send.send(PlayerEvent::Position { path: path.to_owned(), position, }); } } } pub struct DecoderSource { file: MediaFile, actor: ActorHandle, consumer: Consumer, event_send: Sender, total_samples: Arc, position: Arc, precision: u64, reported: u64, end_of_track: bool, norm_factor: f32, signal_spec: SignalSpec, time_base: TimeBase, } impl DecoderSource { pub fn new( file: MediaFile, decoder: AudioDecoder, norm_factor: f32, event_send: Sender, ) -> Self { const REPORT_PRECISION: Duration = Duration::from_millis(900); // Gather the source signal parameters and compute how often we should report // the play-head position. let signal_spec = decoder.signal_spec(); let time_base = decoder.codec_params().time_base.unwrap(); let precision = (signal_spec.rate as f64 * signal_spec.channels.count() as f64 * REPORT_PRECISION.as_secs_f64()) as u64; // Create a ring-buffer for the decoded samples. Worker thread is producing, // we are consuming in the `AudioSource` impl. let buffer = Worker::default_buffer(); let consumer = buffer.consumer(); // We keep track of the current play-head position by sharing an atomic sample // counter with the decoding worker. Worker is setting this on seek, we are // incrementing on reading from the ring-buffer. let position = Arc::new(AtomicU64::new(0)); // Because the `n_frames` count that Symphonia gives us can be a bit unreliable, // we track the total number of samples in this stream in this atomic, set when // the underlying decoder returns EOF. let total_samples = Arc::new(AtomicU64::new(u64::MAX)); // Spawn the worker and kick-start the decoding. The buffer will start filling // now. let actor = Worker::spawn_with_default_cap("audio_decoding", { let position = Arc::clone(&position); let total_samples = Arc::clone(&total_samples); move |this| Worker::new(this, decoder, buffer, position, total_samples) }); let _ = actor.send(Msg::Read); Self { file, actor, consumer, event_send, norm_factor, signal_spec, time_base, total_samples, end_of_track: false, position, precision, reported: u64::MAX, // Something sufficiently distinct from any position. } } fn written_samples(&self, position: u64) -> u64 { self.position.fetch_add(position, Ordering::Relaxed) + position } fn should_report(&self, pos: u64) -> bool { self.reported > pos || pos - self.reported >= self.precision } fn samples_to_duration(&self, samples: u64) -> Duration { let frames = samples / self.signal_spec.channels.count() as u64; let time = self.time_base.calc_time(frames); Duration::from_secs(time.seconds) + Duration::from_secs_f64(time.frac) } } impl AudioSource for DecoderSource { fn write(&mut self, output: &mut [f32]) -> usize { if self.end_of_track { return 0; } let written = self.consumer.read(output).unwrap_or(0); // Apply the normalization factor. output[..written] .iter_mut() .for_each(|s| *s *= self.norm_factor); let position = self.written_samples(written as u64); if self.should_report(position) { // Send a position report, so the upper layers can visualize the playback // progress and preload the next track. We cannot block here, so if the channel // is full, we just try the next time instead of waiting. if self .event_send .try_send(PlayerEvent::Position { path: self.file.path(), position: self.samples_to_duration(position), }) .is_ok() { self.reported = position; } } let total_samples = self.total_samples.load(Ordering::Relaxed); if position >= total_samples { // After reading the total number of samples, we stop. Signal to the upper layer // this track is over and short-circuit all further reads from this source. if self.event_send.try_send(PlayerEvent::EndOfTrack).is_ok() { self.end_of_track = true; } } written } fn channel_count(&self) -> usize { self.signal_spec.channels.count() } fn sample_rate(&self) -> u32 { self.signal_spec.rate } } impl Drop for DecoderSource { fn drop(&mut self) { let _ = self.actor.send(Msg::Stop); } } enum Msg { Seek(Duration), Read, Stop, } struct Worker { /// Sending part of our own actor channel. this: Sender, /// Decoder we are reading packets/samples from. input: AudioDecoder, /// Audio properties of the decoded signal. input_spec: SignalSpec, /// Sample buffer containing samples read in the last packet. input_packet: SampleBuffer, /// Ring-buffer for the output signal. output: SpscRb, /// Producing part of the output ring-buffer. output_producer: Producer, /// Shared atomic position. We update this on seek only. position: Arc, /// Shared atomic for total number of samples. We set this on EOF. total_samples: Arc, /// Range of samples in `resampled` that are awaiting flush into `output`. samples_to_write: Range, /// Number of samples written into the output channel. samples_written: u64, /// Are we in the middle of automatic read loop? is_reading: bool, } impl Worker { fn default_buffer() -> SpscRb { const DEFAULT_BUFFER_SIZE: usize = 128 * 1024; SpscRb::new(DEFAULT_BUFFER_SIZE) } fn new( this: Sender, input: AudioDecoder, output: SpscRb, position: Arc, total_samples: Arc, ) -> Self { const DEFAULT_MAX_FRAMES: u64 = 8 * 1024; let max_input_frames = input .codec_params() .max_frames_per_packet .unwrap_or(DEFAULT_MAX_FRAMES); // Promote the worker thread to audio priority to prevent buffer under-runs on // high CPU usage. if let Err(err) = audio_thread_priority::promote_current_thread_to_real_time(0, input.signal_spec().rate) { log::warn!("failed to promote thread to audio priority: {err}"); } Self { output_producer: output.producer(), input_packet: SampleBuffer::new(max_input_frames, input.signal_spec()), input_spec: input.signal_spec(), input, this, output, position, total_samples, samples_written: 0, samples_to_write: 0..0, // Arbitrary empty range. is_reading: false, } } } impl Actor for Worker { type Message = Msg; type Error = Error; fn handle(&mut self, msg: Msg) -> Result, Self::Error> { match msg { Msg::Seek(time) => self.on_seek(time), Msg::Read => self.on_read(), Msg::Stop => Ok(Act::Shutdown), } } } impl Worker { fn on_seek(&mut self, time: Duration) -> Result, Error> { match self.input.seek(time) { Ok(timestamp) => { if self.is_reading { self.samples_to_write = 0..0; } else { self.this.send(Msg::Read)?; } let position = timestamp * self.input_spec.channels.count() as u64; self.samples_written = position; self.position.store(position, Ordering::Relaxed); self.output.clear(); } Err(err) => { log::error!("failed to seek: {err}"); } } Ok(Act::Continue) } fn on_read(&mut self) -> Result, Error> { if !self.samples_to_write.is_empty() { let writable = &self.input_packet.samples()[self.samples_to_write.clone()]; if let Ok(written) = self.output_producer.write(writable) { self.samples_written += written as u64; self.samples_to_write.start += written; self.is_reading = true; self.this.send(Msg::Read)?; Ok(Act::Continue) } else { // Buffer is full. Wait a bit a try again. We also have to indicate that the // read loop is not running at the moment (if we receive a `Seek` while waiting, // we need it to explicitly kickstart reading again). self.is_reading = false; Ok(Act::WaitOr { timeout: Duration::from_millis(500), timeout_msg: Msg::Read, }) } } else { match self.input.read_packet(&mut self.input_packet) { Some(_) => { self.samples_to_write = 0..self.input_packet.samples().len(); self.is_reading = true; self.this.send(Msg::Read)?; } None => { self.is_reading = false; self.total_samples .store(self.samples_written, Ordering::Relaxed); } } Ok(Act::Continue) } } } ================================================ FILE: psst-core/src/session/access_token.rs ================================================ use std::time::{Duration, Instant}; use parking_lot::Mutex; use serde::Deserialize; use crate::error::Error; use super::SessionService; // Client ID of the official Web Spotify front-end. pub const CLIENT_ID: &str = "65b708073fc0480ea92a077233ca87bd"; // All scopes we could possibly require. pub const ACCESS_SCOPES: &str = "streaming,user-read-email,user-read-private,playlist-read-private,playlist-read-collaborative,playlist-modify-public,playlist-modify-private,user-follow-modify,user-follow-read,user-library-read,user-library-modify,user-top-read,user-read-recently-played"; // Consider token expired even before the official expiration time. Spotify // seems to be reporting excessive token TTLs so let's cut it down by 30 // minutes. const EXPIRATION_TIME_THRESHOLD: Duration = Duration::from_secs(60 * 30); #[derive(Clone)] pub struct AccessToken { pub token: String, pub expires: Instant, } impl AccessToken { fn expired() -> Self { Self { token: String::new(), expires: Instant::now(), } } pub fn request(session: &SessionService) -> Result { #[derive(Deserialize)] struct MercuryAccessToken { #[serde(rename = "expiresIn")] expires_in: u64, #[serde(rename = "accessToken")] access_token: String, } let token: MercuryAccessToken = session.connected()?.get_mercury_json(format!( "hm://keymaster/token/authenticated?client_id={CLIENT_ID}&scope={ACCESS_SCOPES}", ))?; Ok(Self { token: token.access_token, expires: Instant::now() + Duration::from_secs(token.expires_in), }) } fn is_expired(&self) -> bool { self.expires.saturating_duration_since(Instant::now()) < EXPIRATION_TIME_THRESHOLD } } pub struct TokenProvider { token: Mutex, } impl TokenProvider { pub fn new() -> Self { Self { token: Mutex::new(AccessToken::expired()), } } pub fn get(&self, session: &SessionService) -> Result { let mut token = self.token.lock(); if token.is_expired() { log::info!("access token expired, requesting"); *token = AccessToken::request(session)?; } Ok(token.clone()) } } ================================================ FILE: psst-core/src/session/audio_key.rs ================================================ use std::{ collections::HashMap, io::{Cursor, Read}, }; use byteorder::{ReadBytesExt, BE}; use crossbeam_channel::Sender; use crate::{ audio::decrypt::AudioKey, connection::shannon_codec::ShannonMsg, error::Error, item_id::{FileId, ItemId}, util::Sequence, }; pub struct AudioKeyDispatcher { sequence: Sequence, pending: HashMap>>, } impl AudioKeyDispatcher { pub fn new() -> Self { Self { sequence: Sequence::new(0), pending: HashMap::new(), } } pub fn enqueue_request( &mut self, track: ItemId, file: FileId, callback: Sender>, ) -> ShannonMsg { let seq = self.sequence.advance(); self.pending.insert(seq, callback); Self::make_key_request(seq, track, file) } fn make_key_request(seq: u32, track: ItemId, file: FileId) -> ShannonMsg { let mut buf = Vec::new(); buf.extend(file.0); buf.extend(track.to_raw()); buf.extend(seq.to_be_bytes()); buf.extend(0_u16.to_be_bytes()); ShannonMsg::new(ShannonMsg::REQUEST_KEY, buf) } pub fn handle_aes_key(&mut self, msg: ShannonMsg) { let mut payload = Cursor::new(msg.payload); let seq = payload.read_u32::().unwrap(); if let Some(tx) = self.pending.remove(&seq) { let mut key = [0_u8; 16]; payload.read_exact(&mut key).unwrap(); if tx.send(Ok(AudioKey(key))).is_err() { log::warn!("missing receiver for audio key, seq: {seq}"); } } else { log::warn!("received unexpected audio key msg, seq: {seq}"); } } pub fn handle_aes_key_error(&mut self, msg: ShannonMsg) { let mut payload = Cursor::new(msg.payload); let seq = payload.read_u32::().unwrap(); if let Some(tx) = self.pending.remove(&seq) { log::error!("audio key error"); if tx.send(Err(Error::UnexpectedResponse)).is_err() { log::warn!("missing receiver for audio key error, seq: {seq}"); } } else { log::warn!("received unknown audio key, seq: {seq}"); } } } ================================================ FILE: psst-core/src/session/client_token.rs ================================================ // Ported from librespot use crate::error::Error; use crate::session::token::{Token}; use crate::util::{default_ureq_agent_builder, solve_hash_cash}; use data_encoding::HEXUPPER_PERMISSIVE; use librespot_protocol::clienttoken_http::{ ChallengeAnswer, ChallengeType, ClientTokenRequest, ClientTokenRequestType, ClientTokenResponse, ClientTokenResponseType, }; use parking_lot::Mutex; use protobuf::{Enum, Message}; use std::time::{Duration, Instant}; use crate::system_info::{CLIENT_ID, DEVICE_ID, OS, SPOTIFY_SEMANTIC_VERSION}; pub struct ClientTokenProvider { token: Mutex>, agent: ureq::Agent, } impl ClientTokenProvider { pub fn new(proxy_url: Option<&str>) -> Self { Self { token: Mutex::new(None), agent: default_ureq_agent_builder(proxy_url).build().into(), } } fn request(&self, message: &M) -> Result, Error> { let body = message.write_to_bytes()?; let mut response = self .agent .post("https://clienttoken.spotify.com/v1/clienttoken") .header("Accept", "application/x-protobuf") .send(body)?; let vec = response.body_mut().read_to_vec(); Ok(vec?) } fn request_new_token(&self) -> Result { log::debug!("Requesting new token..."); let mut request = ClientTokenRequest::new(); request.request_type = ClientTokenRequestType::REQUEST_CLIENT_DATA_REQUEST.into(); let client_data = request.mut_client_data(); client_data.client_version = SPOTIFY_SEMANTIC_VERSION.into(); client_data.client_id = CLIENT_ID.into(); let connectivity_data = client_data.mut_connectivity_sdk_data(); connectivity_data.device_id = DEVICE_ID.to_string(); let platform_data = connectivity_data .platform_specific_data .mut_or_insert_default(); let os_version = sysinfo::System::os_version().unwrap_or("0".into()); let kernel_version = sysinfo::System::kernel_version().unwrap_or_else(|| String::from("0")); match OS { "windows" => { let os_version = os_version.parse::().unwrap_or(10.) as i32; let kernel_version = kernel_version.parse::().unwrap_or(21370); let (pe, image_file) = match std::env::consts::ARCH { "arm" => (448, 452), "aarch64" => (43620, 452), "x86_64" => (34404, 34404), _ => (332, 332), // x86 }; let windows_data = platform_data.mut_desktop_windows(); windows_data.os_version = os_version; windows_data.os_build = kernel_version; windows_data.platform_id = 2; windows_data.unknown_value_6 = 9; windows_data.image_file_machine = image_file; windows_data.pe_machine = pe; windows_data.unknown_value_10 = true; } "ios" => { let ios_data = platform_data.mut_ios(); ios_data.user_interface_idiom = 0; ios_data.target_iphone_simulator = false; ios_data.hw_machine = "iPhone14,5".to_string(); ios_data.system_version = os_version; } "android" => { let android_data = platform_data.mut_android(); android_data.android_version = os_version; android_data.api_version = 31; "Pixel".clone_into(&mut android_data.device_name); "GF5KQ".clone_into(&mut android_data.model_str); "Google".clone_into(&mut android_data.vendor); } "macos" => { let macos_data = platform_data.mut_desktop_macos(); macos_data.system_version = os_version; macos_data.hw_model = "iMac21,1".to_string(); macos_data.compiled_cpu_type = std::env::consts::ARCH.to_string(); } _ => { let linux_data = platform_data.mut_desktop_linux(); linux_data.system_name = "Linux".to_string(); linux_data.system_release = kernel_version; linux_data.system_version = os_version; linux_data.hardware = std::env::consts::ARCH.to_string(); } } let mut response = self.request(&request)?; let mut count = 0; const MAX_TRIES: u8 = 3; let token_response = loop { count += 1; let message = ClientTokenResponse::parse_from_bytes(&response)?; match ClientTokenResponseType::from_i32(message.response_type.value()) { // depending on the platform, you're either given a token immediately // or are presented a hash cash challenge to solve first Some(ClientTokenResponseType::RESPONSE_GRANTED_TOKEN_RESPONSE) => { log::debug!("Received a granted token"); break message; } Some(ClientTokenResponseType::RESPONSE_CHALLENGES_RESPONSE) => { log::debug!("Received a hash cash challenge, solving..."); let challenges = message.challenges().clone(); let state = challenges.state; if let Some(challenge) = challenges.challenges.first() { let hash_cash_challenge = challenge.evaluate_hashcash_parameters(); let ctx = vec![]; let prefix = HEXUPPER_PERMISSIVE .decode(hash_cash_challenge.prefix.as_bytes()) .map_err(|e| { Error::InvalidStateError( format!("Unable to decode hash cash challenge: {e}").into(), ) })?; let length = hash_cash_challenge.length; let mut suffix = [0u8; 0x10]; let answer = solve_hash_cash(&ctx, &prefix, length, &mut suffix); match answer { Ok(_) => { // the suffix must be in uppercase let suffix = HEXUPPER_PERMISSIVE.encode(&suffix); let mut answer_message = ClientTokenRequest::new(); answer_message.request_type = ClientTokenRequestType::REQUEST_CHALLENGE_ANSWERS_REQUEST .into(); let challenge_answers = answer_message.mut_challenge_answers(); let mut challenge_answer = ChallengeAnswer::new(); challenge_answer.mut_hash_cash().suffix = suffix; challenge_answer.ChallengeType = ChallengeType::CHALLENGE_HASH_CASH.into(); challenge_answers.state = state.to_string(); challenge_answers.answers.push(challenge_answer); log::trace!("Answering hash cash challenge"); match self.request(&answer_message) { Ok(token) => { response = token; continue; } Err(e) => { log::trace!("Answer not accepted {count}/{MAX_TRIES}: {e}"); } } } Err(e) => log::trace!( "Unable to solve hash cash challenge {count}/{MAX_TRIES}: {e}" ), } if count < MAX_TRIES { response = self.request(&request)?; } else { return Err(Error::InvalidStateError( format!("Unable to solve any of {MAX_TRIES} hash cash challenges") .into(), )); } } else { return Err(Error::InvalidStateError("No challenges found".into())); } } Some(unknown) => { return Err(Error::UnimplementedError( format!("Unknown client token response type: {unknown:?}").into(), )); } None => { return Err(Error::InvalidStateError( "No client token response type".into(), )) } } }; let granted_token = token_response.granted_token(); let access_token = granted_token.token.to_owned(); Ok(Token { access_token: access_token.clone(), expires_in: Duration::from_secs( granted_token .refresh_after_seconds .try_into() .unwrap_or(7200), ), token_type: "client-token".to_string(), scopes: granted_token .domains .iter() .map(|d| d.domain.clone()) .collect(), timestamp: Instant::now(), }) } pub fn get(&self) -> Result { // Check for cached token availability, else retrieve fresh token let mut cur_token = self.token.lock(); if let Some(token) = &*cur_token { if !token.is_expired() { return Ok(token.access_token.clone()); } *cur_token = None; log::debug!("Client token expired"); } let new_token = self.request_new_token()?; *cur_token = Some(new_token.clone()); Ok(new_token.access_token) } } ================================================ FILE: psst-core/src/session/login5.rs ================================================ // Ported from librespot use crate::error::Error; use crate::session::client_token::ClientTokenProvider; use crate::session::token::Token; use crate::session::SessionService; use crate::system_info::{CLIENT_ID, DEVICE_ID}; use crate::util::{default_ureq_agent_builder, solve_hash_cash}; use librespot_protocol::login5::login_response::Response; use librespot_protocol::{ client_info::ClientInfo, credentials::StoredCredential, hashcash::HashcashSolution, login5::{ login_request::Login_method, ChallengeSolution, LoginError, LoginRequest, LoginResponse, }, }; use parking_lot::Mutex; use protobuf::well_known_types::duration::Duration as ProtoDuration; use protobuf::{Message, MessageField}; use std::fmt::Formatter; use std::time::{Duration, Instant}; use std::{error, fmt, thread}; const MAX_LOGIN_TRIES: u8 = 3; const LOGIN_TIMEOUT: Duration = Duration::from_secs(3); #[derive(Debug)] pub enum ChallengeError { Unsupported, NoneFound, } #[derive(Debug)] enum Login5Error { /// The server denied the request with a specific error code. RequestDenied(LoginError), /// The server issued a challenge that we could not solve. Challenge(ChallengeError), /// The operation could not be performed due to invalid local state. InvalidState(String), /// The login attempt failed after multiple retries. RetriesExceeded(u8), /// The server's response was malformed or missing expected fields. MalformedResponse, } impl error::Error for Login5Error {} impl fmt::Display for Login5Error { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { match self { Login5Error::RequestDenied(e) => write!(f, "Login request was denied: {:?}", e), Login5Error::Challenge(c) => match c { ChallengeError::Unsupported => write!(f, "Login5 code challenge is not supported"), ChallengeError::NoneFound => write!(f, "No challenges found in response"), }, Login5Error::InvalidState(s) => write!(f, "Invalid state: {}", s), Login5Error::RetriesExceeded(n) => { write!(f, "Couldn't successfully authenticate after {} times", n) } Login5Error::MalformedResponse => write!(f, "Missing response from login server"), } } } impl From for Error { fn from(err: Login5Error) -> Self { match err { Login5Error::RequestDenied(_) | Login5Error::InvalidState(_) | Login5Error::RetriesExceeded(_) | Login5Error::MalformedResponse => Error::InvalidStateError(err.into()), Login5Error::Challenge(_) => Error::UnimplementedError(err.into()), } } } pub struct Login5 { auth_token: Mutex>, client_token_provider: ClientTokenProvider, agent: ureq::Agent, } impl Login5 { /// Login5 instances can be used to cache and retrieve access tokens from stored credentials. /// /// # Arguments /// /// * `client_token_provider`: Can be optionally injected to control which client-id is /// used for it. /// /// returns: Login5 pub fn new( client_token_provider: Option, proxy_url: Option<&str>, ) -> Self { Self { auth_token: Mutex::new(None), client_token_provider: client_token_provider .unwrap_or_else(|| ClientTokenProvider::new(proxy_url)), agent: default_ureq_agent_builder(proxy_url).build().into(), } } fn request(&self, message: &LoginRequest) -> Result, Error> { let client_token: String = self.client_token_provider.get()?; let body = message.write_to_bytes()?; let mut response = self .agent .post("https://login5.spotify.com/v3/login") .header("Accept", "application/x-protobuf") .header("client-token", &client_token) .send(body)?; let vec = response.body_mut().read_to_vec()?; Ok(vec) } fn request_new_token(&self, login: Login_method) -> Result { let mut login_request = LoginRequest { client_info: MessageField::some(ClientInfo { client_id: String::from(CLIENT_ID), device_id: String::from(DEVICE_ID), special_fields: Default::default(), }), login_method: Some(login), ..Default::default() }; let mut response = self.request(&login_request)?; let mut count = 0; loop { count += 1; let mut message = LoginResponse::parse_from_bytes(&response)?; match message.response.take() { Some(Response::Ok(ok)) => { let expiry_secs = ok.access_token_expires_in.try_into().unwrap_or(3600); return Ok(Token { access_token: ok.access_token, expires_in: Duration::from_secs(expiry_secs), token_type: "Bearer".to_string(), scopes: vec![], timestamp: Instant::now(), }); } Some(Response::Error(err)) => match err.enum_value() { Ok(LoginError::TIMEOUT) | Ok(LoginError::TOO_MANY_ATTEMPTS) => { log::debug!("Too many login5 requests... timeout!"); thread::sleep(LOGIN_TIMEOUT) } Ok(other) => { log::debug!("Login5 request failed!"); return Err(Login5Error::RequestDenied(other).into()); } Err(other) => { log::warn!("Unknown login error: {}", other); } }, Some(Response::Challenges(_)) => { // handles the challenges, and updates the login context with the response Self::handle_challenges(&mut login_request, message)?; } None => { return Err(Login5Error::MalformedResponse.into()); } _ => { log::warn!("Unhandled login response"); } } if count < MAX_LOGIN_TRIES { response = self.request(&login_request)?; } else { return Err(Login5Error::RetriesExceeded(MAX_LOGIN_TRIES).into()); } } } fn handle_challenges( login_request: &mut LoginRequest, message: LoginResponse, ) -> Result<(), Error> { let challenges = message.challenges(); log::debug!( "Received {} challenges, solving...", challenges.challenges.len() ); if challenges.challenges.is_empty() { return Err(Login5Error::Challenge(ChallengeError::NoneFound).into()); } for challenge in &challenges.challenges { if challenge.has_code() || !challenge.has_hashcash() { // We only solve hashcash challenges. return Err(Login5Error::Challenge(ChallengeError::Unsupported).into()); } let hash_cash_challenge = challenge.hashcash(); let mut suffix = [0u8; 0x10]; let duration = solve_hash_cash( &message.login_context, &hash_cash_challenge.prefix, hash_cash_challenge.length, &mut suffix, )?; let (seconds, nanos) = (duration.as_secs() as i64, duration.subsec_nanos() as i32); log::debug!("Solving hashcash took {seconds}s {nanos}ns"); let mut solution = ChallengeSolution::new(); solution.set_hashcash(HashcashSolution { suffix: Vec::from(suffix), duration: MessageField::some(ProtoDuration { seconds, nanos, ..Default::default() }), ..Default::default() }); login_request .challenge_solutions .mut_or_insert_default() .solutions .push(solution); } login_request.login_context = message.login_context; Ok(()) } /// Retrieve an `access_token` via Login5. The token is either requested first (slow), or /// retrieved from local cache (fast). /// /// This request will only work if the session already has valid credentials available. /// The client-id of the credentials have to match the client-id used to retrieve /// the client token (see also `Login5::new(...)`). For example, if you previously generated /// stored credentials with an android client-id, they won't work within login5 using a desktop /// client-id. pub fn get_access_token(&self, session: &SessionService) -> Result { let mut cur_token = self.auth_token.lock(); let login_creds = session.config.lock().as_ref().unwrap().login_creds.clone(); let auth_data = login_creds.auth_data.clone(); if auth_data.is_empty() { return Err(Login5Error::InvalidState( "Tried to acquire access token without stored credentials".to_string(), ) .into()); } if let Some(auth_token) = &*cur_token { if !auth_token.is_expired() { return Ok(auth_token.clone()); } *cur_token = None; log::debug!("Auth token expired"); } log::debug!("Requesting new auth token"); // Conversion from psst protocol structs to librespot protocol structs let method = Login_method::StoredCredential(StoredCredential { username: login_creds.username.clone().unwrap(), data: auth_data, ..Default::default() }); let new_token = self.request_new_token(method)?; log::debug!("Successfully requested new auth token"); *cur_token = Some(new_token.clone()); Ok(new_token) } } ================================================ FILE: psst-core/src/session/mercury.rs ================================================ use std::{ collections::HashMap, io::{Cursor, Read}, }; use byteorder::{ReadBytesExt, BE}; use crossbeam_channel::Sender; use crate::{connection::shannon_codec::ShannonMsg, util::Sequence}; use librespot_protocol::mercury::Header; use protobuf::Message; pub struct MercuryDispatcher { sequence: Sequence, pending: HashMap, } impl MercuryDispatcher { pub fn new() -> Self { Self { sequence: Sequence::new(0), pending: HashMap::new(), } } pub fn enqueue_request( &mut self, req: MercuryRequest, callback: Sender, ) -> ShannonMsg { let seq = self.sequence.advance(); self.pending.insert( seq, Pending { callback, messages: Vec::new(), }, ); ShannonMsg::new(ShannonMsg::MERCURY_REQ, req.encode_to_mercury_message(seq)) } pub fn handle_mercury_req(&mut self, shannon_msg: ShannonMsg) { let msg = Msg::decode(shannon_msg.payload); let msg_flags = msg.flags; let msg_seq = msg.seq; if let Some(mut pending) = self.pending.remove(&msg_seq) { pending.messages.push(msg); if msg_flags == Msg::FINAL { // This is the final message. Aggregate all pending parts and process further. let parts = Msg::aggregate(pending.messages); let response = MercuryResponse::decode_from_parts(parts); // Send the response. If the response channel is closed, ignore it. let _ = pending.callback.send(response); } else { // This is not the final message of this sequence, but it back as pending. self.pending.insert(msg_seq, pending); } } else { log::warn!("received unexpected mercury msg, seq: {msg_seq}"); } } } #[derive(Debug)] pub struct MercuryRequest { pub uri: String, pub method: String, pub payload: Vec>, } impl MercuryRequest { pub fn get(uri: String) -> Self { Self { uri, method: "GET".to_string(), payload: Vec::new(), } } pub fn send(uri: String, data: Vec) -> Self { Self { uri, method: "SEND".to_string(), payload: vec![data], } } fn encode_to_mercury_message(self, seq: u64) -> Vec { let parts = self.encode_to_parts(); let msg = Msg::new(seq, Msg::FINAL, parts); msg.encode() } fn encode_to_parts(self) -> Vec> { let header = Header { uri: Some(self.uri), method: Some(self.method), ..Header::default() }; let header_part = header .write_to_bytes() .expect("Failed to serialize message header"); let mut parts = self.payload; parts.insert(0, header_part); parts } } #[derive(Debug, Clone)] pub struct MercuryResponse { pub uri: String, pub status_code: i32, pub payload: Vec>, } impl MercuryResponse { fn decode_from_parts(mut parts: Vec>) -> Self { let header_part = parts.remove(0); let header = Header::parse_from_bytes(&header_part) .expect("Failed to deserialize message header"); Self { uri: header.uri.unwrap(), status_code: header.status_code.unwrap(), payload: parts, } } } #[derive(Debug)] struct Pending { messages: Vec, callback: Sender, } #[derive(Debug, Default)] struct Msg { seq: u64, flags: u8, count: u16, parts: Vec>, } impl Msg { const FINAL: u8 = 0x01; const PARTIAL: u8 = 0x02; fn new(seq: u64, flags: u8, parts: Vec>) -> Self { let count = parts.len() as u16; Self { seq, flags, count, parts, } } fn decode(buf: Vec) -> Self { let mut buf = Cursor::new(buf); let seq_len = buf.read_u16::().unwrap(); let seq = buf.read_uint::(seq_len.into()).unwrap(); let flags = buf.read_u8().unwrap(); let count = buf.read_u16::().unwrap(); let mut parts = Vec::with_capacity(count.into()); for _ in 0..count { let part_len = buf.read_u16::().unwrap(); let mut part = vec![0_u8; part_len.into()]; buf.read_exact(&mut part).unwrap(); parts.push(part); } Self { seq, flags, count, parts, } } fn encode(&self) -> Vec { let mut buf = Vec::new(); buf.extend(8_u16.to_be_bytes()); // Sequence length. buf.extend(self.seq.to_be_bytes()); buf.push(self.flags); buf.extend(self.count.to_be_bytes()); for part in &self.parts { let len = part.len() as u16; buf.extend(len.to_be_bytes()); buf.extend(part); } buf } fn aggregate(msgs: impl IntoIterator) -> Vec> { let mut results = Vec::new(); let mut partial: Option> = None; for msg in msgs { for (i, mut part) in msg.parts.into_iter().enumerate() { // If we have a partial data left from the last message, append to it. if let Some(mut partial) = partial.take() { partial.extend(part); part = partial; } // Save the last part of partial messages for later. let is_last_part = i as u16 == msg.count - 1; if msg.flags == Self::PARTIAL && is_last_part { partial = Some(part); } else { results.push(part); } } } results } } ================================================ FILE: psst-core/src/session/mod.rs ================================================ pub mod access_token; pub mod audio_key; pub mod mercury; pub mod login5; pub mod client_token; pub mod token; use std::{ io, net::{Shutdown, TcpStream}, sync::{ atomic::{AtomicBool, Ordering}, Arc, }, thread::{self, JoinHandle}, }; use crossbeam_channel::{unbounded, Receiver, Sender}; use parking_lot::Mutex; use serde::de::DeserializeOwned; use crate::{ audio::decrypt::AudioKey, connection::{ shannon_codec::{ShannonDecoder, ShannonEncoder, ShannonMsg}, Credentials, Transport, }, error::Error, item_id::{FileId, ItemId}, }; use self::{ audio_key::AudioKeyDispatcher, mercury::{MercuryDispatcher, MercuryRequest, MercuryResponse}, }; /// Configuration values needed to open the session connection. #[derive(Clone)] pub struct SessionConfig { pub login_creds: Credentials, pub proxy_url: Option, } /// Cheap to clone, shareable service handle that holds the active session /// worker. Session connection is lazily opened in `connected()`, using config /// values set in `update_config()`. In case the session dies or is explicitly /// shut down, worker is disposed of, and a new session is opened on the next /// request. #[derive(Clone)] pub struct SessionService { connected: Arc>>, config: Arc>>, } impl SessionService { /// Create a new session service without any configuration. To open a /// session, a config needs to be set up first using `update_config`. pub fn empty() -> Self { Self { connected: Arc::default(), config: Arc::default(), } } /// Create a new session service with pre-set configuration. pub fn with_config(config: SessionConfig) -> Self { Self { connected: Arc::default(), config: Arc::new(Mutex::new(Some(config))), } } /// Replace the active session config. If a session is already connected, /// shut it down and wait until it's terminated. pub fn update_config(&self, config: SessionConfig) { self.config.lock().replace(config); self.shutdown(); } /// Returns true if a session worker is actively servicing the connected /// session. We return false here after any case of I/O errors or an /// explicit session shutdown. pub fn is_connected(&self) -> bool { matches!(self.connected.lock().as_ref(), Some(worker) if !worker.has_terminated()) } /// Return a handle for the connected session. In case no connection is /// open, *synchronously* connect, start the worker and keep it as active. /// Although a lock is held for the whole duration of connection setup, /// `SessionConnection::open` has an internal timeout, and should give up in /// a timely manner. pub fn connected(&self) -> Result { let mut connected = self.connected.lock(); let is_connected_and_not_terminated = matches!(connected.as_ref(), Some(worker) if !worker.has_terminated()); if !is_connected_and_not_terminated { let connection = SessionConnection::open( self.config .lock() .as_ref() .ok_or(Error::SessionDisconnected)? .clone(), )?; let worker = SessionWorker::run(connection.transport); connected.replace(worker); } connected .as_ref() .map(SessionWorker::handle) .ok_or(Error::SessionDisconnected) } /// Signal a shutdown to the active worker and wait until it terminates. pub fn shutdown(&self) { if let Some(worker) = self.connected.lock().take() { worker.handle().request_shutdown(); worker.join(); } } } /// Successful connection through the Spotify Shannon-encrypted TCP channel. pub struct SessionConnection { /// Credentials re-usable in the next authentication (i.e. username and /// password are not required anymore). pub credentials: Credentials, /// I/O codec for the Shannon messages. pub transport: Transport, } impl SessionConnection { /// Synchronously connect to the Spotify servers and authenticate with /// credentials provided in `config`. pub fn open(config: SessionConfig) -> Result { // Connect to the server and exchange keys. let proxy_url = config.proxy_url.as_deref(); let ap_list = Transport::resolve_ap_with_fallback(proxy_url); let mut transport = Transport::connect(&ap_list, proxy_url)?; let credentials = transport.authenticate(config.login_creds)?; Ok(Self { credentials, transport, }) } } pub struct SessionWorker { sender: Sender, decoding_thread: JoinHandle<()>, encoding_thread: JoinHandle<()>, dispatching_thread: JoinHandle<()>, terminated: Arc, } impl SessionWorker { pub fn run(transport: Transport) -> Self { let (disp_send, disp_recv) = unbounded(); let (msg_send, msg_recv) = unbounded(); let terminated = Arc::new(AtomicBool::new(false)); Self { decoding_thread: { let decoder = transport.decoder; let disp_send = disp_send.clone(); thread::spawn(move || decode_shannon_messages(decoder, disp_send)) }, encoding_thread: { let encoder = transport.encoder; let disp_send = disp_send.clone(); thread::spawn(move || encode_shannon_messages(encoder, msg_recv, disp_send)) }, dispatching_thread: { let stream = transport.stream; let terminated = terminated.clone(); thread::spawn(move || { dispatch_messages(disp_recv, msg_send, stream); terminated.store(true, Ordering::SeqCst); }) }, sender: disp_send, terminated, } } pub fn handle(&self) -> SessionHandle { SessionHandle { sender: self.sender.clone(), } } pub fn join(self) { if let Err(err) = self.dispatching_thread.join() { log::error!("session dispatching thread panicked: {err:?}"); } if let Err(err) = self.encoding_thread.join() { log::error!("session encoding thread panicked: {err:?}"); } if let Err(err) = self.decoding_thread.join() { log::error!("session decoding thread panicked: {err:?}"); } } pub fn has_terminated(&self) -> bool { self.terminated.load(Ordering::SeqCst) } } #[derive(Clone)] pub struct SessionHandle { sender: Sender, } impl SessionHandle { pub fn get_mercury_protobuf(&self, uri: String) -> Result where T: protobuf::Message, { let payload = self.get_mercury_bytes(uri)?; let message = T::parse_from_bytes(&payload)?; Ok(message) } pub fn get_mercury_json(&self, uri: String) -> Result where T: DeserializeOwned, { let payload = self.get_mercury_bytes(uri)?; let message = serde_json::from_slice(&payload)?; Ok(message) } pub fn get_mercury_bytes(&self, uri: String) -> Result, Error> { let (callback, receiver) = unbounded(); let request = MercuryRequest::get(uri); self.sender .send(DispatchCmd::MercuryReq { callback, request }) .ok() .ok_or(Error::SessionDisconnected)?; let response = receiver.recv().ok().ok_or(Error::SessionDisconnected)?; let first_part = response .payload .into_iter() .next() .ok_or(Error::UnexpectedResponse)?; Ok(first_part) } pub fn get_audio_key(&self, track: ItemId, file: FileId) -> Result { let (callback, receiver) = unbounded(); self.sender .send(DispatchCmd::AudioKeyReq { callback, track, file, }) .ok() .ok_or(Error::SessionDisconnected)?; receiver.recv().ok().ok_or(Error::SessionDisconnected)? } pub fn get_country_code(&self) -> Option { let (callback, receiver) = unbounded(); self.sender .send(DispatchCmd::CountryCodeReq { callback }) .ok()?; receiver.recv().ok()? } pub fn request_shutdown(&self) { let _ = self.sender.send(DispatchCmd::Shutdown); } } /// Read Shannon messages from the TCP stream one by one and send them to /// dispatcher for further processing. In case the decoding fails with an error /// (this happens also in case we explicitly shutdown the connection), report /// the error to the dispatcher and quit. If the dispatcher has already dropped /// its receiving part, quit silently as well. fn decode_shannon_messages(mut decoder: ShannonDecoder, dispatch: Sender) { loop { match decoder.decode() { Ok(msg) => { if dispatch.send(DispatchCmd::DecodedMsg(msg)).is_err() { break; } } Err(err) => { let _ = dispatch.send(DispatchCmd::DecoderError(err)); break; } }; } } /// Receive Shannon messages from `messages` and encode them into the TCP stream /// through `encoder`. In case the encoding fails with an error (this happens /// also in case we explicitly shutdown the connection), report the error to the /// dispatcher and quit. If the dispatcher has already dropped the /// corresponding sender of `messages`, quit as well. fn encode_shannon_messages( mut encoder: ShannonEncoder, messages: Receiver, dispatch: Sender, ) { for msg in messages { match encoder.encode(msg) { Ok(_) => { // Message encoded, continue. } Err(err) => { let _ = dispatch.send(DispatchCmd::EncoderError(err)); break; } } } } enum DispatchCmd { MercuryReq { request: MercuryRequest, callback: Sender, }, AudioKeyReq { track: ItemId, file: FileId, callback: Sender>, }, CountryCodeReq { callback: Sender>, }, DecodedMsg(ShannonMsg), DecoderError(io::Error), EncoderError(io::Error), Shutdown, } fn dispatch_messages( dispatch: Receiver, messages: Sender, stream: TcpStream, ) { let mut mercury = MercuryDispatcher::new(); let mut audio_key = AudioKeyDispatcher::new(); let mut country_code = None; for disp in dispatch { match disp { DispatchCmd::MercuryReq { request, callback } => { let msg = mercury.enqueue_request(request, callback); let _ = messages.send(msg); } DispatchCmd::AudioKeyReq { track, file, callback, } => { let msg = audio_key.enqueue_request(track, file, callback); let _ = messages.send(msg); } DispatchCmd::CountryCodeReq { callback } => { let _ = callback.send(country_code.clone()); } DispatchCmd::DecodedMsg(msg) if msg.cmd == ShannonMsg::PING => { let _ = messages.send(pong_message()); } DispatchCmd::DecodedMsg(msg) if msg.cmd == ShannonMsg::COUNTRY_CODE => { country_code.replace(parse_country_code(msg).unwrap()); } DispatchCmd::DecodedMsg(msg) if msg.cmd == ShannonMsg::AES_KEY => { audio_key.handle_aes_key(msg) } DispatchCmd::DecodedMsg(msg) if msg.cmd == ShannonMsg::AES_KEY_ERROR => { audio_key.handle_aes_key_error(msg) } DispatchCmd::DecodedMsg(msg) if msg.cmd == ShannonMsg::MERCURY_REQ => { mercury.handle_mercury_req(msg) } DispatchCmd::DecodedMsg(msg) => { log::debug!("ignored message: {:?}", msg.cmd); } DispatchCmd::DecoderError(err) => { log::error!("connection error: {err:?}"); let _ = stream.shutdown(Shutdown::Write); break; } DispatchCmd::EncoderError(err) => { log::error!("connection error: {err:?}"); let _ = stream.shutdown(Shutdown::Read); break; } DispatchCmd::Shutdown => { log::info!("connection shutdown"); let _ = stream.shutdown(Shutdown::Both); break; } } } } fn pong_message() -> ShannonMsg { ShannonMsg::new(ShannonMsg::PONG, vec![0, 0, 0, 0]) } fn parse_country_code(msg: ShannonMsg) -> Result { String::from_utf8(msg.payload) .ok() .ok_or(Error::UnexpectedResponse) } impl From for Error { fn from(error: serde_json::Error) -> Self { Error::JsonError(Box::new(error)) } } ================================================ FILE: psst-core/src/session/token.rs ================================================ // Ported from librespot use std::time::{Duration, Instant}; const EXPIRY_THRESHOLD: Duration = Duration::from_secs(10); #[derive(Clone, Debug)] pub struct Token { pub access_token: String, pub expires_in: Duration, pub token_type: String, pub scopes: Vec, pub timestamp: Instant, } impl Token { pub fn is_expired(&self) -> bool { self.timestamp + (self.expires_in.saturating_sub(EXPIRY_THRESHOLD)) < Instant::now() } } ================================================ FILE: psst-core/src/system_info.rs ================================================ /// Operating System as given by the Rust standard library pub const OS: &str = std::env::consts::OS; /// Device ID used for authentication procedures. /// librespot opts for UUIDv4s instead pub const DEVICE_ID: &str = "Psst"; /// Client ID for desktop keymaster client pub const CLIENT_ID: &str = "65b708073fc0480ea92a077233ca87bd"; /// The semantic version of the Spotify desktop client pub const SPOTIFY_SEMANTIC_VERSION: &str = "1.2.52.442"; ================================================ FILE: psst-core/src/util.rs ================================================ use crate::error::Error; use byteorder::{BigEndian, ByteOrder}; use num_traits::{One, WrappingAdd}; use sha1::{Digest, Sha1}; use std::time::Instant; use std::{io, io::SeekFrom, mem, time::Duration}; pub const NET_CONNECT_TIMEOUT: Duration = Duration::from_millis(8 * 1000); pub const NET_IO_TIMEOUT: Duration = Duration::from_millis(16 * 1000); pub fn default_ureq_agent_builder( proxy_url: Option<&str>, ) -> ureq::config::ConfigBuilder { let mut agent = ureq::Agent::config_builder() .timeout_global(Some(Duration::from_secs(5))) .timeout_connect(Some(NET_CONNECT_TIMEOUT)) .timeout_recv_response(Some(NET_IO_TIMEOUT)) .timeout_send_request(Some(NET_IO_TIMEOUT)); if let Some(proxy_url) = proxy_url { let proxy = ureq::Proxy::new(proxy_url).ok(); agent = agent.proxy(proxy); } agent } pub fn solve_hash_cash( ctx: &[u8], prefix: &[u8], length: i32, dst: &mut [u8], ) -> Result { const TIMEOUT: Duration = Duration::from_secs(5); // SHA-1 produces a 20-byte hash, we check the trailing 8 bytes. const OFFSET_LEN: usize = 8; const CHECK_OFFSET: usize = 20 - OFFSET_LEN; let now = Instant::now(); let initial_digest = Sha1::digest(ctx); let target = BigEndian::read_i64(&initial_digest[CHECK_OFFSET..]); let mut suffix = [0u8; 16]; let mut counter = 0i64; while now.elapsed() < TIMEOUT { suffix[..OFFSET_LEN].copy_from_slice(&target.wrapping_add(counter).to_be_bytes()); suffix[OFFSET_LEN..].copy_from_slice(&counter.to_be_bytes()); let final_digest = Sha1::new() .chain_update(prefix) .chain_update(suffix) .finalize(); if BigEndian::read_i64(&final_digest[CHECK_OFFSET..]).trailing_zeros() >= (length as u32) { dst.copy_from_slice(&suffix); return Ok(now.elapsed()); } counter += 1; } Err(Error::InvalidStateError( format!("{TIMEOUT:?} expired").into(), )) } #[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, PartialOrd, Ord, Default)] pub struct Sequence(T); impl Sequence { pub fn new(value: T) -> Self { Sequence(value) } pub fn advance(&mut self) -> T { let next = self.0.wrapping_add(&T::one()); mem::replace(&mut self.0, next) } } pub struct OffsetFile { stream: T, offset: u64, } impl OffsetFile { pub fn new(mut stream: T, offset: u64) -> io::Result> { stream.seek(SeekFrom::Start(offset))?; Ok(OffsetFile { stream, offset }) } } impl io::Read for OffsetFile { fn read(&mut self, buf: &mut [u8]) -> io::Result { self.stream.read(buf) } } impl io::Write for OffsetFile { fn write(&mut self, buf: &[u8]) -> io::Result { self.stream.write(buf) } fn flush(&mut self) -> io::Result<()> { self.stream.flush() } } impl io::Seek for OffsetFile { fn seek(&mut self, pos: SeekFrom) -> io::Result { let offset_pos = match pos { SeekFrom::Start(offset) => SeekFrom::Start(offset + self.offset), from_end_or_current => from_end_or_current, }; let new_pos = self.stream.seek(offset_pos)?; let offset_new_pos = new_pos.saturating_sub(self.offset); Ok(offset_new_pos) } } pub struct FileWithConstSize { stream: T, len: u64, } impl FileWithConstSize { pub fn len(&self) -> u64 { self.len } pub fn is_empty(&self) -> bool { self.len() == 0 } } impl FileWithConstSize where T: io::Seek, { pub fn new(mut stream: T) -> Self { stream.seek(SeekFrom::End(0)).unwrap(); let len = stream.stream_position().unwrap(); stream.seek(SeekFrom::Start(0)).unwrap(); Self { stream, len } } } impl io::Read for FileWithConstSize where T: io::Read, { fn read(&mut self, buf: &mut [u8]) -> io::Result { self.stream.read(buf) } } impl io::Seek for FileWithConstSize where T: io::Seek, { fn seek(&mut self, pos: io::SeekFrom) -> io::Result { self.stream.seek(pos) } } ================================================ FILE: psst-gui/Cargo.toml ================================================ [package] name = "psst-gui" version = "0.1.0" authors = ["Jan Pochyla "] edition = "2021" build = "build.rs" description = "Fast and native Spotify client" repository = "https://github.com/jpochyla/psst" [features] default = ["cpal"] cpal = ["psst-core/cpal"] cubeb = ["psst-core/cubeb"] [dependencies] psst-core = { path = "../psst-core" } # Common crossbeam-channel = { version = "0.5.15" } directories = "6.0.0" env_logger = { version = "0.11.8" } itertools = "0.14.0" log = { version = "0.4.27" } lru = "0.14.0" parking_lot = { version = "0.12.3" } platform-dirs = { version = "0.3.0" } rand = { version = "0.9.1" } regex = { version = "1.11.1" } serde = { version = "1.0.219", features = ["derive", "rc"] } serde_json = { version = "1.0.140" } threadpool = { version = "1.8.1" } time = { version = "0.3.41", features = ["macros", "formatting"] } time-humanize = { version = "0.1.3" } ureq = { version = "3.0.11", features = ["json", "socks-proxy"] } url = { version = "2.5.4" } infer = "0.19.0" urlencoding = { version = "2.1.3" } # GUI druid = { git = "https://github.com/jpochyla/druid", branch = "psst", features = [ "im", "image", "jpeg", "png", "webp", "serde", ] } druid-enums = { git = "https://github.com/jpochyla/druid-enums" } druid-shell = { git = "https://github.com/jpochyla/druid", branch = "psst", features = [ "raw-win-handle", ] } open = { version = "5.3.2" } raw-window-handle = "0.5.2" # Must stay compatible with Druid souvlaki = { version = "0.8.2", default-features = false, features = ["use_zbus"] } sanitize_html = "0.9.0" rustfm-scrobble = "1.1.1" [target.'cfg(windows)'.build-dependencies] winres = { version = "0.1.12" } image = { version = "0.25.6" } [package.metadata.bundle] name = "Psst" identifier = "com.jpochyla.psst" icon = ["assets/logo.icns"] version = "0.1.0" osx_minimum_system_version = "11.0" resources = [] copyright = "Copyright (c) Jan Pochyla 2024. All rights reserved." category = "Music" short_description = "Fast and native Spotify client" long_description = """ Small and efficient graphical music player for the Spotify network. """ ================================================ FILE: psst-gui/build-icons.sh ================================================ #!/bin/bash set -euo pipefail # Check for required tools command -v rsvg-convert >/dev/null 2>&1 || { echo >&2 "rsvg-convert is required but not installed. Aborting." exit 1 } command -v iconutil >/dev/null 2>&1 || { echo >&2 "iconutil is required but not installed. Aborting." exit 1 } command -v pngquant >/dev/null 2>&1 || { echo >&2 "pngquant is required but not installed. Aborting." exit 1 } command -v optipng >/dev/null 2>&1 || { echo >&2 "optipng is required but not installed. Aborting." exit 1 } # Temp folder ICON_DIR="icons" mkdir -p "$ICON_DIR" # Generate PNG icons from SVG SIZES=(16 32 64 128 256 512) for size in "${SIZES[@]}"; do rsvg-convert -w $size -h $size assets/logo.svg -o "$ICON_DIR/logo_${size}.png" # Apply lossy compression with pngquant pngquant --force --quality=60-80 "$ICON_DIR/logo_${size}.png" --output "$ICON_DIR/logo_${size}.png" # Further optimize with optipng optipng -quiet -o5 "$ICON_DIR/logo_${size}.png" # For smaller sizes, reduce color depth if [ $size -le 32 ]; then magick "$ICON_DIR/logo_${size}.png" -colors 256 PNG8:"$ICON_DIR/logo_${size}.png" fi done # Generate ICNS for macOS ICONSET_DIR="$ICON_DIR/psst.iconset" mkdir -p "$ICONSET_DIR" for size in "${SIZES[@]}"; do cp "$ICON_DIR/logo_${size}.png" "$ICONSET_DIR/icon_${size}x${size}.png" if [ $size -ne 16 ] && [ $size -ne 32 ]; then cp "$ICON_DIR/logo_${size}.png" "$ICONSET_DIR/icon_$((size / 2))x$((size / 2))@2x.png" fi done # Create ICNS file iconutil -c icns "$ICONSET_DIR" -o assets/logo.icns # Cleanup rm -r "$ICON_DIR" echo "Icon generation complete. ICNS file size: $(du -h assets/logo.icns | cut -f1)" ================================================ FILE: psst-gui/build.rs ================================================ fn main() { #[cfg(windows)] add_windows_icon(); } #[cfg(windows)] fn add_windows_icon() { use image::{ codecs::ico::{IcoEncoder, IcoFrame}, ColorType, }; let ico_path = "assets/logo.ico"; if std::fs::metadata(ico_path).is_err() { let ico_frames = load_images(); save_ico(&ico_frames, ico_path); } let mut res = winres::WindowsResource::new(); res.set_icon(ico_path); res.compile().expect("Could not attach exe icon"); fn load_images() -> Vec> { let sizes = [32, 64, 128, 256]; sizes .iter() .map(|s| { IcoFrame::as_png( image::open(format!("assets/logo_{s}.png")) .unwrap() .as_bytes(), *s, *s, ColorType::Rgba8.into(), ) .unwrap() }) .collect() } fn save_ico(images: &[IcoFrame<'_>], ico_path: &str) { let file = std::fs::File::create(ico_path).unwrap(); let encoder = IcoEncoder::new(file); encoder.encode_images(images).unwrap(); } } ================================================ FILE: psst-gui/src/cmd.rs ================================================ use crate::data::Track; use druid::{Selector, WidgetId}; use psst_core::{item_id::ItemId, player::item::PlaybackItem}; use std::sync::Arc; use std::time::Duration; use crate::{ data::{Nav, PlaybackPayload, QueueBehavior, QueueEntry}, ui::find::Find, }; // Widget IDs pub const WIDGET_SEARCH_INPUT: WidgetId = WidgetId::reserved(1); // Common pub const SHOW_MAIN: Selector = Selector::new("app.show-main"); pub const SHOW_ACCOUNT_SETUP: Selector = Selector::new("app.show-initial"); pub const CLOSE_ALL_WINDOWS: Selector = Selector::new("app.close-all-windows"); pub const QUIT_APP_WITH_SAVE: Selector = Selector::new("app.quit-with-save"); pub const SET_FOCUS: Selector = Selector::new("app.set-focus"); pub const COPY: Selector = Selector::new("app.copy-to-clipboard"); pub const GO_TO_URL: Selector = Selector::new("app.go-to-url"); // Find pub const TOGGLE_FINDER: Selector = Selector::new("app.show-finder"); pub const FIND_IN_PLAYLIST: Selector = Selector::new("find-in-playlist"); pub const FIND_IN_SAVED_TRACKS: Selector = Selector::new("find-in-saved-tracks"); // Session pub const SESSION_CONNECT: Selector = Selector::new("app.session-connect"); pub const LOG_OUT: Selector = Selector::new("app.log-out"); // Navigation pub const NAVIGATE: Selector