Merge pull request #891 from roderickvd/new-api-wip

WIP: new Spotify API
This commit is contained in:
Roderick van Domburg 2021-12-29 07:42:57 +01:00 committed by GitHub
commit b4d3c8363f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
313 changed files with 11649 additions and 4400 deletions

View file

@ -31,32 +31,20 @@ on:
"!LICENSE",
"!*.sh",
]
schedule:
# Run CI every week
- cron: "00 01 * * 0"
env:
RUST_BACKTRACE: 1
RUSTFLAGS: -D warnings
# The layering here is as follows, checking in priority from highest to lowest:
# 1. absence of errors and warnings on Linux/x86
# 2. cross compilation on Windows and Linux/ARM
# 3. absence of lints
# 4. code formatting
jobs:
fmt:
name: rustfmt
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Install toolchain
uses: actions-rs/toolchain@v1
with:
profile: minimal
toolchain: stable
override: true
components: rustfmt
- run: cargo fmt --all -- --check
test-linux:
needs: fmt
name: cargo +${{ matrix.toolchain }} build (${{ matrix.os }})
name: cargo +${{ matrix.toolchain }} check (${{ matrix.os }})
runs-on: ${{ matrix.os }}
continue-on-error: ${{ matrix.experimental }}
strategy:
@ -66,12 +54,11 @@ jobs:
toolchain:
- 1.48 # MSRV (Minimum supported rust version)
- stable
- beta
experimental: [false]
# Ignore failures in nightly
# Ignore failures in beta
include:
- os: ubuntu-latest
toolchain: nightly
toolchain: beta
experimental: true
steps:
- name: Checkout code
@ -106,22 +93,25 @@ jobs:
- run: cargo test --workspace
- run: cargo install cargo-hack
- run: cargo hack --workspace --remove-dev-deps
- run: cargo build -p librespot-core --no-default-features
- run: cargo build -p librespot-core
- run: cargo hack build --each-feature -p librespot-discovery
- run: cargo hack build --each-feature -p librespot-playback
- run: cargo hack build --each-feature
- run: cargo hack --workspace --remove-dev-deps
- run: cargo check -p librespot-core --no-default-features
- run: cargo check -p librespot-core
- run: cargo hack check --each-feature -p librespot-discovery
- run: cargo hack check --each-feature -p librespot-playback
- run: cargo hack check --each-feature
test-windows:
needs: fmt
name: cargo build (${{ matrix.os }})
needs: test-linux
name: cargo +${{ matrix.toolchain }} check (${{ matrix.os }})
runs-on: ${{ matrix.os }}
continue-on-error: false
strategy:
fail-fast: false
matrix:
os: [windows-latest]
toolchain: [stable]
toolchain:
- 1.48 # MSRV (Minimum supported rust version)
- stable
steps:
- name: Checkout code
uses: actions/checkout@v2
@ -153,20 +143,22 @@ jobs:
- run: cargo install cargo-hack
- run: cargo hack --workspace --remove-dev-deps
- run: cargo build --no-default-features
- run: cargo build
- run: cargo check --no-default-features
- run: cargo check
test-cross-arm:
needs: fmt
name: cross +${{ matrix.toolchain }} build ${{ matrix.target }}
needs: test-linux
runs-on: ${{ matrix.os }}
continue-on-error: false
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
target: armv7-unknown-linux-gnueabihf
toolchain: stable
os: [ubuntu-latest]
target: [armv7-unknown-linux-gnueabihf]
toolchain:
- 1.48 # MSRV (Minimum supported rust version)
- stable
steps:
- name: Checkout code
uses: actions/checkout@v2
@ -197,3 +189,67 @@ jobs:
run: cargo install cross || true
- name: Build
run: cross build --locked --target ${{ matrix.target }} --no-default-features
clippy:
needs: [test-cross-arm, test-windows]
name: cargo +${{ matrix.toolchain }} clippy (${{ matrix.os }})
runs-on: ${{ matrix.os }}
continue-on-error: false
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest]
toolchain: [stable]
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Install toolchain
uses: actions-rs/toolchain@v1
with:
profile: minimal
toolchain: ${{ matrix.toolchain }}
override: true
components: clippy
- name: Get Rustc version
id: get-rustc-version
run: echo "::set-output name=version::$(rustc -V)"
shell: bash
- name: Cache Rust dependencies
uses: actions/cache@v2
with:
path: |
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git
target
key: ${{ runner.os }}-${{ steps.get-rustc-version.outputs.version }}-${{ hashFiles('Cargo.lock') }}
- name: Install developer package dependencies
run: sudo apt-get update && sudo apt-get install libpulse-dev portaudio19-dev libasound2-dev libsdl2-dev gstreamer1.0-dev libgstreamer-plugins-base1.0-dev libavahi-compat-libdnssd-dev
- run: cargo install cargo-hack
- run: cargo hack --workspace --remove-dev-deps
- run: cargo clippy -p librespot-core --no-default-features
- run: cargo clippy -p librespot-core
- run: cargo hack clippy --each-feature -p librespot-discovery
- run: cargo hack clippy --each-feature -p librespot-playback
- run: cargo hack clippy --each-feature
fmt:
needs: clippy
name: cargo +${{ matrix.toolchain }} fmt
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Install toolchain
uses: actions-rs/toolchain@v1
with:
profile: minimal
toolchain: stable
override: true
components: rustfmt
- run: cargo fmt --all -- --check

View file

@ -6,15 +6,51 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html) since v0.2.0.
## [Unreleased]
### Changed
- [main] Enforce reasonable ranges for option values (breaking).
- [main] Don't evaluate options that would otherwise have no effect.
- [playback] `alsa`: Improve `--device ?` functionality for the alsa backend.
- [contrib] Hardened security of the systemd service units
- [main] Verbose logging mode (`-v`, `--verbose`) now logs all parsed environment variables and command line arguments (credentials are redacted).
### Added
- [cache] Add `disable-credential-cache` flag (breaking).
- [main] Use different option descriptions and error messages based on what backends are enabled at build time.
- [main] Add a `-q`, `--quiet` option that changes the logging level to warn.
- [main] Add a short name for every flag and option.
- [main] Add the ability to parse environment variables.
### Fixed
- [main] Prevent hang when discovery is disabled and there are no credentials or when bad credentials are given.
- [main] Don't panic when parsing options. Instead list valid values and exit.
### Removed
- [playback] `alsamixer`: previously deprecated option `mixer-card` has been removed.
- [playback] `alsamixer`: previously deprecated option `mixer-name` has been removed.
- [playback] `alsamixer`: previously deprecated option `mixer-index` has been removed.
## [0.3.1] - 2021-10-24
### Changed
- Include build profile in the displayed version information
- [playback] Improve dithering CPU usage by about 33%
### Fixed
- [connect] Partly fix behavior after last track of an album/playlist
## [0.3.0] - 2021-10-13
### Added
- [discovery] The crate `librespot-discovery` for discovery in LAN was created. Its functionality was previously part of `librespot-connect`.
- [playback] Add support for dithering with `--dither` for lower requantization error (breaking)
- [playback] Add `--volume-range` option to set dB range and control `log` and `cubic` volume control curves
- [playback] `alsamixer`: support for querying dB range from Alsa softvol
- [playback] Add `--format F64` (supported by Alsa and GStreamer only)
- [playback] Add `--normalisation-gain-type auto` that switches between album and track automatically
### Changed
- [audio, playback] Moved `VorbisDecoder`, `VorbisError`, `AudioPacket`, `PassthroughDecoder`, `PassthroughError`, `AudioError`, `AudioDecoder` and the `convert` module from `librespot-audio` to `librespot-playback`. The underlying crates `vorbis`, `librespot-tremor`, `lewton` and `ogg` should be used directly. (breaking)
- [audio, playback] Moved `VorbisDecoder`, `VorbisError`, `AudioPacket`, `PassthroughDecoder`, `PassthroughError`, `DecoderError`, `AudioDecoder` and the `convert` module from `librespot-audio` to `librespot-playback`. The underlying crates `vorbis`, `librespot-tremor`, `lewton` and `ogg` should be used directly. (breaking)
- [audio, playback] Use `Duration` for time constants and functions (breaking)
- [connect, playback] Moved volume controls from `librespot-connect` to `librespot-playback` crate
- [connect] Synchronize player volume with mixer volume on playback
@ -22,20 +58,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- [playback] Make cubic volume control available to all mixers with `--volume-ctrl cubic`
- [playback] Normalize volumes to `[0.0..1.0]` instead of `[0..65535]` for greater precision and performance (breaking)
- [playback] `alsamixer`: complete rewrite (breaking)
- [playback] `alsamixer`: query card dB range for the `log` volume control unless specified otherwise
- [playback] `alsamixer`: query card dB range for the volume control unless specified otherwise
- [playback] `alsamixer`: use `--device` name for `--mixer-card` unless specified otherwise
- [playback] `player`: consider errors in `sink.start`, `sink.stop` and `sink.write` fatal and `exit(1)` (breaking)
- [playback] `player`: make `convert` and `decoder` public so you can implement your own `Sink`
- [playback] `player`: update default normalisation threshold to -2 dBFS
- [playback] `player`: default normalisation type is now `auto`
### Deprecated
- [connect] The `discovery` module was deprecated in favor of the `librespot-discovery` crate
- [playback] `alsamixer`: renamed `mixer-card` to `alsa-mixer-device`
- [playback] `alsamixer`: renamed `mixer-name` to `alsa-mixer-control`
- [playback] `alsamixer`: renamed `mixer-index` to `alsa-mixer-index`
### Removed
- [connect] Removed no-op mixer started/stopped logic (breaking)
- [playback] Removed `with-vorbis` and `with-tremor` features
- [playback] `alsamixer`: removed `--mixer-linear-volume` option; use `--volume-ctrl linear` instead
- [playback] `alsamixer`: removed `--mixer-linear-volume` option, now that `--volume-ctrl {linear|log}` work as expected on Alsa
### Fixed
- [connect] Fix step size on volume up/down events
- [connect] Fix looping back to the first track after the last track of an album or playlist
- [playback] Incorrect `PlayerConfig::default().normalisation_threshold` caused distortion when using dynamic volume normalisation downstream
- [playback] Fix `log` and `cubic` volume controls to be mute at zero volume
- [playback] Fix `S24_3` format on big-endian systems
@ -43,7 +86,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- [playback] `alsamixer`: make `--volume-ctrl {linear|log}` work as expected
- [playback] `alsa`, `gstreamer`, `pulseaudio`: always output in native endianness
- [playback] `alsa`: revert buffer size to ~500 ms
- [playback] `alsa`, `pipe`: better error handling
- [playback] `alsa`, `pipe`, `pulseaudio`: better error handling
- [metadata] Skip tracks whose Spotify ID's can't be found (e.g. local files, which aren't supported)
## [0.2.0] - 2021-05-04
@ -59,7 +103,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [0.1.0] - 2019-11-06
[unreleased]: https://github.com/librespot-org/librespot/compare/v0.2.0..HEAD
[unreleased]: https://github.com/librespot-org/librespot/compare/v0.3.1..HEAD
[0.3.1]: https://github.com/librespot-org/librespot/compare/v0.3.0..v0.3.1
[0.3.0]: https://github.com/librespot-org/librespot/compare/v0.2.0..v0.3.0
[0.2.0]: https://github.com/librespot-org/librespot/compare/v0.1.6..v0.2.0
[0.1.6]: https://github.com/librespot-org/librespot/compare/v0.1.5..v0.1.6
[0.1.5]: https://github.com/librespot-org/librespot/compare/v0.1.3..v0.1.5

View file

@ -5,20 +5,15 @@
In order to compile librespot, you will first need to set up a suitable Rust build environment, with the necessary dependencies installed. You will need to have a C compiler, Rust, and the development libraries for the audio backend(s) you want installed. These instructions will walk you through setting up a simple build environment.
### Install Rust
The easiest, and recommended way to get Rust is to use [rustup](https://rustup.rs). On Unix/MacOS You can install `rustup` with this command:
```bash
curl https://sh.rustup.rs -sSf | sh
```
Follow any prompts it gives you to install Rust. Once thats done, Rust's standard tools should be setup and ready to use.
The easiest, and recommended way to get Rust is to use [rustup](https://rustup.rs). Once thats installed, Rust's standard tools should be set up and ready to use.
*Note: The current minimum required Rust version at the time of writing is 1.48, you can find the current minimum version specified in the `.github/workflow/test.yml` file.*
#### Additional Rust tools - `rustfmt`
To ensure a consistent codebase, we utilise [`rustfmt`](https://github.com/rust-lang/rustfmt), which is installed by default with `rustup` these days, else it can be installed manually with:
To ensure a consistent codebase, we utilise [`rustfmt`](https://github.com/rust-lang/rustfmt) and [`clippy`](https://github.com/rust-lang/rust-clippy), which are installed by default with `rustup` these days, else they can be installed manually with:
```bash
rustup component add rustfmt
rustup component add clippy
```
Using `rustfmt` is not optional, as our CI checks against this repo's rules.
@ -43,12 +38,13 @@ Depending on the chosen backend, specific development libraries are required.
|--------------------|------------------------------|-----------------------------------|-------------|
|Rodio (default) | `libasound2-dev` | `alsa-lib-devel` | |
|ALSA | `libasound2-dev, pkg-config` | `alsa-lib-devel` | |
|GStreamer | `gstreamer1.0-plugins-base libgstreamer-plugins-base1.0-dev gstreamer1.0-plugins-good libgstreamer-plugins-good1.0-dev` | `gstreamer1 gstreamer1-devel gstreamer1-plugins-base-devel gstreamer1-plugins-good` | `gstreamer gst-devtools gst-plugins-base gst-plugins-good` |
|PortAudio | `portaudio19-dev` | `portaudio-devel` | `portaudio` |
|PulseAudio | `libpulse-dev` | `pulseaudio-libs-devel` | |
|JACK | `libjack-dev` | `jack-audio-connection-kit-devel` | |
|JACK over Rodio | `libjack-dev` | `jack-audio-connection-kit-devel` | - |
|SDL | `libsdl2-dev` | `SDL2-devel` | |
|Pipe | - | - | - |
|JACK | `libjack-dev` | `jack-audio-connection-kit-devel` | `jack` |
|JACK over Rodio | `libjack-dev` | `jack-audio-connection-kit-devel` | `jack` |
|SDL | `libsdl2-dev` | `SDL2-devel` | `sdl2` |
|Pipe & subprocess | - | - | - |
###### For example, to build an ALSA based backend, you would need to run the following to install the required dependencies:
@ -68,7 +64,6 @@ The recommended method is to first fork the repo, so that you have a copy that y
```bash
git clone git@github.com:YOURUSERNAME/librespot.git
cd librespot
```
## Compiling & Running
@ -109,7 +104,9 @@ cargo build --no-default-features --features "alsa-backend"
Assuming you just compiled a ```debug``` build, you can run librespot with the following command:
```bash
./target/debug/librespot -n Librespot
./target/debug/librespot
```
There are various runtime options, documented in the wiki, and visible by running librespot with the ```-h``` argument.
Note that debug builds may cause buffer underruns and choppy audio when dithering is enabled (which it is by default). You can disable dithering with ```--dither none```.

View file

@ -8,10 +8,12 @@ If you have encountered a bug, please report it, as we rely on user reports to f
Please also make sure that your issues are helpful. To ensure that your issue is helpful, please read over this brief checklist to avoid the more common pitfalls:
- Please take a moment to search/read previous similar issues to ensure you arent posting a duplicate. Duplicates will be closed immediately.
- Please include a clear description of what the issue is. Issues with descriptions such as It hangs after 40 minutes will be closed immediately.
- Please include, where possible, steps to reproduce the bug, along with any other material that is related to the bug. For example, if librespot consistently crashes when you try to play a song, please include the Spotify URI of that song. This can be immensely helpful in quickly pinpointing and resolving issues.
- Lastly, and perhaps most importantly, please include a backtrace where possible. Recent versions of librespot should produce these automatically when it crashes, and print them to the console, but in some cases, you may need to run export RUST_BACKTRACE=full before running librespot to enable backtraces.
- Please take a moment to search/read previous similar issues to ensure you arent posting a duplicate. Duplicates will be closed immediately.
- Please include a clear description of what the issue is. Issues with descriptions such as It hangs after 40 minutes will be closed immediately.
- Please include, where possible, steps to reproduce the bug, along with any other material that is related to the bug. For example, if librespot consistently crashes when you try to play a song, please include the Spotify URI of that song. This can be immensely helpful in quickly pinpointing and resolving issues.
- Please be alert and respond to questions asked by any project members. Stale issues will be closed.
- When your issue concerns audio playback, please first make sure that your audio system is set up correctly and can play audio from other applications. This project aims to provide correct audio backends, not to provide Linux support to end users.
- Lastly, and perhaps most importantly, please include a backtrace where possible. Recent versions of librespot should produce these automatically when it crashes, and print them to the console, but in some cases, you may need to run export RUST_BACKTRACE=full before running librespot to enable backtraces.
## Contributing Code
@ -29,20 +31,25 @@ In order to prepare for a PR, you will need to do a couple of things first:
Make any changes that you are going to make to the code, but do not commit yet.
Unless your changes are negligible, please add an entry in the "Unreleased" section of `CHANGELOG.md`. Refer to [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) for instructions on how this entry should look like.
Unless your changes are negligible, please add an entry in the "Unreleased" section of `CHANGELOG.md`. Refer to [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) for instructions on how this entry should look like. If your changes break the API such that downstream packages that depend on librespot need to update their source to still compile, you should mark your changes as `(breaking)`.
Make sure that the code is correctly formatted by running:
```bash
cargo +stable fmt --all
cargo fmt --all
```
This command runs the previously installed stable version of ```rustfmt```, a code formatting tool that will automatically correct any formatting that you have used that does not conform with the librespot code style. Once that command has run, you will need to rebuild the project:
This command runs ```rustfmt```, a code formatting tool that will automatically correct any formatting that you have used that does not conform with the librespot code style. Once that command has run, you will need to rebuild the project:
```bash
cargo build
```
Once it has built, and you have confirmed there are no warnings or errors, you should commit your changes.
Once it has built, check for common code mistakes by running:
```bash
cargo clippy
```
Once you have confirmed there are no warnings or errors, you should commit your changes.
```bash
git commit -a -m "My fancy fix"

816
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
[package]
name = "librespot"
version = "0.2.0"
version = "0.3.1"
authors = ["Librespot Org"]
license = "MIT"
description = "An open source client library for Spotify, with support for Spotify Connect"
@ -22,35 +22,35 @@ doc = false
[dependencies.librespot-audio]
path = "audio"
version = "0.2.0"
version = "0.3.1"
[dependencies.librespot-connect]
path = "connect"
version = "0.2.0"
version = "0.3.1"
[dependencies.librespot-core]
path = "core"
version = "0.2.0"
version = "0.3.1"
[dependencies.librespot-discovery]
path = "discovery"
version = "0.2.0"
version = "0.3.1"
[dependencies.librespot-metadata]
path = "metadata"
version = "0.2.0"
version = "0.3.1"
[dependencies.librespot-playback]
path = "playback"
version = "0.2.0"
version = "0.3.1"
[dependencies.librespot-protocol]
path = "protocol"
version = "0.2.0"
version = "0.3.1"
[dependencies]
base64 = "0.13"
env_logger = {version = "0.8", default-features = false, features = ["termcolor","humantime","atty"]}
env_logger = { version = "0.8", default-features = false, features = ["termcolor", "humantime", "atty"] }
futures-util = { version = "0.3", default_features = false }
getopts = "0.2.21"
hex = "0.4"
@ -58,7 +58,7 @@ hyper = "0.14"
log = "0.4"
rpassword = "5.0"
thiserror = "1.0"
tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "signal", "sync", "process"] }
tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "signal", "sync", "parking_lot", "process"] }
url = "2.2"
sha-1 = "0.9"
@ -72,7 +72,7 @@ rodiojack-backend = ["librespot-playback/rodiojack-backend"]
sdl-backend = ["librespot-playback/sdl-backend"]
gstreamer-backend = ["librespot-playback/gstreamer-backend"]
with-dns-sd = ["librespot-discovery/with-dns-sd"]
with-dns-sd = ["librespot-core/with-dns-sd", "librespot-discovery/with-dns-sd"]
default = ["rodio-backend"]

View file

@ -2,17 +2,17 @@
[![Gitter chat](https://badges.gitter.im/librespot-org/librespot.png)](https://gitter.im/librespot-org/spotify-connect-resources)
[![Crates.io](https://img.shields.io/crates/v/librespot.svg)](https://crates.io/crates/librespot)
Current maintainer is [@awiouy](https://github.com/awiouy) folks.
Current maintainers are [listed on GitHub](https://github.com/orgs/librespot-org/people).
# librespot
*librespot* is an open source client library for Spotify. It enables applications to use Spotify's service to control and play music via various backends, and to act as a Spotify Connect receiver. It is an alternative to the official and [now deprecated](https://pyspotify.mopidy.com/en/latest/#libspotify-s-deprecation) closed-source `libspotify`. Additionally, it will provide extra features which are not available in the official library.
_Note: librespot only works with Spotify Premium. This will remain the case for the foreseeable future, as we are unlikely to work on implementing the features such as limited skips and adverts that would be required to make librespot compliant with free accounts._
_Note: librespot only works with Spotify Premium. This will remain the case. We will not support any features to make librespot compatible with free accounts, such as limited skips and adverts._
## Quick start
We're available on [crates.io](https://crates.io/crates/librespot) as the _librespot_ package. Simply run `cargo install librespot` to install librespot on your system. Check the wiki for more info and possible [usage options](https://github.com/librespot-org/librespot/wiki/Options).
After installation, you can run librespot from the CLI using a command such as `librespot -n "Librespot Speaker" -b 160` to create a speaker called _Librespot Speaker_ serving 160kbps audio.
After installation, you can run librespot from the CLI using a command such as `librespot -n "Librespot Speaker" -b 160` to create a speaker called _Librespot Speaker_ serving 160 kbps audio.
## This fork
As the origin by [plietar](https://github.com/plietar/) is no longer actively maintained, this organisation and repository have been set up so that the project may be maintained and upgraded in the future.
@ -20,7 +20,7 @@ As the origin by [plietar](https://github.com/plietar/) is no longer actively ma
# Documentation
Documentation is currently a work in progress, contributions are welcome!
There is some brief documentation on how the protocol works in the [docs](https://github.com/librespot-org/librespot/tree/master/docs) folder,
There is some brief documentation on how the protocol works in the [docs](https://github.com/librespot-org/librespot/tree/master/docs) folder.
[COMPILING.md](https://github.com/librespot-org/librespot/blob/master/COMPILING.md) contains detailed instructions on setting up a development environment, and compiling librespot. More general usage and compilation information is available on the [wiki](https://github.com/librespot-org/librespot/wiki).
[CONTRIBUTING.md](https://github.com/librespot-org/librespot/blob/master/CONTRIBUTING.md) also contains our contributing guidelines.
@ -30,37 +30,39 @@ If you wish to learn more about how librespot works overall, the best way is to
# Issues & Discussions
**We have recently started using Github discussions for general questions and feature requests, as they are a more natural medium for such cases, and allow for upvoting to prioritize feature development. Check them out [here](https://github.com/librespot-org/librespot/discussions). Bugs and issues with the underlying library should still be reported as issues.**
If you run into a bug when using librespot, please search the existing issues before opening a new one. Chances are, we've encountered it before, and have provided a resolution. If not, please open a new one, and where possible, include the backtrace librespot generates on crashing, along with anything we can use to reproduce the issue, eg. the Spotify URI of the song that caused the crash.
If you run into a bug when using librespot, please search the existing issues before opening a new one. Chances are, we've encountered it before, and have provided a resolution. If not, please open a new one, and where possible, include the backtrace librespot generates on crashing, along with anything we can use to reproduce the issue, e.g. the Spotify URI of the song that caused the crash.
# Building
A quick walk through of the build process is outlined here, while a detailed compilation guide can be found [here](https://github.com/librespot-org/librespot/blob/master/COMPILING.md).
A quick walkthrough of the build process is outlined below, while a detailed compilation guide can be found [here](https://github.com/librespot-org/librespot/blob/master/COMPILING.md).
## Additional Dependencies
We recently switched to using [Rodio](https://github.com/tomaka/rodio) for audio playback by default, hence for macOS and Windows, you should just be able to clone and build librespot (with the command below).
For Linux, you will need to run the additional commands below, depending on your distro.
On Debian/Ubuntu, the following command will install these dependencies :
On Debian/Ubuntu, the following command will install these dependencies:
```shell
sudo apt-get install build-essential libasound2-dev
```
On Fedora systems, the following command will install these dependencies :
On Fedora systems, the following command will install these dependencies:
```shell
sudo dnf install alsa-lib-devel make gcc
```
librespot currently offers the following selection of [audio backends](https://github.com/librespot-org/librespot/wiki/Audio-Backends).
librespot currently offers the following selection of [audio backends](https://github.com/librespot-org/librespot/wiki/Audio-Backends):
```
Rodio (default)
ALSA
GStreamer
PortAudio
PulseAudio
JACK
JACK over Rodio
SDL
Pipe
Subprocess
```
Please check the corresponding [compiling entry](https://github.com/librespot-org/librespot/wiki/Compiling#general-dependencies) for backend specific dependencies.
Please check the corresponding [Compiling](https://github.com/librespot-org/librespot/wiki/Compiling#general-dependencies) entry on the wiki for backend specific dependencies.
Once you've installed the dependencies and cloned this repository you can build *librespot* with the default backend using Cargo.
```shell
@ -84,14 +86,14 @@ The above is a minimal example. Here is a more fully fledged one:
```shell
target/release/librespot -n "Librespot" -b 320 -c ./cache --enable-volume-normalisation --initial-volume 75 --device-type avr
```
The above command will create a receiver named ```Librespot```, with bitrate set to 320kbps, initial volume at 75%, with volume normalisation enabled, and the device displayed in the app as an Audio/Video Receiver. A folder named ```cache``` will be created/used in the current directory, and be used to cache audio data and credentials.
The above command will create a receiver named ```Librespot```, with bitrate set to 320 kbps, initial volume at 75%, with volume normalisation enabled, and the device displayed in the app as an Audio/Video Receiver. A folder named ```cache``` will be created/used in the current directory, and be used to cache audio data and credentials.
A full list of runtime options are available [here](https://github.com/librespot-org/librespot/wiki/Options)
A full list of runtime options is available [here](https://github.com/librespot-org/librespot/wiki/Options).
_Please Note: When using the cache feature, an authentication blob is stored for your account in the cache directory. For security purposes, we recommend that you set directory permissions on the cache directory to `700`._
## Contact
Come and hang out on gitter if you need help or want to offer some.
Come and hang out on gitter if you need help or want to offer some:
https://gitter.im/librespot-org/spotify-connect-resources
## Disclaimer
@ -114,3 +116,4 @@ functionality.
- [librespot-java](https://github.com/devgianlu/librespot-java) - A Java port of librespot.
- [ncspot](https://github.com/hrkfdn/ncspot) - Cross-platform ncurses Spotify client.
- [ansible-role-librespot](https://github.com/xMordax/ansible-role-librespot/tree/master) - Ansible role that will build, install and configure Librespot.
- [Spot](https://github.com/xou816/spot) - Gtk/Rust native Spotify client for the GNOME desktop.

View file

@ -1,20 +1,25 @@
[package]
name = "librespot-audio"
version = "0.2.0"
version = "0.3.1"
authors = ["Paul Lietar <paul@lietar.net>"]
description="The audio fetching and processing logic for librespot"
license="MIT"
license = "MIT"
edition = "2018"
[dependencies.librespot-core]
path = "../core"
version = "0.2.0"
version = "0.3.1"
[dependencies]
aes-ctr = "0.6"
byteorder = "1.4"
bytes = "1.0"
log = "0.4"
futures-core = { version = "0.3", default-features = false }
futures-executor = "0.3"
futures-util = { version = "0.3", default_features = false }
hyper = { version = "0.14", features = ["client"] }
log = "0.4"
parking_lot = { version = "0.11", features = ["deadlock_detection"] }
tempfile = "3.1"
tokio = { version = "1", features = ["sync", "macros"] }
thiserror = "1.0"
tokio = { version = "1", features = ["macros", "parking_lot", "sync"] }

View file

@ -1,8 +1,11 @@
use std::io;
use aes_ctr::cipher::generic_array::GenericArray;
use aes_ctr::cipher::{NewStreamCipher, SyncStreamCipher, SyncStreamCipherSeek};
use aes_ctr::Aes128Ctr;
use aes_ctr::{
cipher::{
generic_array::GenericArray, NewStreamCipher, SyncStreamCipher, SyncStreamCipherSeek,
},
Aes128Ctr,
};
use librespot_core::audio_key::AudioKey;
@ -18,8 +21,8 @@ pub struct AudioDecrypt<T: io::Read> {
impl<T: io::Read> AudioDecrypt<T> {
pub fn new(key: AudioKey, reader: T) -> AudioDecrypt<T> {
let cipher = Aes128Ctr::new(
&GenericArray::from_slice(&key.0),
&GenericArray::from_slice(&AUDIO_AESIV),
GenericArray::from_slice(&key.0),
GenericArray::from_slice(&AUDIO_AESIV),
);
AudioDecrypt { cipher, reader }
}

View file

@ -1,40 +1,77 @@
mod receive;
use std::cmp::{max, min};
use std::fs;
use std::io::{self, Read, Seek, SeekFrom};
use std::sync::atomic::{self, AtomicUsize};
use std::sync::{Arc, Condvar, Mutex};
use std::time::{Duration, Instant};
use std::{
cmp::{max, min},
fs,
io::{self, Read, Seek, SeekFrom},
sync::{
atomic::{self, AtomicUsize},
Arc,
},
time::{Duration, Instant},
};
use byteorder::{BigEndian, ByteOrder};
use futures_util::{future, StreamExt, TryFutureExt, TryStreamExt};
use librespot_core::channel::{ChannelData, ChannelError, ChannelHeaders};
use librespot_core::session::Session;
use librespot_core::spotify_id::FileId;
use futures_util::{future::IntoStream, StreamExt, TryFutureExt};
use hyper::{client::ResponseFuture, header::CONTENT_RANGE, Body, Response, StatusCode};
use parking_lot::{Condvar, Mutex};
use tempfile::NamedTempFile;
use thiserror::Error;
use tokio::sync::{mpsc, oneshot};
use self::receive::{audio_file_fetch, request_range};
use librespot_core::{cdn_url::CdnUrl, Error, FileId, Session};
use self::receive::audio_file_fetch;
use crate::range_set::{Range, RangeSet};
pub type AudioFileResult = Result<(), librespot_core::Error>;
#[derive(Error, Debug)]
pub enum AudioFileError {
#[error("other end of channel disconnected")]
Channel,
#[error("required header not found")]
Header,
#[error("streamer received no data")]
NoData,
#[error("no output available")]
Output,
#[error("invalid status code {0}")]
StatusCode(StatusCode),
#[error("wait timeout exceeded")]
WaitTimeout,
}
impl From<AudioFileError> for Error {
fn from(err: AudioFileError) -> Self {
match err {
AudioFileError::Channel => Error::aborted(err),
AudioFileError::Header => Error::unavailable(err),
AudioFileError::NoData => Error::unavailable(err),
AudioFileError::Output => Error::aborted(err),
AudioFileError::StatusCode(_) => Error::failed_precondition(err),
AudioFileError::WaitTimeout => Error::deadline_exceeded(err),
}
}
}
/// The minimum size of a block that is requested from the Spotify servers in one request.
/// This is the block size that is typically requested while doing a `seek()` on a file.
/// Note: smaller requests can happen if part of the block is downloaded already.
const MINIMUM_DOWNLOAD_SIZE: usize = 1024 * 16;
pub const MINIMUM_DOWNLOAD_SIZE: usize = 1024 * 128;
/// The amount of data that is requested when initially opening a file.
/// Note: if the file is opened to play from the beginning, the amount of data to
/// read ahead is requested in addition to this amount. If the file is opened to seek to
/// another position, then only this amount is requested on the first request.
const INITIAL_DOWNLOAD_SIZE: usize = 1024 * 16;
pub const INITIAL_DOWNLOAD_SIZE: usize = 1024 * 128;
/// The ping time that is used for calculations before a ping time was actually measured.
const INITIAL_PING_TIME_ESTIMATE: Duration = Duration::from_millis(500);
pub const INITIAL_PING_TIME_ESTIMATE: Duration = Duration::from_millis(500);
/// If the measured ping time to the Spotify server is larger than this value, it is capped
/// to avoid run-away block sizes and pre-fetching.
const MAXIMUM_ASSUMED_PING_TIME: Duration = Duration::from_millis(1500);
pub const MAXIMUM_ASSUMED_PING_TIME: Duration = Duration::from_millis(1500);
/// Before playback starts, this many seconds of data must be present.
/// Note: the calculations are done using the nominal bitrate of the file. The actual amount
@ -63,7 +100,7 @@ pub const READ_AHEAD_DURING_PLAYBACK_ROUNDTRIPS: f32 = 10.0;
/// If the amount of data that is pending (requested but not received) is less than a certain amount,
/// data is pre-fetched in addition to the read ahead settings above. The threshold for requesting more
/// data is calculated as `<pending bytes> < PREFETCH_THRESHOLD_FACTOR * <ping time> * <nominal data rate>`
const PREFETCH_THRESHOLD_FACTOR: f32 = 4.0;
pub const PREFETCH_THRESHOLD_FACTOR: f32 = 4.0;
/// Similar to `PREFETCH_THRESHOLD_FACTOR`, but it also takes the current download rate into account.
/// The formula used is `<pending bytes> < FAST_PREFETCH_THRESHOLD_FACTOR * <ping time> * <measured download rate>`
@ -72,16 +109,16 @@ const PREFETCH_THRESHOLD_FACTOR: f32 = 4.0;
/// the download rate ramps up. However, this comes at the cost that it might hurt ping time if a seek is
/// performed while downloading. Values smaller than `1.0` cause the download rate to collapse and effectively
/// only `PREFETCH_THRESHOLD_FACTOR` is in effect. Thus, set to `0.0` if bandwidth saturation is not wanted.
const FAST_PREFETCH_THRESHOLD_FACTOR: f32 = 1.5;
pub const FAST_PREFETCH_THRESHOLD_FACTOR: f32 = 1.5;
/// Limit the number of requests that are pending simultaneously before pre-fetching data. Pending
/// requests share bandwidth. Thus, havint too many requests can lead to the one that is needed next
/// requests share bandwidth. Thus, having too many requests can lead to the one that is needed next
/// for playback to be delayed leading to a buffer underrun. This limit has the effect that a new
/// pre-fetch request is only sent if less than `MAX_PREFETCH_REQUESTS` are pending.
const MAX_PREFETCH_REQUESTS: usize = 4;
pub const MAX_PREFETCH_REQUESTS: usize = 4;
/// The time we will wait to obtain status updates on downloading.
const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(1);
pub const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(1);
pub enum AudioFile {
Cached(fs::File),
@ -89,7 +126,16 @@ pub enum AudioFile {
}
#[derive(Debug)]
enum StreamLoaderCommand {
pub struct StreamingRequest {
streamer: IntoStream<ResponseFuture>,
initial_response: Option<Response<Body>>,
offset: usize,
length: usize,
request_time: Instant,
}
#[derive(Debug)]
pub enum StreamLoaderCommand {
Fetch(Range), // signal the stream loader to fetch a range of the file
RandomAccessMode(), // optimise download strategy for random access
StreamMode(), // optimise download strategy for streaming
@ -113,22 +159,28 @@ impl StreamLoaderController {
}
pub fn range_available(&self, range: Range) -> bool {
if let Some(ref shared) = self.stream_shared {
let download_status = shared.download_status.lock().unwrap();
let available = if let Some(ref shared) = self.stream_shared {
let download_status = shared.download_status.lock();
range.length
<= download_status
.downloaded
.contained_length_from_value(range.start)
} else {
range.length <= self.len() - range.start
}
};
available
}
pub fn range_to_end_available(&self) -> bool {
self.stream_shared.as_ref().map_or(true, |shared| {
let read_position = shared.read_position.load(atomic::Ordering::Relaxed);
self.range_available(Range::new(read_position, self.len() - read_position))
})
match self.stream_shared {
Some(ref shared) => {
let read_position = shared.read_position.load(atomic::Ordering::Relaxed);
self.range_available(Range::new(read_position, self.len() - read_position))
}
None => true,
}
}
pub fn ping_time(&self) -> Duration {
@ -139,7 +191,8 @@ impl StreamLoaderController {
fn send_stream_loader_command(&self, command: StreamLoaderCommand) {
if let Some(ref channel) = self.channel_tx {
// ignore the error in case the channel has been closed already.
// Ignore the error in case the channel has been closed already.
// This means that the file was completely downloaded.
let _ = channel.send(command);
}
}
@ -149,7 +202,7 @@ impl StreamLoaderController {
self.send_stream_loader_command(StreamLoaderCommand::Fetch(range));
}
pub fn fetch_blocking(&self, mut range: Range) {
pub fn fetch_blocking(&self, mut range: Range) -> AudioFileResult {
// signal the stream loader to tech a range of the file and block until it is loaded.
// ensure the range is within the file's bounds.
@ -162,17 +215,21 @@ impl StreamLoaderController {
self.fetch(range);
if let Some(ref shared) = self.stream_shared {
let mut download_status = shared.download_status.lock().unwrap();
let mut download_status = shared.download_status.lock();
while range.length
> download_status
.downloaded
.contained_length_from_value(range.start)
{
download_status = shared
if shared
.cond
.wait_timeout(download_status, DOWNLOAD_TIMEOUT)
.unwrap()
.0;
.wait_for(&mut download_status, DOWNLOAD_TIMEOUT)
.timed_out()
{
return Err(AudioFileError::WaitTimeout.into());
}
if range.length
> (download_status
.downloaded
@ -185,6 +242,8 @@ impl StreamLoaderController {
}
}
}
Ok(())
}
pub fn fetch_next(&self, length: usize) {
@ -193,17 +252,20 @@ impl StreamLoaderController {
start: shared.read_position.load(atomic::Ordering::Relaxed),
length,
};
self.fetch(range)
self.fetch(range);
}
}
pub fn fetch_next_blocking(&self, length: usize) {
if let Some(ref shared) = self.stream_shared {
let range = Range {
start: shared.read_position.load(atomic::Ordering::Relaxed),
length,
};
self.fetch_blocking(range);
pub fn fetch_next_blocking(&self, length: usize) -> AudioFileResult {
match self.stream_shared {
Some(ref shared) => {
let range = Range {
start: shared.read_position.load(atomic::Ordering::Relaxed),
length,
};
self.fetch_blocking(range)
}
None => Ok(()),
}
}
@ -242,9 +304,9 @@ enum DownloadStrategy {
}
struct AudioFileShared {
file_id: FileId,
cdn_url: CdnUrl,
file_size: usize,
stream_data_rate: usize,
bytes_per_second: usize,
cond: Condvar,
download_status: Mutex<AudioFileDownloadStatus>,
download_strategy: Mutex<DownloadStrategy>,
@ -259,7 +321,7 @@ impl AudioFile {
file_id: FileId,
bytes_per_second: usize,
play_from_beginning: bool,
) -> Result<AudioFile, ChannelError> {
) -> Result<AudioFile, Error> {
if let Some(file) = session.cache().and_then(|cache| cache.file(file_id)) {
debug!("File {} already in cache", file_id);
return Ok(AudioFile::Cached(file));
@ -268,48 +330,35 @@ impl AudioFile {
debug!("Downloading file {}", file_id);
let (complete_tx, complete_rx) = oneshot::channel();
let mut initial_data_length = if play_from_beginning {
INITIAL_DOWNLOAD_SIZE
+ max(
(READ_AHEAD_DURING_PLAYBACK.as_secs_f32() * bytes_per_second as f32) as usize,
(INITIAL_PING_TIME_ESTIMATE.as_secs_f32()
* READ_AHEAD_DURING_PLAYBACK_ROUNDTRIPS
* bytes_per_second as f32) as usize,
)
} else {
INITIAL_DOWNLOAD_SIZE
};
if initial_data_length % 4 != 0 {
initial_data_length += 4 - (initial_data_length % 4);
}
let (headers, data) = request_range(session, file_id, 0, initial_data_length).split();
let streaming = AudioFileStreaming::open(
session.clone(),
data,
initial_data_length,
Instant::now(),
headers,
file_id,
complete_tx,
bytes_per_second,
play_from_beginning,
);
let session_ = session.clone();
session.spawn(complete_rx.map_ok(move |mut file| {
debug!("Downloading file {} complete", file_id);
if let Some(cache) = session_.cache() {
debug!("File {} complete, saving to cache", file_id);
cache.save_file(file_id, &mut file);
} else {
debug!("File {} complete", file_id);
if let Some(cache_id) = cache.file(file_id) {
if let Err(e) = cache.save_file(file_id, &mut file) {
error!("Error caching file {} to {:?}: {}", file_id, cache_id, e);
} else {
debug!("File {} cached to {:?}", file_id, cache_id);
}
}
}
}));
Ok(AudioFile::Streaming(streaming.await?))
}
pub fn get_stream_loader_controller(&self) -> StreamLoaderController {
match self {
pub fn get_stream_loader_controller(&self) -> Result<StreamLoaderController, Error> {
let controller = match self {
AudioFile::Streaming(ref stream) => StreamLoaderController {
channel_tx: Some(stream.stream_loader_command_tx.clone()),
stream_shared: Some(stream.shared.clone()),
@ -318,9 +367,11 @@ impl AudioFile {
AudioFile::Cached(ref file) => StreamLoaderController {
channel_tx: None,
stream_shared: None,
file_size: file.metadata().unwrap().len() as usize,
file_size: file.metadata()?.len() as usize,
},
}
};
Ok(controller)
}
pub fn is_cached(&self) -> bool {
@ -331,53 +382,80 @@ impl AudioFile {
impl AudioFileStreaming {
pub async fn open(
session: Session,
initial_data_rx: ChannelData,
initial_data_length: usize,
initial_request_sent_time: Instant,
headers: ChannelHeaders,
file_id: FileId,
complete_tx: oneshot::Sender<NamedTempFile>,
streaming_data_rate: usize,
) -> Result<AudioFileStreaming, ChannelError> {
let (_, data) = headers
.try_filter(|(id, _)| future::ready(*id == 0x3))
.next()
.await
.unwrap()?;
bytes_per_second: usize,
play_from_beginning: bool,
) -> Result<AudioFileStreaming, Error> {
let download_size = if play_from_beginning {
INITIAL_DOWNLOAD_SIZE
+ max(
(READ_AHEAD_DURING_PLAYBACK.as_secs_f32() * bytes_per_second as f32) as usize,
(INITIAL_PING_TIME_ESTIMATE.as_secs_f32()
* READ_AHEAD_DURING_PLAYBACK_ROUNDTRIPS
* bytes_per_second as f32) as usize,
)
} else {
INITIAL_DOWNLOAD_SIZE
};
let size = BigEndian::read_u32(&data) as usize * 4;
let cdn_url = CdnUrl::new(file_id).resolve_audio(&session).await?;
if let Ok(url) = cdn_url.try_get_url() {
trace!("Streaming from {}", url);
}
let mut streamer = session
.spclient()
.stream_from_cdn(&cdn_url, 0, download_size)?;
let request_time = Instant::now();
// Get the first chunk with the headers to get the file size.
// The remainder of that chunk with possibly also a response body is then
// further processed in `audio_file_fetch`.
let response = streamer.next().await.ok_or(AudioFileError::NoData)??;
let header_value = response
.headers()
.get(CONTENT_RANGE)
.ok_or(AudioFileError::Header)?;
let str_value = header_value.to_str()?;
let file_size_str = str_value.split('/').last().unwrap_or_default();
let file_size = file_size_str.parse()?;
let initial_request = StreamingRequest {
streamer,
initial_response: Some(response),
offset: 0,
length: download_size,
request_time,
};
let shared = Arc::new(AudioFileShared {
file_id,
file_size: size,
stream_data_rate: streaming_data_rate,
cdn_url,
file_size,
bytes_per_second,
cond: Condvar::new(),
download_status: Mutex::new(AudioFileDownloadStatus {
requested: RangeSet::new(),
downloaded: RangeSet::new(),
}),
download_strategy: Mutex::new(DownloadStrategy::RandomAccess()), // start with random access mode until someone tells us otherwise
download_strategy: Mutex::new(DownloadStrategy::Streaming()),
number_of_open_requests: AtomicUsize::new(0),
ping_time_ms: AtomicUsize::new(0),
read_position: AtomicUsize::new(0),
});
let mut write_file = NamedTempFile::new().unwrap();
write_file.as_file().set_len(size as u64).unwrap();
write_file.seek(SeekFrom::Start(0)).unwrap();
let write_file = NamedTempFile::new_in(session.config().tmp_dir.clone())?;
let read_file = write_file.reopen()?;
let read_file = write_file.reopen().unwrap();
// let (seek_tx, seek_rx) = mpsc::unbounded();
let (stream_loader_command_tx, stream_loader_command_rx) =
mpsc::unbounded_channel::<StreamLoaderCommand>();
session.spawn(audio_file_fetch(
session.clone(),
shared.clone(),
initial_data_rx,
initial_request_sent_time,
initial_data_length,
initial_request,
write_file,
stream_loader_command_rx,
complete_tx,
@ -402,7 +480,7 @@ impl Read for AudioFileStreaming {
let length = min(output.len(), self.shared.file_size - offset);
let length_to_request = match *(self.shared.download_strategy.lock().unwrap()) {
let length_to_request = match *(self.shared.download_strategy.lock()) {
DownloadStrategy::RandomAccess() => length,
DownloadStrategy::Streaming() => {
// Due to the read-ahead stuff, we potentially request more than the actual request demanded.
@ -414,10 +492,10 @@ impl Read for AudioFileStreaming {
let length_to_request = length
+ max(
(READ_AHEAD_DURING_PLAYBACK.as_secs_f32()
* self.shared.stream_data_rate as f32) as usize,
* self.shared.bytes_per_second as f32) as usize,
(READ_AHEAD_DURING_PLAYBACK_ROUNDTRIPS
* ping_time_seconds
* self.shared.stream_data_rate as f32) as usize,
* self.shared.bytes_per_second as f32) as usize,
);
min(length_to_request, self.shared.file_size - offset)
}
@ -426,34 +504,33 @@ impl Read for AudioFileStreaming {
let mut ranges_to_request = RangeSet::new();
ranges_to_request.add_range(&Range::new(offset, length_to_request));
let mut download_status = self.shared.download_status.lock().unwrap();
let mut download_status = self.shared.download_status.lock();
ranges_to_request.subtract_range_set(&download_status.downloaded);
ranges_to_request.subtract_range_set(&download_status.requested);
for &range in ranges_to_request.iter() {
self.stream_loader_command_tx
.send(StreamLoaderCommand::Fetch(range))
.unwrap();
.map_err(|err| io::Error::new(io::ErrorKind::BrokenPipe, err))?;
}
if length == 0 {
return Ok(0);
}
let mut download_message_printed = false;
while !download_status.downloaded.contains(offset) {
if let DownloadStrategy::Streaming() = *self.shared.download_strategy.lock().unwrap() {
if !download_message_printed {
debug!("Stream waiting for download of file position {}. Downloaded ranges: {}. Pending ranges: {}", offset, download_status.downloaded, download_status.requested.minus(&download_status.downloaded));
download_message_printed = true;
}
}
download_status = self
if self
.shared
.cond
.wait_timeout(download_status, DOWNLOAD_TIMEOUT)
.unwrap()
.0;
.wait_for(&mut download_status, DOWNLOAD_TIMEOUT)
.timed_out()
{
return Err(io::Error::new(
io::ErrorKind::TimedOut,
Error::deadline_exceeded(AudioFileError::WaitTimeout),
));
}
}
let available_length = download_status
.downloaded
@ -461,19 +538,10 @@ impl Read for AudioFileStreaming {
assert!(available_length > 0);
drop(download_status);
self.position = self.read_file.seek(SeekFrom::Start(offset as u64)).unwrap();
self.position = self.read_file.seek(SeekFrom::Start(offset as u64))?;
let read_len = min(length, available_length);
let read_len = self.read_file.read(&mut output[..read_len])?;
if download_message_printed {
debug!(
"Read at postion {} completed. {} bytes returned, {} bytes were requested.",
offset,
read_len,
output.len()
);
}
self.position += read_len as u64;
self.shared
.read_position

View file

@ -1,58 +1,27 @@
use std::cmp::{max, min};
use std::io::{Seek, SeekFrom, Write};
use std::sync::{atomic, Arc};
use std::time::{Duration, Instant};
use std::{
cmp::{max, min},
io::{Seek, SeekFrom, Write},
sync::{atomic, Arc},
time::{Duration, Instant},
};
use atomic::Ordering;
use byteorder::{BigEndian, WriteBytesExt};
use bytes::Bytes;
use futures_util::StreamExt;
use librespot_core::channel::{Channel, ChannelData};
use librespot_core::packet::PacketType;
use librespot_core::session::Session;
use librespot_core::spotify_id::FileId;
use hyper::StatusCode;
use tempfile::NamedTempFile;
use tokio::sync::{mpsc, oneshot};
use librespot_core::{session::Session, Error};
use crate::range_set::{Range, RangeSet};
use super::{AudioFileShared, DownloadStrategy, StreamLoaderCommand};
use super::{
FAST_PREFETCH_THRESHOLD_FACTOR, MAXIMUM_ASSUMED_PING_TIME, MAX_PREFETCH_REQUESTS,
MINIMUM_DOWNLOAD_SIZE, PREFETCH_THRESHOLD_FACTOR,
AudioFileError, AudioFileResult, AudioFileShared, DownloadStrategy, StreamLoaderCommand,
StreamingRequest, FAST_PREFETCH_THRESHOLD_FACTOR, MAXIMUM_ASSUMED_PING_TIME,
MAX_PREFETCH_REQUESTS, MINIMUM_DOWNLOAD_SIZE, PREFETCH_THRESHOLD_FACTOR,
};
pub fn request_range(session: &Session, file: FileId, offset: usize, length: usize) -> Channel {
assert!(
offset % 4 == 0,
"Range request start positions must be aligned by 4 bytes."
);
assert!(
length % 4 == 0,
"Range request range lengths must be aligned by 4 bytes."
);
let start = offset / 4;
let end = (offset + length) / 4;
let (id, channel) = session.channel().allocate();
let mut data: Vec<u8> = Vec::new();
data.write_u16::<BigEndian>(id).unwrap();
data.write_u8(0).unwrap();
data.write_u8(1).unwrap();
data.write_u16::<BigEndian>(0x0000).unwrap();
data.write_u32::<BigEndian>(0x00000000).unwrap();
data.write_u32::<BigEndian>(0x00009C40).unwrap();
data.write_u32::<BigEndian>(0x00020000).unwrap();
data.write(&file.0).unwrap();
data.write_u32::<BigEndian>(start as u32).unwrap();
data.write_u32::<BigEndian>(end as u32).unwrap();
session.send_packet(PacketType::StreamChunk, data);
channel
}
struct PartialFileData {
offset: usize,
data: Bytes,
@ -66,13 +35,15 @@ enum ReceivedData {
async fn receive_data(
shared: Arc<AudioFileShared>,
file_data_tx: mpsc::UnboundedSender<ReceivedData>,
mut data_rx: ChannelData,
initial_data_offset: usize,
initial_request_length: usize,
request_sent_time: Instant,
) {
let mut data_offset = initial_data_offset;
let mut request_length = initial_request_length;
mut request: StreamingRequest,
) -> AudioFileResult {
let requested_offset = request.offset;
let requested_length = request.length;
let mut data_offset = requested_offset;
let mut request_length = requested_length;
// TODO : check Content-Length and Content-Range headers
let old_number_of_request = shared
.number_of_open_requests
@ -80,31 +51,49 @@ async fn receive_data(
let mut measure_ping_time = old_number_of_request == 0;
let result = loop {
let data = match data_rx.next().await {
Some(Ok(data)) => data,
Some(Err(e)) => break Err(e),
None => break Ok(()),
let result: Result<_, Error> = loop {
let response = match request.initial_response.take() {
Some(data) => data,
None => match request.streamer.next().await {
Some(Ok(response)) => response,
Some(Err(e)) => break Err(e.into()),
None => break Ok(()),
},
};
let code = response.status();
let body = response.into_body();
if code != StatusCode::PARTIAL_CONTENT {
debug!("Streamer expected partial content but got: {}", code);
break Err(AudioFileError::StatusCode(code).into());
}
let data = match hyper::body::to_bytes(body).await {
Ok(bytes) => bytes,
Err(e) => break Err(e.into()),
};
if measure_ping_time {
let mut duration = Instant::now() - request_sent_time;
let mut duration = Instant::now() - request.request_time;
if duration > MAXIMUM_ASSUMED_PING_TIME {
duration = MAXIMUM_ASSUMED_PING_TIME;
}
let _ = file_data_tx.send(ReceivedData::ResponseTime(duration));
file_data_tx.send(ReceivedData::ResponseTime(duration))?;
measure_ping_time = false;
}
let data_size = data.len();
let _ = file_data_tx.send(ReceivedData::Data(PartialFileData {
file_data_tx.send(ReceivedData::Data(PartialFileData {
offset: data_offset,
data,
}));
}))?;
data_offset += data_size;
if request_length < data_size {
warn!(
"Data receiver for range {} (+{}) received more data from server than requested.",
initial_data_offset, initial_request_length
"Data receiver for range {} (+{}) received more data from server than requested ({} instead of {}).",
requested_offset, requested_length, data_size, request_length
);
request_length = 0;
} else {
@ -116,28 +105,38 @@ async fn receive_data(
}
};
if request_length > 0 {
let missing_range = Range::new(data_offset, request_length);
drop(request.streamer);
let mut download_status = shared.download_status.lock().unwrap();
download_status.requested.subtract_range(&missing_range);
shared.cond.notify_all();
if request_length > 0 {
{
let missing_range = Range::new(data_offset, request_length);
let mut download_status = shared.download_status.lock();
download_status.requested.subtract_range(&missing_range);
shared.cond.notify_all();
}
}
shared
.number_of_open_requests
.fetch_sub(1, Ordering::SeqCst);
if result.is_err() {
warn!(
"Error from channel for data receiver for range {} (+{}).",
initial_data_offset, initial_request_length
);
} else if request_length > 0 {
warn!(
"Data receiver for range {} (+{}) received less data from server than requested.",
initial_data_offset, initial_request_length
);
match result {
Ok(()) => {
if request_length > 0 {
warn!(
"Streamer for range {} (+{}) received less data from server than requested.",
requested_offset, requested_length
);
}
Ok(())
}
Err(e) => {
error!(
"Error from streamer for range {} (+{}): {:?}",
requested_offset, requested_length, e
);
Err(e)
}
}
}
@ -160,67 +159,63 @@ enum ControlFlow {
impl AudioFileFetch {
fn get_download_strategy(&mut self) -> DownloadStrategy {
*(self.shared.download_strategy.lock().unwrap())
*(self.shared.download_strategy.lock())
}
fn download_range(&mut self, mut offset: usize, mut length: usize) {
fn download_range(&mut self, offset: usize, mut length: usize) -> AudioFileResult {
if length < MINIMUM_DOWNLOAD_SIZE {
length = MINIMUM_DOWNLOAD_SIZE;
}
// ensure the values are within the bounds and align them by 4 for the spotify protocol.
if offset >= self.shared.file_size {
return;
}
if length == 0 {
return;
}
if offset + length > self.shared.file_size {
length = self.shared.file_size - offset;
}
if offset % 4 != 0 {
length += offset % 4;
offset -= offset % 4;
}
if length % 4 != 0 {
length += 4 - (length % 4);
}
let mut ranges_to_request = RangeSet::new();
ranges_to_request.add_range(&Range::new(offset, length));
let mut download_status = self.shared.download_status.lock().unwrap();
// The iteration that follows spawns streamers fast, without awaiting them,
// so holding the lock for the entire scope of this function should be faster
// then locking and unlocking multiple times.
let mut download_status = self.shared.download_status.lock();
ranges_to_request.subtract_range_set(&download_status.downloaded);
ranges_to_request.subtract_range_set(&download_status.requested);
// TODO : refresh cdn_url when the token expired
for range in ranges_to_request.iter() {
let (_headers, data) = request_range(
&self.session,
self.shared.file_id,
let streamer = self.session.spclient().stream_from_cdn(
&self.shared.cdn_url,
range.start,
range.length,
)
.split();
)?;
download_status.requested.add_range(range);
let streaming_request = StreamingRequest {
streamer,
initial_response: None,
offset: range.start,
length: range.length,
request_time: Instant::now(),
};
self.session.spawn(receive_data(
self.shared.clone(),
self.file_data_tx.clone(),
data,
range.start,
range.length,
Instant::now(),
streaming_request,
));
}
Ok(())
}
fn pre_fetch_more_data(&mut self, bytes: usize, max_requests_to_send: usize) {
fn pre_fetch_more_data(
&mut self,
bytes: usize,
max_requests_to_send: usize,
) -> AudioFileResult {
let mut bytes_to_go = bytes;
let mut requests_to_go = max_requests_to_send;
@ -229,7 +224,7 @@ impl AudioFileFetch {
let mut missing_data = RangeSet::new();
missing_data.add_range(&Range::new(0, self.shared.file_size));
{
let download_status = self.shared.download_status.lock().unwrap();
let download_status = self.shared.download_status.lock();
missing_data.subtract_range_set(&download_status.downloaded);
missing_data.subtract_range_set(&download_status.requested);
}
@ -247,7 +242,7 @@ impl AudioFileFetch {
let range = tail_end.get_range(0);
let offset = range.start;
let length = min(range.length, bytes_to_go);
self.download_range(offset, length);
self.download_range(offset, length)?;
requests_to_go -= 1;
bytes_to_go -= length;
} else if !missing_data.is_empty() {
@ -255,19 +250,21 @@ impl AudioFileFetch {
let range = missing_data.get_range(0);
let offset = range.start;
let length = min(range.length, bytes_to_go);
self.download_range(offset, length);
self.download_range(offset, length)?;
requests_to_go -= 1;
bytes_to_go -= length;
} else {
return;
break;
}
}
Ok(())
}
fn handle_file_data(&mut self, data: ReceivedData) -> ControlFlow {
fn handle_file_data(&mut self, data: ReceivedData) -> Result<ControlFlow, Error> {
match data {
ReceivedData::ResponseTime(response_time) => {
trace!("Ping time estimated as: {}ms", response_time.as_millis());
let old_ping_time_ms = self.shared.ping_time_ms.load(Ordering::Relaxed);
// prune old response times. Keep at most two so we can push a third.
while self.network_response_times.len() >= 3 {
@ -278,109 +275,129 @@ impl AudioFileFetch {
self.network_response_times.push(response_time);
// stats::median is experimental. So we calculate the median of up to three ourselves.
let ping_time = match self.network_response_times.len() {
1 => self.network_response_times[0],
2 => (self.network_response_times[0] + self.network_response_times[1]) / 2,
3 => {
let mut times = self.network_response_times.clone();
times.sort_unstable();
times[1]
}
_ => unreachable!(),
let ping_time_ms = {
let response_time = match self.network_response_times.len() {
1 => self.network_response_times[0],
2 => (self.network_response_times[0] + self.network_response_times[1]) / 2,
3 => {
let mut times = self.network_response_times.clone();
times.sort_unstable();
times[1]
}
_ => unreachable!(),
};
response_time.as_millis() as usize
};
// print when the new estimate deviates by more than 10% from the last
if f32::abs(
(ping_time_ms as f32 - old_ping_time_ms as f32) / old_ping_time_ms as f32,
) > 0.1
{
debug!("Ping time now estimated as: {} ms", ping_time_ms);
}
// store our new estimate for everyone to see
self.shared
.ping_time_ms
.store(ping_time.as_millis() as usize, Ordering::Relaxed);
.store(ping_time_ms, Ordering::Relaxed);
}
ReceivedData::Data(data) => {
self.output
.as_mut()
.unwrap()
.seek(SeekFrom::Start(data.offset as u64))
.unwrap();
self.output
.as_mut()
.unwrap()
.write_all(data.data.as_ref())
.unwrap();
let mut download_status = self.shared.download_status.lock().unwrap();
match self.output.as_mut() {
Some(output) => {
output.seek(SeekFrom::Start(data.offset as u64))?;
output.write_all(data.data.as_ref())?;
}
None => return Err(AudioFileError::Output.into()),
}
let received_range = Range::new(data.offset, data.data.len());
download_status.downloaded.add_range(&received_range);
self.shared.cond.notify_all();
let full = download_status.downloaded.contained_length_from_value(0)
>= self.shared.file_size;
let full = {
let mut download_status = self.shared.download_status.lock();
download_status.downloaded.add_range(&received_range);
self.shared.cond.notify_all();
drop(download_status);
download_status.downloaded.contained_length_from_value(0)
>= self.shared.file_size
};
if full {
self.finish();
return ControlFlow::Break;
self.finish()?;
return Ok(ControlFlow::Break);
}
}
}
ControlFlow::Continue
Ok(ControlFlow::Continue)
}
fn handle_stream_loader_command(&mut self, cmd: StreamLoaderCommand) -> ControlFlow {
fn handle_stream_loader_command(
&mut self,
cmd: StreamLoaderCommand,
) -> Result<ControlFlow, Error> {
match cmd {
StreamLoaderCommand::Fetch(request) => {
self.download_range(request.start, request.length);
self.download_range(request.start, request.length)?;
}
StreamLoaderCommand::RandomAccessMode() => {
*(self.shared.download_strategy.lock().unwrap()) = DownloadStrategy::RandomAccess();
*(self.shared.download_strategy.lock()) = DownloadStrategy::RandomAccess();
}
StreamLoaderCommand::StreamMode() => {
*(self.shared.download_strategy.lock().unwrap()) = DownloadStrategy::Streaming();
*(self.shared.download_strategy.lock()) = DownloadStrategy::Streaming();
}
StreamLoaderCommand::Close() => return ControlFlow::Break,
StreamLoaderCommand::Close() => return Ok(ControlFlow::Break),
}
ControlFlow::Continue
Ok(ControlFlow::Continue)
}
fn finish(&mut self) {
let mut output = self.output.take().unwrap();
let complete_tx = self.complete_tx.take().unwrap();
fn finish(&mut self) -> AudioFileResult {
let output = self.output.take();
output.seek(SeekFrom::Start(0)).unwrap();
let _ = complete_tx.send(output);
let complete_tx = self.complete_tx.take();
if let Some(mut output) = output {
output.seek(SeekFrom::Start(0))?;
if let Some(complete_tx) = complete_tx {
complete_tx
.send(output)
.map_err(|_| AudioFileError::Channel)?;
}
}
Ok(())
}
}
pub(super) async fn audio_file_fetch(
session: Session,
shared: Arc<AudioFileShared>,
initial_data_rx: ChannelData,
initial_request_sent_time: Instant,
initial_data_length: usize,
initial_request: StreamingRequest,
output: NamedTempFile,
mut stream_loader_command_rx: mpsc::UnboundedReceiver<StreamLoaderCommand>,
complete_tx: oneshot::Sender<NamedTempFile>,
) {
) -> AudioFileResult {
let (file_data_tx, mut file_data_rx) = mpsc::unbounded_channel();
{
let requested_range = Range::new(0, initial_data_length);
let mut download_status = shared.download_status.lock().unwrap();
let requested_range = Range::new(
initial_request.offset,
initial_request.offset + initial_request.length,
);
let mut download_status = shared.download_status.lock();
download_status.requested.add_range(&requested_range);
}
session.spawn(receive_data(
shared.clone(),
file_data_tx.clone(),
initial_data_rx,
0,
initial_data_length,
initial_request_sent_time,
initial_request,
));
let mut fetch = AudioFileFetch {
session,
session: session.clone(),
shared,
output: Some(output),
@ -392,13 +409,23 @@ pub(super) async fn audio_file_fetch(
loop {
tokio::select! {
cmd = stream_loader_command_rx.recv() => {
if cmd.map_or(true, |cmd| fetch.handle_stream_loader_command(cmd) == ControlFlow::Break) {
break;
match cmd {
Some(cmd) => {
if fetch.handle_stream_loader_command(cmd)? == ControlFlow::Break {
break;
}
}
None => break,
}
}
},
data = file_data_rx.recv() => {
if data.map_or(true, |data| fetch.handle_file_data(data) == ControlFlow::Break) {
break;
data = file_data_rx.recv() => {
match data {
Some(data) => {
if fetch.handle_file_data(data)? == ControlFlow::Break {
break;
}
}
None => break,
}
}
}
@ -410,7 +437,8 @@ pub(super) async fn audio_file_fetch(
let max_requests_to_send = MAX_PREFETCH_REQUESTS - number_of_open_requests;
let bytes_pending: usize = {
let download_status = fetch.shared.download_status.lock().unwrap();
let download_status = fetch.shared.download_status.lock();
download_status
.requested
.minus(&download_status.downloaded)
@ -425,7 +453,7 @@ pub(super) async fn audio_file_fetch(
let desired_pending_bytes = max(
(PREFETCH_THRESHOLD_FACTOR
* ping_time_seconds
* fetch.shared.stream_data_rate as f32) as usize,
* fetch.shared.bytes_per_second as f32) as usize,
(FAST_PREFETCH_THRESHOLD_FACTOR * ping_time_seconds * download_rate as f32)
as usize,
);
@ -434,9 +462,11 @@ pub(super) async fn audio_file_fetch(
fetch.pre_fetch_more_data(
desired_pending_bytes - bytes_pending,
max_requests_to_send,
);
)?;
}
}
}
}
Ok(())
}

View file

@ -1,5 +1,3 @@
#![allow(clippy::unused_io_amount, clippy::too_many_arguments)]
#[macro_use]
extern crate log;
@ -9,7 +7,7 @@ mod fetch;
mod range_set;
pub use decrypt::AudioDecrypt;
pub use fetch::{AudioFile, StreamLoaderController};
pub use fetch::{AudioFile, AudioFileError, StreamLoaderController};
pub use fetch::{
READ_AHEAD_BEFORE_PLAYBACK, READ_AHEAD_BEFORE_PLAYBACK_ROUNDTRIPS, READ_AHEAD_DURING_PLAYBACK,
READ_AHEAD_DURING_PLAYBACK_ROUNDTRIPS,

View file

@ -1,6 +1,8 @@
use std::cmp::{max, min};
use std::fmt;
use std::slice::Iter;
use std::{
cmp::{max, min},
fmt,
slice::Iter,
};
#[derive(Copy, Clone, Debug)]
pub struct Range {
@ -10,7 +12,7 @@ pub struct Range {
impl fmt::Display for Range {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
return write!(f, "[{}, {}]", self.start, self.start + self.length - 1);
write!(f, "[{}, {}]", self.start, self.start + self.length - 1)
}
}
@ -24,16 +26,16 @@ impl Range {
}
}
#[derive(Clone)]
#[derive(Debug, Clone)]
pub struct RangeSet {
ranges: Vec<Range>,
}
impl fmt::Display for RangeSet {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "(").unwrap();
write!(f, "(")?;
for range in self.ranges.iter() {
write!(f, "{}", range).unwrap();
write!(f, "{}", range)?;
}
write!(f, ")")
}

View file

@ -1,6 +1,6 @@
[package]
name = "librespot-connect"
version = "0.2.0"
version = "0.3.1"
authors = ["Paul Lietar <paul@lietar.net>"]
description = "The discovery and Spotify Connect logic for librespot"
license = "MIT"
@ -15,24 +15,25 @@ protobuf = "2.14.0"
rand = "0.8"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tokio = { version = "1.0", features = ["macros", "sync"] }
thiserror = "1.0"
tokio = { version = "1.0", features = ["macros", "parking_lot", "sync"] }
tokio-stream = "0.1.1"
[dependencies.librespot-core]
path = "../core"
version = "0.2.0"
version = "0.3.1"
[dependencies.librespot-playback]
path = "../playback"
version = "0.2.0"
version = "0.3.1"
[dependencies.librespot-protocol]
path = "../protocol"
version = "0.2.0"
version = "0.3.1"
[dependencies.librespot-discovery]
path = "../discovery"
version = "0.2.0"
version = "0.3.1"
[features]
with-dns-sd = ["librespot-discovery/with-dns-sd"]

View file

@ -1,7 +1,12 @@
// TODO : move to metadata
use crate::core::spotify_id::SpotifyId;
use crate::protocol::spirc::TrackRef;
use serde::Deserialize;
use serde::{
de::{Error, Unexpected},
Deserialize,
};
#[derive(Deserialize, Debug)]
pub struct StationContext {
@ -46,6 +51,7 @@ pub struct TrackContext {
// pub metadata: MetadataContext,
}
#[allow(dead_code)]
#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct ArtistContext {
@ -54,6 +60,7 @@ pub struct ArtistContext {
image_uri: String,
}
#[allow(dead_code)]
#[derive(Deserialize, Debug)]
pub struct MetadataContext {
album_title: String,
@ -70,17 +77,23 @@ where
D: serde::Deserializer<'d>,
{
let v: Vec<TrackContext> = serde::Deserialize::deserialize(de)?;
let track_vec = v
.iter()
v.iter()
.map(|v| {
let mut t = TrackRef::new();
// This has got to be the most round about way of doing this.
t.set_gid(SpotifyId::from_base62(&v.gid).unwrap().to_raw().to_vec());
t.set_gid(
SpotifyId::from_base62(&v.gid)
.map_err(|_| {
D::Error::invalid_value(
Unexpected::Str(&v.gid),
&"a Base-62 encoded Spotify ID",
)
})?
.to_raw()
.to_vec(),
);
t.set_uri(v.uri.to_owned());
t
Ok(t)
})
.collect::<Vec<TrackRef>>();
Ok(track_vec)
.collect::<Result<Vec<TrackRef>, D::Error>>()
}

View file

@ -1,10 +1,11 @@
use std::io;
use std::pin::Pin;
use std::task::{Context, Poll};
use std::{
io,
pin::Pin,
task::{Context, Poll},
};
use futures_util::Stream;
use librespot_core::authentication::Credentials;
use librespot_core::config::ConnectConfig;
use librespot_core::{authentication::Credentials, config::ConnectConfig};
pub struct DiscoveryStream(librespot_discovery::Discovery);

File diff suppressed because it is too large Load diff

View file

@ -9,8 +9,10 @@
#
# If only one architecture is desired, cargo can be invoked directly with the appropriate options :
# $ docker run -v /tmp/librespot-build:/build librespot-cross cargo build --release --no-default-features --features "alsa-backend"
# $ docker run -v /tmp/librespot-build:/build librespot-cross cargo build --release --target arm-unknown-linux-gnueabihf --no-default-features --features "alsa-backend"
# $ docker run -v /tmp/librespot-build:/build librespot-cross cargo build --release --target arm-unknown-linux-gnueabi --no-default-features --features "alsa-backend"
# $ docker run -v /tmp/librespot-build:/build librespot-cross cargo build --release --target arm-unknown-linux-gnueabihf --no-default-features --features alsa-backend
# $ docker run -v /tmp/librespot-build:/build librespot-cross cargo build --release --target arm-unknown-linux-gnueabi --no-default-features --features alsa-backend
# $ docker run -v /tmp/librespot-build:/build librespot-cross cargo build --release --target aarch64-unknown-linux-gnu --no-default-features --features alsa-backend
# $ docker run -v /tmp/librespot-build:/build librespot-cross contrib/docker-build-pi-armv6hf.sh
FROM debian:stretch

View file

@ -2,12 +2,12 @@
Description=Librespot (an open source Spotify client)
Documentation=https://github.com/librespot-org/librespot
Documentation=https://github.com/librespot-org/librespot/wiki/Options
Requires=network-online.target
After=network-online.target
Wants=network.target sound.target
After=network.target sound.target
[Service]
User=nobody
Group=audio
DynamicUser=yes
SupplementaryGroups=audio
Restart=always
RestartSec=10
ExecStart=/usr/bin/librespot --name "%p@%H"

View file

@ -2,6 +2,8 @@
Description=Librespot (an open source Spotify client)
Documentation=https://github.com/librespot-org/librespot
Documentation=https://github.com/librespot-org/librespot/wiki/Options
Wants=network.target sound.target
After=network.target sound.target
[Service]
Restart=always

View file

@ -1,6 +1,6 @@
[package]
name = "librespot-core"
version = "0.2.0"
version = "0.3.1"
authors = ["Paul Lietar <paul@lietar.net>"]
build = "build.rs"
description = "The core functionality provided by librespot"
@ -10,21 +10,24 @@ edition = "2018"
[dependencies.librespot-protocol]
path = "../protocol"
version = "0.2.0"
version = "0.3.1"
[dependencies]
aes = "0.6"
base64 = "0.13"
byteorder = "1.4"
bytes = "1.0"
bytes = "1"
chrono = "0.4"
dns-sd = { version = "0.1.3", optional = true }
form_urlencoded = "1.0"
futures-core = { version = "0.3", default-features = false }
futures-util = { version = "0.3", default-features = false, features = ["alloc", "bilock", "unstable", "sink"] }
futures-util = { version = "0.3", default-features = false, features = ["alloc", "bilock", "sink", "unstable"] }
hmac = "0.11"
httparse = "1.3"
http = "0.2"
hyper = { version = "0.14", features = ["client", "tcp", "http1"] }
hyper-proxy = { version = "0.9.1", default-features = false }
hyper = { version = "0.14", features = ["client", "http1", "http2", "tcp"] }
hyper-proxy = { version = "0.9.1", default-features = false, features = ["rustls"] }
hyper-rustls = { version = "0.22", default-features = false, features = ["native-tokio"] }
log = "0.4"
num = "0.4"
num-bigint = { version = "0.4", features = ["rand"] }
@ -32,16 +35,20 @@ num-derive = "0.3"
num-integer = "0.1"
num-traits = "0.2"
once_cell = "1.5.2"
parking_lot = { version = "0.11", features = ["deadlock_detection"] }
pbkdf2 = { version = "0.8", default-features = false, features = ["hmac"] }
priority-queue = "1.1"
protobuf = "2.14.0"
quick-xml = { version = "0.22", features = ["serialize"] }
rand = "0.8"
rustls = "0.19"
rustls-native-certs = "0.5"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
sha-1 = "0.9"
shannon = "0.2.0"
thiserror = "1.0.7"
tokio = { version = "1.5", features = ["io-util", "macros", "net", "rt", "time", "sync"] }
thiserror = "1.0"
tokio = { version = "1.5", features = ["io-util", "macros", "net", "parking_lot", "rt", "sync", "time"] }
tokio-stream = "0.1.1"
tokio-tungstenite = { version = "0.14", default-features = false, features = ["rustls-tls"] }
tokio-util = { version = "0.6", features = ["codec"] }
@ -54,4 +61,7 @@ vergen = "3.0.4"
[dev-dependencies]
env_logger = "0.8"
tokio = {version = "1.0", features = ["macros"] }
tokio = { version = "1.0", features = ["macros", "parking_lot"] }
[features]
with-dns-sd = ["dns-sd"]

View file

@ -1,19 +1,21 @@
use hyper::{Body, Request};
use serde::Deserialize;
use std::error::Error;
use std::sync::atomic::{AtomicUsize, Ordering};
use hyper::{Body, Method, Request};
use serde::Deserialize;
use crate::Error;
pub type SocketAddress = (String, u16);
#[derive(Default)]
struct AccessPoints {
pub struct AccessPoints {
accesspoint: Vec<SocketAddress>,
dealer: Vec<SocketAddress>,
spclient: Vec<SocketAddress>,
}
#[derive(Deserialize)]
struct ApResolveData {
pub struct ApResolveData {
accesspoint: Vec<String>,
dealer: Vec<String>,
spclient: Vec<String>,
@ -42,7 +44,7 @@ component! {
impl ApResolver {
// return a port if a proxy URL and/or a proxy port was specified. This is useful even when
// there is no proxy, but firewalls only allow certain ports (e.g. 443 and not 4070).
fn port_config(&self) -> Option<u16> {
pub fn port_config(&self) -> Option<u16> {
if self.session().config().proxy.is_some() || self.session().config().ap_port.is_some() {
Some(self.session().config().ap_port.unwrap_or(443))
} else {
@ -54,9 +56,7 @@ impl ApResolver {
data.into_iter()
.filter_map(|ap| {
let mut split = ap.rsplitn(2, ':');
let port = split
.next()
.expect("rsplitn should not return empty iterator");
let port = split.next()?;
let host = split.next()?.to_owned();
let port: u16 = port.parse().ok()?;
if let Some(p) = self.port_config() {
@ -69,12 +69,11 @@ impl ApResolver {
.collect()
}
async fn try_apresolve(&self) -> Result<ApResolveData, Box<dyn Error>> {
pub async fn try_apresolve(&self) -> Result<ApResolveData, Error> {
let req = Request::builder()
.method("GET")
.method(Method::GET)
.uri("http://apresolve.spotify.com/?type=accesspoint&type=dealer&type=spclient")
.body(Body::empty())
.unwrap();
.body(Body::empty())?;
let body = self.session().http_client().request_body(req).await?;
let data: ApResolveData = serde_json::from_slice(body.as_ref())?;

View file

@ -1,53 +1,85 @@
use std::{collections::HashMap, io::Write};
use byteorder::{BigEndian, ByteOrder, WriteBytesExt};
use bytes::Bytes;
use std::collections::HashMap;
use std::io::Write;
use thiserror::Error;
use tokio::sync::oneshot;
use crate::packet::PacketType;
use crate::spotify_id::{FileId, SpotifyId};
use crate::util::SeqGenerator;
use crate::{packet::PacketType, util::SeqGenerator, Error, FileId, SpotifyId};
#[derive(Debug, Hash, PartialEq, Eq, Copy, Clone)]
pub struct AudioKey(pub [u8; 16]);
#[derive(Debug, Hash, PartialEq, Eq, Copy, Clone)]
pub struct AudioKeyError;
#[derive(Debug, Error)]
pub enum AudioKeyError {
#[error("audio key error")]
AesKey,
#[error("other end of channel disconnected")]
Channel,
#[error("unexpected packet type {0}")]
Packet(u8),
#[error("sequence {0} not pending")]
Sequence(u32),
}
impl From<AudioKeyError> for Error {
fn from(err: AudioKeyError) -> Self {
match err {
AudioKeyError::AesKey => Error::unavailable(err),
AudioKeyError::Channel => Error::aborted(err),
AudioKeyError::Sequence(_) => Error::aborted(err),
AudioKeyError::Packet(_) => Error::unimplemented(err),
}
}
}
component! {
AudioKeyManager : AudioKeyManagerInner {
sequence: SeqGenerator<u32> = SeqGenerator::new(0),
pending: HashMap<u32, oneshot::Sender<Result<AudioKey, AudioKeyError>>> = HashMap::new(),
pending: HashMap<u32, oneshot::Sender<Result<AudioKey, Error>>> = HashMap::new(),
}
}
impl AudioKeyManager {
pub(crate) fn dispatch(&self, cmd: PacketType, mut data: Bytes) {
pub(crate) fn dispatch(&self, cmd: PacketType, mut data: Bytes) -> Result<(), Error> {
let seq = BigEndian::read_u32(data.split_to(4).as_ref());
let sender = self.lock(|inner| inner.pending.remove(&seq));
let sender = self
.lock(|inner| inner.pending.remove(&seq))
.ok_or(AudioKeyError::Sequence(seq))?;
if let Some(sender) = sender {
match cmd {
PacketType::AesKey => {
let mut key = [0u8; 16];
key.copy_from_slice(data.as_ref());
let _ = sender.send(Ok(AudioKey(key)));
}
PacketType::AesKeyError => {
warn!(
"error audio key {:x} {:x}",
data.as_ref()[0],
data.as_ref()[1]
);
let _ = sender.send(Err(AudioKeyError));
}
_ => (),
match cmd {
PacketType::AesKey => {
let mut key = [0u8; 16];
key.copy_from_slice(data.as_ref());
sender
.send(Ok(AudioKey(key)))
.map_err(|_| AudioKeyError::Channel)?
}
PacketType::AesKeyError => {
error!(
"error audio key {:x} {:x}",
data.as_ref()[0],
data.as_ref()[1]
);
sender
.send(Err(AudioKeyError::AesKey.into()))
.map_err(|_| AudioKeyError::Channel)?
}
_ => {
trace!(
"Did not expect {:?} AES key packet with data {:#?}",
cmd,
data
);
return Err(AudioKeyError::Packet(cmd as u8).into());
}
}
Ok(())
}
pub async fn request(&self, track: SpotifyId, file: FileId) -> Result<AudioKey, AudioKeyError> {
pub async fn request(&self, track: SpotifyId, file: FileId) -> Result<AudioKey, Error> {
let (tx, rx) = oneshot::channel();
let seq = self.lock(move |inner| {
@ -56,16 +88,16 @@ impl AudioKeyManager {
seq
});
self.send_key_request(seq, track, file);
rx.await.map_err(|_| AudioKeyError)?
self.send_key_request(seq, track, file)?;
rx.await?
}
fn send_key_request(&self, seq: u32, track: SpotifyId, file: FileId) {
fn send_key_request(&self, seq: u32, track: SpotifyId, file: FileId) -> Result<(), Error> {
let mut data: Vec<u8> = Vec::new();
data.write_all(&file.0).unwrap();
data.write_all(&track.to_raw()).unwrap();
data.write_u32::<BigEndian>(seq).unwrap();
data.write_u16::<BigEndian>(0x0000).unwrap();
data.write_all(&file.0)?;
data.write_all(&track.to_raw())?;
data.write_u32::<BigEndian>(seq)?;
data.write_u16::<BigEndian>(0x0000)?;
self.session().send_packet(PacketType::RequestKey, data)
}

View file

@ -7,8 +7,21 @@ use pbkdf2::pbkdf2;
use protobuf::ProtobufEnum;
use serde::{Deserialize, Serialize};
use sha1::{Digest, Sha1};
use thiserror::Error;
use crate::protocol::authentication::AuthenticationType;
use crate::{protocol::authentication::AuthenticationType, Error};
#[derive(Debug, Error)]
pub enum AuthenticationError {
#[error("unknown authentication type {0}")]
AuthType(u32),
}
impl From<AuthenticationError> for Error {
fn from(err: AuthenticationError) -> Self {
Error::invalid_argument(err)
}
}
/// The credentials are used to log into the Spotify API.
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -42,7 +55,11 @@ impl Credentials {
}
}
pub fn with_blob(username: String, encrypted_blob: &str, device_id: &str) -> Credentials {
pub fn with_blob(
username: impl Into<String>,
encrypted_blob: impl AsRef<[u8]>,
device_id: impl AsRef<[u8]>,
) -> Result<Credentials, Error> {
fn read_u8<R: Read>(stream: &mut R) -> io::Result<u8> {
let mut data = [0u8];
stream.read_exact(&mut data)?;
@ -67,7 +84,9 @@ impl Credentials {
Ok(data)
}
let secret = Sha1::digest(device_id.as_bytes());
let username = username.into();
let secret = Sha1::digest(device_id.as_ref());
let key = {
let mut key = [0u8; 24];
@ -85,12 +104,12 @@ impl Credentials {
use aes::cipher::generic_array::GenericArray;
use aes::cipher::{BlockCipher, NewBlockCipher};
let mut data = base64::decode(encrypted_blob).unwrap();
let mut data = base64::decode(encrypted_blob)?;
let cipher = Aes192::new(GenericArray::from_slice(&key));
let block_size = <Aes192 as BlockCipher>::BlockSize::to_usize();
assert_eq!(data.len() % block_size, 0);
// replace to chunks_exact_mut with MSRV bump to 1.31
for chunk in data.chunks_mut(block_size) {
for chunk in data.chunks_exact_mut(block_size) {
cipher.decrypt_block(GenericArray::from_mut_slice(chunk));
}
@ -102,20 +121,21 @@ impl Credentials {
data
};
let mut cursor = io::Cursor::new(&blob);
read_u8(&mut cursor).unwrap();
read_bytes(&mut cursor).unwrap();
read_u8(&mut cursor).unwrap();
let auth_type = read_int(&mut cursor).unwrap();
let auth_type = AuthenticationType::from_i32(auth_type as i32).unwrap();
read_u8(&mut cursor).unwrap();
let auth_data = read_bytes(&mut cursor).unwrap();
let mut cursor = io::Cursor::new(blob.as_slice());
read_u8(&mut cursor)?;
read_bytes(&mut cursor)?;
read_u8(&mut cursor)?;
let auth_type = read_int(&mut cursor)?;
let auth_type = AuthenticationType::from_i32(auth_type as i32)
.ok_or(AuthenticationError::AuthType(auth_type))?;
read_u8(&mut cursor)?;
let auth_data = read_bytes(&mut cursor)?;
Credentials {
Ok(Credentials {
username,
auth_type,
auth_data,
}
})
}
}

View file

@ -1,15 +1,30 @@
use std::cmp::Reverse;
use std::collections::HashMap;
use std::fs::{self, File};
use std::io::{self, Error, ErrorKind, Read, Write};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::SystemTime;
use std::{
cmp::Reverse,
collections::HashMap,
fs::{self, File},
io::{self, Read, Write},
path::{Path, PathBuf},
sync::Arc,
time::SystemTime,
};
use parking_lot::Mutex;
use priority_queue::PriorityQueue;
use thiserror::Error;
use crate::authentication::Credentials;
use crate::spotify_id::FileId;
use crate::{authentication::Credentials, error::ErrorKind, Error, FileId};
#[derive(Debug, Error)]
pub enum CacheError {
#[error("audio cache location is not configured")]
Path,
}
impl From<CacheError> for Error {
fn from(err: CacheError) -> Self {
Error::failed_precondition(err)
}
}
/// Some kind of data structure that holds some paths, the size of these files and a timestamp.
/// It keeps track of the file sizes and is able to pop the path with the oldest timestamp if
@ -57,16 +72,17 @@ impl SizeLimiter {
/// to delete the file in the file system.
fn pop(&mut self) -> Option<PathBuf> {
if self.exceeds_limit() {
let (next, _) = self
.queue
.pop()
.expect("in_use was > 0, so the queue should have contained an item.");
let size = self
.sizes
.remove(&next)
.expect("`queue` and `sizes` should have the same keys.");
self.in_use -= size;
Some(next)
if let Some((next, _)) = self.queue.pop() {
if let Some(size) = self.sizes.remove(&next) {
self.in_use -= size;
} else {
error!("`queue` and `sizes` should have the same keys.");
}
Some(next)
} else {
error!("in_use was > 0, so the queue should have contained an item.");
None
}
} else {
None
}
@ -85,11 +101,11 @@ impl SizeLimiter {
return false;
}
let size = self
.sizes
.remove(file)
.expect("`queue` and `sizes` should have the same keys.");
self.in_use -= size;
if let Some(size) = self.sizes.remove(file) {
self.in_use -= size;
} else {
error!("`queue` and `sizes` should have the same keys.");
}
true
}
@ -173,23 +189,21 @@ impl FsSizeLimiter {
}
fn add(&self, file: &Path, size: u64) {
self.limiter
.lock()
.unwrap()
.add(file, size, SystemTime::now());
self.limiter.lock().add(file, size, SystemTime::now())
}
fn touch(&self, file: &Path) -> bool {
self.limiter.lock().unwrap().update(file, SystemTime::now())
self.limiter.lock().update(file, SystemTime::now())
}
fn remove(&self, file: &Path) {
self.limiter.lock().unwrap().remove(file);
fn remove(&self, file: &Path) -> bool {
self.limiter.lock().remove(file)
}
fn prune_internal<F: FnMut() -> Option<PathBuf>>(mut pop: F) {
fn prune_internal<F: FnMut() -> Option<PathBuf>>(mut pop: F) -> Result<(), Error> {
let mut first = true;
let mut count = 0;
let mut last_error = None;
while let Some(file) = pop() {
if first {
@ -197,8 +211,10 @@ impl FsSizeLimiter {
first = false;
}
if let Err(e) = fs::remove_file(&file) {
let res = fs::remove_file(&file);
if let Err(e) = res {
warn!("Could not remove file {:?} from cache dir: {}", file, e);
last_error = Some(e);
} else {
count += 1;
}
@ -207,21 +223,27 @@ impl FsSizeLimiter {
if count > 0 {
info!("Removed {} cache files.", count);
}
if let Some(err) = last_error {
Err(err.into())
} else {
Ok(())
}
}
fn prune(&self) {
Self::prune_internal(|| self.limiter.lock().unwrap().pop())
fn prune(&self) -> Result<(), Error> {
Self::prune_internal(|| self.limiter.lock().pop())
}
fn new(path: &Path, limit: u64) -> Self {
fn new(path: &Path, limit: u64) -> Result<Self, Error> {
let mut limiter = SizeLimiter::new(limit);
Self::init_dir(&mut limiter, path);
Self::prune_internal(|| limiter.pop());
Self::prune_internal(|| limiter.pop())?;
Self {
Ok(Self {
limiter: Mutex::new(limiter),
}
})
}
}
@ -234,33 +256,39 @@ pub struct Cache {
size_limiter: Option<Arc<FsSizeLimiter>>,
}
pub struct RemoveFileError(());
impl Cache {
pub fn new<P: AsRef<Path>>(
system_location: Option<P>,
audio_location: Option<P>,
credentials_path: Option<P>,
volume_path: Option<P>,
audio_path: Option<P>,
size_limit: Option<u64>,
) -> io::Result<Self> {
if let Some(location) = &system_location {
) -> Result<Self, Error> {
let mut size_limiter = None;
if let Some(location) = &credentials_path {
fs::create_dir_all(location)?;
}
let mut size_limiter = None;
let credentials_location = credentials_path
.as_ref()
.map(|p| p.as_ref().join("credentials.json"));
if let Some(location) = &audio_location {
if let Some(location) = &volume_path {
fs::create_dir_all(location)?;
}
let volume_location = volume_path.as_ref().map(|p| p.as_ref().join("volume"));
if let Some(location) = &audio_path {
fs::create_dir_all(location)?;
if let Some(limit) = size_limit {
let limiter = FsSizeLimiter::new(location.as_ref(), limit);
let limiter = FsSizeLimiter::new(location.as_ref(), limit)?;
size_limiter = Some(Arc::new(limiter));
}
}
let audio_location = audio_location.map(|p| p.as_ref().to_owned());
let volume_location = system_location.as_ref().map(|p| p.as_ref().join("volume"));
let credentials_location = system_location
.as_ref()
.map(|p| p.as_ref().join("credentials.json"));
let audio_location = audio_path.map(|p| p.as_ref().to_owned());
let cache = Cache {
credentials_location,
@ -276,11 +304,11 @@ impl Cache {
let location = self.credentials_location.as_ref()?;
// This closure is just convencience to enable the question mark operator
let read = || {
let read = || -> Result<Credentials, Error> {
let mut file = File::open(location)?;
let mut contents = String::new();
file.read_to_string(&mut contents)?;
serde_json::from_str(&contents).map_err(|e| Error::new(ErrorKind::InvalidData, e))
Ok(serde_json::from_str(&contents)?)
};
match read() {
@ -288,7 +316,7 @@ impl Cache {
Err(e) => {
// If the file did not exist, the file was probably not written
// before. Otherwise, log the error.
if e.kind() != ErrorKind::NotFound {
if e.kind != ErrorKind::NotFound {
warn!("Error reading credentials from cache: {}", e);
}
None
@ -312,19 +340,17 @@ impl Cache {
pub fn volume(&self) -> Option<u16> {
let location = self.volume_location.as_ref()?;
let read = || {
let read = || -> Result<u16, Error> {
let mut file = File::open(location)?;
let mut contents = String::new();
file.read_to_string(&mut contents)?;
contents
.parse()
.map_err(|e| Error::new(ErrorKind::InvalidData, e))
Ok(contents.parse()?)
};
match read() {
Ok(v) => Some(v),
Err(e) => {
if e.kind() != ErrorKind::NotFound {
if e.kind != ErrorKind::NotFound {
warn!("Error reading volume from cache: {}", e);
}
None
@ -355,12 +381,14 @@ impl Cache {
match File::open(&path) {
Ok(file) => {
if let Some(limiter) = self.size_limiter.as_deref() {
limiter.touch(&path);
if !limiter.touch(&path) {
error!("limiter could not touch {:?}", path);
}
}
Some(file)
}
Err(e) => {
if e.kind() != ErrorKind::NotFound {
if e.kind() != io::ErrorKind::NotFound {
warn!("Error reading file from cache: {}", e)
}
None
@ -368,38 +396,33 @@ impl Cache {
}
}
pub fn save_file<F: Read>(&self, file: FileId, contents: &mut F) {
let path = if let Some(path) = self.file_path(file) {
path
} else {
return;
};
let parent = path.parent().unwrap();
let result = fs::create_dir_all(parent)
.and_then(|_| File::create(&path))
.and_then(|mut file| io::copy(contents, &mut file));
if let Ok(size) = result {
if let Some(limiter) = self.size_limiter.as_deref() {
limiter.add(&path, size);
limiter.prune();
pub fn save_file<F: Read>(&self, file: FileId, contents: &mut F) -> Result<(), Error> {
if let Some(path) = self.file_path(file) {
if let Some(parent) = path.parent() {
if let Ok(size) = fs::create_dir_all(parent)
.and_then(|_| File::create(&path))
.and_then(|mut file| io::copy(contents, &mut file))
{
if let Some(limiter) = self.size_limiter.as_deref() {
limiter.add(&path, size);
limiter.prune()?;
}
return Ok(());
}
}
}
Err(CacheError::Path.into())
}
pub fn remove_file(&self, file: FileId) -> Result<(), RemoveFileError> {
let path = self.file_path(file).ok_or(RemoveFileError(()))?;
pub fn remove_file(&self, file: FileId) -> Result<(), Error> {
let path = self.file_path(file).ok_or(CacheError::Path)?;
if let Err(err) = fs::remove_file(&path) {
warn!("Unable to remove file from cache: {}", err);
Err(RemoveFileError(()))
} else {
if let Some(limiter) = self.size_limiter.as_deref() {
limiter.remove(&path);
}
Ok(())
fs::remove_file(&path)?;
if let Some(limiter) = self.size_limiter.as_deref() {
limiter.remove(&path);
}
Ok(())
}
}

166
core/src/cdn_url.rs Normal file
View file

@ -0,0 +1,166 @@
use std::{
convert::{TryFrom, TryInto},
ops::{Deref, DerefMut},
};
use chrono::Local;
use protobuf::Message;
use thiserror::Error;
use url::Url;
use super::{date::Date, Error, FileId, Session};
use librespot_protocol as protocol;
use protocol::storage_resolve::StorageResolveResponse as CdnUrlMessage;
use protocol::storage_resolve::StorageResolveResponse_Result;
#[derive(Debug, Clone)]
pub struct MaybeExpiringUrl(pub String, pub Option<Date>);
#[derive(Debug, Clone)]
pub struct MaybeExpiringUrls(pub Vec<MaybeExpiringUrl>);
impl Deref for MaybeExpiringUrls {
type Target = Vec<MaybeExpiringUrl>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl DerefMut for MaybeExpiringUrls {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
#[derive(Debug, Error)]
pub enum CdnUrlError {
#[error("all URLs expired")]
Expired,
#[error("resolved storage is not for CDN")]
Storage,
#[error("no URLs resolved")]
Unresolved,
}
impl From<CdnUrlError> for Error {
fn from(err: CdnUrlError) -> Self {
match err {
CdnUrlError::Expired => Error::deadline_exceeded(err),
CdnUrlError::Storage | CdnUrlError::Unresolved => Error::unavailable(err),
}
}
}
#[derive(Debug, Clone)]
pub struct CdnUrl {
pub file_id: FileId,
urls: MaybeExpiringUrls,
}
impl CdnUrl {
pub fn new(file_id: FileId) -> Self {
Self {
file_id,
urls: MaybeExpiringUrls(Vec::new()),
}
}
pub async fn resolve_audio(&self, session: &Session) -> Result<Self, Error> {
let file_id = self.file_id;
let response = session.spclient().get_audio_storage(file_id).await?;
let msg = CdnUrlMessage::parse_from_bytes(&response)?;
let urls = MaybeExpiringUrls::try_from(msg)?;
let cdn_url = Self { file_id, urls };
trace!("Resolved CDN storage: {:#?}", cdn_url);
Ok(cdn_url)
}
pub fn try_get_url(&self) -> Result<&str, Error> {
if self.urls.is_empty() {
return Err(CdnUrlError::Unresolved.into());
}
let now = Local::now();
let url = self.urls.iter().find(|url| match url.1 {
Some(expiry) => now < expiry.as_utc(),
None => true,
});
if let Some(url) = url {
Ok(&url.0)
} else {
Err(CdnUrlError::Expired.into())
}
}
}
impl TryFrom<CdnUrlMessage> for MaybeExpiringUrls {
type Error = crate::Error;
fn try_from(msg: CdnUrlMessage) -> Result<Self, Self::Error> {
if !matches!(msg.get_result(), StorageResolveResponse_Result::CDN) {
return Err(CdnUrlError::Storage.into());
}
let is_expiring = !msg.get_fileid().is_empty();
let result = msg
.get_cdnurl()
.iter()
.map(|cdn_url| {
let url = Url::parse(cdn_url)?;
if is_expiring {
let expiry_str = if let Some(token) = url
.query_pairs()
.into_iter()
.find(|(key, _value)| key == "__token__")
{
if let Some(mut start) = token.1.find("exp=") {
start += 4;
if token.1.len() >= start {
let slice = &token.1[start..];
if let Some(end) = slice.find('~') {
// this is the only valid invariant for akamaized.net
String::from(&slice[..end])
} else {
String::from(slice)
}
} else {
String::new()
}
} else {
String::new()
}
} else if let Some(query) = url.query() {
let mut items = query.split('_');
if let Some(first) = items.next() {
// this is the only valid invariant for scdn.co
String::from(first)
} else {
String::new()
}
} else {
String::new()
};
let mut expiry: i64 = expiry_str.parse()?;
expiry -= 5 * 60; // seconds
Ok(MaybeExpiringUrl(
cdn_url.to_owned(),
Some(expiry.try_into()?),
))
} else {
Ok(MaybeExpiringUrl(cdn_url.to_owned(), None))
}
})
.collect::<Result<Vec<MaybeExpiringUrl>, Error>>()?;
Ok(Self(result))
}
}

View file

@ -1,18 +1,20 @@
use std::collections::HashMap;
use std::pin::Pin;
use std::task::{Context, Poll};
use std::time::Instant;
use std::{
collections::HashMap,
fmt,
pin::Pin,
task::{Context, Poll},
time::Instant,
};
use byteorder::{BigEndian, ByteOrder};
use bytes::Bytes;
use futures_core::Stream;
use futures_util::lock::BiLock;
use futures_util::{ready, StreamExt};
use futures_util::{lock::BiLock, ready, StreamExt};
use num_traits::FromPrimitive;
use thiserror::Error;
use tokio::sync::mpsc;
use crate::packet::PacketType;
use crate::util::SeqGenerator;
use crate::{packet::PacketType, util::SeqGenerator, Error};
component! {
ChannelManager : ChannelManagerInner {
@ -27,9 +29,21 @@ component! {
const ONE_SECOND_IN_MS: usize = 1000;
#[derive(Debug, Hash, PartialEq, Eq, Copy, Clone)]
#[derive(Debug, Error, Hash, PartialEq, Eq, Copy, Clone)]
pub struct ChannelError;
impl From<ChannelError> for Error {
fn from(err: ChannelError) -> Self {
Error::aborted(err)
}
}
impl fmt::Display for ChannelError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "channel error")
}
}
pub struct Channel {
receiver: mpsc::UnboundedReceiver<(u8, Bytes)>,
state: ChannelState,
@ -70,7 +84,7 @@ impl ChannelManager {
(seq, channel)
}
pub(crate) fn dispatch(&self, cmd: PacketType, mut data: Bytes) {
pub(crate) fn dispatch(&self, cmd: PacketType, mut data: Bytes) -> Result<(), Error> {
use std::collections::hash_map::Entry;
let id: u16 = BigEndian::read_u16(data.split_to(2).as_ref());
@ -94,9 +108,14 @@ impl ChannelManager {
inner.download_measurement_bytes += data.len();
if let Entry::Occupied(entry) = inner.channels.entry(id) {
let _ = entry.get().send((cmd as u8, data));
entry
.get()
.send((cmd as u8, data))
.map_err(|_| ChannelError)?;
}
});
Ok(())
})
}
pub fn get_download_rate_estimate(&self) -> usize {
@ -142,7 +161,11 @@ impl Stream for Channel {
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
loop {
match self.state.clone() {
ChannelState::Closed => panic!("Polling already terminated channel"),
ChannelState::Closed => {
error!("Polling already terminated channel");
return Poll::Ready(None);
}
ChannelState::Header(mut data) => {
if data.is_empty() {
data = ready!(self.recv_packet(cx))?;

View file

@ -1,20 +1,20 @@
macro_rules! component {
($name:ident : $inner:ident { $($key:ident : $ty:ty = $value:expr,)* }) => {
#[derive(Clone)]
pub struct $name(::std::sync::Arc<($crate::session::SessionWeak, ::std::sync::Mutex<$inner>)>);
pub struct $name(::std::sync::Arc<($crate::session::SessionWeak, ::parking_lot::Mutex<$inner>)>);
impl $name {
#[allow(dead_code)]
pub(crate) fn new(session: $crate::session::SessionWeak) -> $name {
debug!(target:"librespot::component", "new {}", stringify!($name));
$name(::std::sync::Arc::new((session, ::std::sync::Mutex::new($inner {
$name(::std::sync::Arc::new((session, ::parking_lot::Mutex::new($inner {
$($key : $value,)*
}))))
}
#[allow(dead_code)]
fn lock<F: FnOnce(&mut $inner) -> R, R>(&self, f: F) -> R {
let mut inner = (self.0).1.lock().expect("Mutex poisoned");
let mut inner = (self.0).1.lock();
f(&mut inner)
}

View file

@ -1,23 +1,23 @@
use std::fmt;
use std::str::FromStr;
use std::{fmt, path::PathBuf, str::FromStr};
use url::Url;
#[derive(Clone, Debug)]
pub struct SessionConfig {
pub user_agent: String,
pub device_id: String,
pub proxy: Option<Url>,
pub ap_port: Option<u16>,
pub tmp_dir: PathBuf,
}
impl Default for SessionConfig {
fn default() -> SessionConfig {
let device_id = uuid::Uuid::new_v4().to_hyphenated().to_string();
SessionConfig {
user_agent: crate::version::VERSION_STRING.to_string(),
device_id,
proxy: None,
ap_port: None,
tmp_dir: std::env::temp_dir(),
}
}
}
@ -125,3 +125,15 @@ pub struct ConnectConfig {
pub has_volume_ctrl: bool,
pub autoplay: bool,
}
impl Default for ConnectConfig {
fn default() -> ConnectConfig {
ConnectConfig {
name: "Librespot".to_string(),
device_type: DeviceType::default(),
initial_volume: Some(50),
has_volume_ctrl: true,
autoplay: false,
}
}
}

View file

@ -1,12 +1,20 @@
use std::io;
use byteorder::{BigEndian, ByteOrder};
use bytes::{BufMut, Bytes, BytesMut};
use shannon::Shannon;
use std::io;
use thiserror::Error;
use tokio_util::codec::{Decoder, Encoder};
const HEADER_SIZE: usize = 3;
const MAC_SIZE: usize = 4;
#[derive(Debug, Error)]
pub enum ApCodecError {
#[error("payload was malformed")]
Payload,
}
#[derive(Debug)]
enum DecodeState {
Header,
@ -88,7 +96,9 @@ impl Decoder for ApCodec {
let mut payload = buf.split_to(size + MAC_SIZE);
self.decode_cipher
.decrypt(&mut payload.get_mut(..size).unwrap());
.decrypt(payload.get_mut(..size).ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidData, ApCodecError::Payload)
})?);
let mac = payload.split_off(size);
self.decode_cipher.check_mac(mac.as_ref())?;

View file

@ -1,16 +1,28 @@
use std::{env::consts::ARCH, io};
use byteorder::{BigEndian, ByteOrder, WriteBytesExt};
use hmac::{Hmac, Mac, NewMac};
use protobuf::{self, Message};
use rand::{thread_rng, RngCore};
use sha1::Sha1;
use std::io;
use thiserror::Error;
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
use tokio_util::codec::{Decoder, Framed};
use super::codec::ApCodec;
use crate::diffie_hellman::DhLocalKeys;
use crate::{diffie_hellman::DhLocalKeys, version};
use crate::protocol;
use crate::protocol::keyexchange::{APResponseMessage, ClientHello, ClientResponsePlaintext};
use crate::protocol::keyexchange::{
APResponseMessage, ClientHello, ClientResponsePlaintext, Platform, ProductFlags,
};
#[derive(Debug, Error)]
pub enum HandshakeError {
#[error("invalid key length")]
InvalidLength,
}
pub async fn handshake<T: AsyncRead + AsyncWrite + Unpin>(
mut connection: T,
@ -27,7 +39,7 @@ pub async fn handshake<T: AsyncRead + AsyncWrite + Unpin>(
.to_owned();
let shared_secret = local_keys.shared_secret(&remote_key);
let (challenge, send_key, recv_key) = compute_keys(&shared_secret, &accumulator);
let (challenge, send_key, recv_key) = compute_keys(&shared_secret, &accumulator)?;
let codec = ApCodec::new(&send_key, &recv_key);
client_response(&mut connection, challenge).await?;
@ -42,14 +54,56 @@ where
let mut client_nonce = vec![0; 0x10];
thread_rng().fill_bytes(&mut client_nonce);
let platform = match std::env::consts::OS {
"android" => Platform::PLATFORM_ANDROID_ARM,
"freebsd" | "netbsd" | "openbsd" => match ARCH {
"x86_64" => Platform::PLATFORM_FREEBSD_X86_64,
_ => Platform::PLATFORM_FREEBSD_X86,
},
"ios" => match ARCH {
"arm64" => Platform::PLATFORM_IPHONE_ARM64,
_ => Platform::PLATFORM_IPHONE_ARM,
},
"linux" => match ARCH {
"arm" | "arm64" => Platform::PLATFORM_LINUX_ARM,
"blackfin" => Platform::PLATFORM_LINUX_BLACKFIN,
"mips" => Platform::PLATFORM_LINUX_MIPS,
"sh" => Platform::PLATFORM_LINUX_SH,
"x86_64" => Platform::PLATFORM_LINUX_X86_64,
_ => Platform::PLATFORM_LINUX_X86,
},
"macos" => match ARCH {
"ppc" | "ppc64" => Platform::PLATFORM_OSX_PPC,
"x86_64" => Platform::PLATFORM_OSX_X86_64,
_ => Platform::PLATFORM_OSX_X86,
},
"windows" => match ARCH {
"arm" => Platform::PLATFORM_WINDOWS_CE_ARM,
"x86_64" => Platform::PLATFORM_WIN32_X86_64,
_ => Platform::PLATFORM_WIN32_X86,
},
_ => Platform::PLATFORM_LINUX_X86,
};
#[cfg(debug_assertions)]
const PRODUCT_FLAGS: ProductFlags = ProductFlags::PRODUCT_FLAG_DEV_BUILD;
#[cfg(not(debug_assertions))]
const PRODUCT_FLAGS: ProductFlags = ProductFlags::PRODUCT_FLAG_NONE;
let mut packet = ClientHello::new();
packet
.mut_build_info()
.set_product(protocol::keyexchange::Product::PRODUCT_PARTNER);
// ProductInfo won't push autoplay and perhaps other settings
// when set to anything else than PRODUCT_CLIENT
.set_product(protocol::keyexchange::Product::PRODUCT_CLIENT);
packet
.mut_build_info()
.set_platform(protocol::keyexchange::Platform::PLATFORM_LINUX_X86);
packet.mut_build_info().set_version(109800078);
.mut_product_flags()
.push(PRODUCT_FLAGS);
packet.mut_build_info().set_platform(platform);
packet
.mut_build_info()
.set_version(version::SPOTIFY_VERSION);
packet
.mut_cryptosuites_supported()
.push(protocol::keyexchange::Cryptosuite::CRYPTO_SUITE_SHANNON);
@ -66,8 +120,8 @@ where
let mut buffer = vec![0, 4];
let size = 2 + 4 + packet.compute_size();
<Vec<u8> as WriteBytesExt>::write_u32::<BigEndian>(&mut buffer, size).unwrap();
packet.write_to_vec(&mut buffer).unwrap();
<Vec<u8> as WriteBytesExt>::write_u32::<BigEndian>(&mut buffer, size)?;
packet.write_to_vec(&mut buffer)?;
connection.write_all(&buffer[..]).await?;
Ok(buffer)
@ -87,8 +141,8 @@ where
let mut buffer = vec![];
let size = 4 + packet.compute_size();
<Vec<u8> as WriteBytesExt>::write_u32::<BigEndian>(&mut buffer, size).unwrap();
packet.write_to_vec(&mut buffer).unwrap();
<Vec<u8> as WriteBytesExt>::write_u32::<BigEndian>(&mut buffer, size)?;
packet.write_to_vec(&mut buffer)?;
connection.write_all(&buffer[..]).await?;
Ok(())
@ -102,7 +156,7 @@ where
let header = read_into_accumulator(connection, 4, acc).await?;
let size = BigEndian::read_u32(header) as usize;
let data = read_into_accumulator(connection, size - 4, acc).await?;
let message = M::parse_from_bytes(data).unwrap();
let message = M::parse_from_bytes(data)?;
Ok(message)
}
@ -118,24 +172,26 @@ async fn read_into_accumulator<'a, 'b, T: AsyncRead + Unpin>(
Ok(&mut acc[offset..])
}
fn compute_keys(shared_secret: &[u8], packets: &[u8]) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
fn compute_keys(shared_secret: &[u8], packets: &[u8]) -> io::Result<(Vec<u8>, Vec<u8>, Vec<u8>)> {
type HmacSha1 = Hmac<Sha1>;
let mut data = Vec::with_capacity(0x64);
for i in 1..6 {
let mut mac =
HmacSha1::new_from_slice(&shared_secret).expect("HMAC can take key of any size");
let mut mac = HmacSha1::new_from_slice(shared_secret).map_err(|_| {
io::Error::new(io::ErrorKind::InvalidData, HandshakeError::InvalidLength)
})?;
mac.update(packets);
mac.update(&[i]);
data.extend_from_slice(&mac.finalize().into_bytes());
}
let mut mac = HmacSha1::new_from_slice(&data[..0x14]).expect("HMAC can take key of any size");
let mut mac = HmacSha1::new_from_slice(&data[..0x14])
.map_err(|_| io::Error::new(io::ErrorKind::InvalidData, HandshakeError::InvalidLength))?;
mac.update(packets);
(
Ok((
mac.finalize().into_bytes().to_vec(),
data[0x14..0x34].to_vec(),
data[0x34..0x54].to_vec(),
)
))
}

View file

@ -1,23 +1,21 @@
mod codec;
mod handshake;
pub use self::codec::ApCodec;
pub use self::handshake::handshake;
pub use self::{codec::ApCodec, handshake::handshake};
use std::io::{self, ErrorKind};
use std::io;
use futures_util::{SinkExt, StreamExt};
use num_traits::FromPrimitive;
use protobuf::{self, Message, ProtobufError};
use protobuf::{self, Message};
use thiserror::Error;
use tokio::net::TcpStream;
use tokio_util::codec::Framed;
use url::Url;
use crate::authentication::Credentials;
use crate::packet::PacketType;
use crate::{authentication::Credentials, packet::PacketType, version, Error};
use crate::protocol::keyexchange::{APLoginFailed, ErrorCode};
use crate::version;
pub type Transport = Framed<TcpStream, ApCodec>;
@ -42,13 +40,19 @@ fn login_error_message(code: &ErrorCode) -> &'static str {
pub enum AuthenticationError {
#[error("Login failed with reason: {}", login_error_message(.0))]
LoginFailed(ErrorCode),
#[error("Authentication failed: {0}")]
IoError(#[from] io::Error),
#[error("invalid packet {0}")]
Packet(u8),
#[error("transport returned no data")]
Transport,
}
impl From<ProtobufError> for AuthenticationError {
fn from(e: ProtobufError) -> Self {
io::Error::new(ErrorKind::InvalidData, e).into()
impl From<AuthenticationError> for Error {
fn from(err: AuthenticationError) -> Self {
match err {
AuthenticationError::LoginFailed(_) => Error::permission_denied(err),
AuthenticationError::Packet(_) => Error::unimplemented(err),
AuthenticationError::Transport => Error::unavailable(err),
}
}
}
@ -68,9 +72,32 @@ pub async fn authenticate(
transport: &mut Transport,
credentials: Credentials,
device_id: &str,
) -> Result<Credentials, AuthenticationError> {
) -> Result<Credentials, Error> {
use crate::protocol::authentication::{APWelcome, ClientResponseEncrypted, CpuFamily, Os};
let cpu_family = match std::env::consts::ARCH {
"blackfin" => CpuFamily::CPU_BLACKFIN,
"arm" | "arm64" => CpuFamily::CPU_ARM,
"ia64" => CpuFamily::CPU_IA64,
"mips" => CpuFamily::CPU_MIPS,
"ppc" => CpuFamily::CPU_PPC,
"ppc64" => CpuFamily::CPU_PPC_64,
"sh" => CpuFamily::CPU_SH,
"x86" => CpuFamily::CPU_X86,
"x86_64" => CpuFamily::CPU_X86_64,
_ => CpuFamily::CPU_UNKNOWN,
};
let os = match std::env::consts::OS {
"android" => Os::OS_ANDROID,
"freebsd" | "netbsd" | "openbsd" => Os::OS_FREEBSD,
"ios" => Os::OS_IPHONE,
"linux" => Os::OS_LINUX,
"macos" => Os::OS_OSX,
"windows" => Os::OS_WINDOWS,
_ => Os::OS_UNKNOWN,
};
let mut packet = ClientResponseEncrypted::new();
packet
.mut_login_credentials()
@ -81,29 +108,30 @@ pub async fn authenticate(
packet
.mut_login_credentials()
.set_auth_data(credentials.auth_data);
packet
.mut_system_info()
.set_cpu_family(CpuFamily::CPU_UNKNOWN);
packet.mut_system_info().set_os(Os::OS_UNKNOWN);
packet.mut_system_info().set_cpu_family(cpu_family);
packet.mut_system_info().set_os(os);
packet
.mut_system_info()
.set_system_information_string(format!(
"librespot_{}_{}",
"librespot-{}-{}",
version::SHA_SHORT,
version::BUILD_ID
));
packet
.mut_system_info()
.set_device_id(device_id.to_string());
packet.set_version_string(version::VERSION_STRING.to_string());
packet.set_version_string(format!("librespot {}", version::SEMVER));
let cmd = PacketType::Login;
let data = packet.write_to_bytes().unwrap();
let data = packet.write_to_bytes()?;
transport.send((cmd as u8, data)).await?;
let (cmd, data) = transport.next().await.expect("EOF")?;
let (cmd, data) = transport
.next()
.await
.ok_or(AuthenticationError::Transport)??;
let packet_type = FromPrimitive::from_u8(cmd);
match packet_type {
let result = match packet_type {
Some(PacketType::APWelcome) => {
let welcome_data = APWelcome::parse_from_bytes(data.as_ref())?;
@ -120,8 +148,13 @@ pub async fn authenticate(
Err(error_data.into())
}
_ => {
let msg = format!("Received invalid packet: {}", cmd);
Err(io::Error::new(ErrorKind::InvalidData, msg).into())
trace!(
"Did not expect {:?} AES key packet with data {:#?}",
cmd,
data
);
Err(AuthenticationError::Packet(cmd))
}
}
};
Ok(result?)
}

79
core/src/date.rs Normal file
View file

@ -0,0 +1,79 @@
use std::{convert::TryFrom, fmt::Debug, ops::Deref};
use chrono::{DateTime, NaiveDate, NaiveDateTime, NaiveTime, Utc};
use thiserror::Error;
use crate::Error;
use librespot_protocol as protocol;
use protocol::metadata::Date as DateMessage;
#[derive(Debug, Error)]
pub enum DateError {
#[error("item has invalid timestamp {0}")]
Timestamp(i64),
}
impl From<DateError> for Error {
fn from(err: DateError) -> Self {
Error::invalid_argument(err)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct Date(pub DateTime<Utc>);
impl Deref for Date {
type Target = DateTime<Utc>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl Date {
pub fn as_timestamp(&self) -> i64 {
self.0.timestamp()
}
pub fn from_timestamp(timestamp: i64) -> Result<Self, Error> {
if let Some(date_time) = NaiveDateTime::from_timestamp_opt(timestamp, 0) {
Ok(Self::from_utc(date_time))
} else {
Err(DateError::Timestamp(timestamp).into())
}
}
pub fn as_utc(&self) -> DateTime<Utc> {
self.0
}
pub fn from_utc(date_time: NaiveDateTime) -> Self {
Self(DateTime::<Utc>::from_utc(date_time, Utc))
}
}
impl From<&DateMessage> for Date {
fn from(date: &DateMessage) -> Self {
let naive_date = NaiveDate::from_ymd(
date.get_year() as i32,
date.get_month() as u32,
date.get_day() as u32,
);
let naive_time = NaiveTime::from_hms(date.get_hour() as u32, date.get_minute() as u32, 0);
let naive_datetime = NaiveDateTime::new(naive_date, naive_time);
Self(DateTime::<Utc>::from_utc(naive_datetime, Utc))
}
}
impl From<DateTime<Utc>> for Date {
fn from(date: DateTime<Utc>) -> Self {
Self(date)
}
}
impl TryFrom<i64> for Date {
type Error = crate::Error;
fn try_from(timestamp: i64) -> Result<Self, Self::Error> {
Self::from_timestamp(timestamp)
}
}

View file

@ -1,7 +1,20 @@
use std::collections::HashMap;
#[derive(Debug)]
pub struct AlreadyHandledError(());
use thiserror::Error;
use crate::Error;
#[derive(Debug, Error)]
pub enum HandlerMapError {
#[error("request was already handled")]
AlreadyHandled,
}
impl From<HandlerMapError> for Error {
fn from(err: HandlerMapError) -> Self {
Error::aborted(err)
}
}
pub enum HandlerMap<T> {
Leaf(T),
@ -19,9 +32,9 @@ impl<T> HandlerMap<T> {
&mut self,
mut path: impl Iterator<Item = &'a str>,
handler: T,
) -> Result<(), AlreadyHandledError> {
) -> Result<(), Error> {
match self {
Self::Leaf(_) => Err(AlreadyHandledError(())),
Self::Leaf(_) => Err(HandlerMapError::AlreadyHandled.into()),
Self::Branch(children) => {
if let Some(component) = path.next() {
let node = children.entry(component.to_owned()).or_default();
@ -30,7 +43,7 @@ impl<T> HandlerMap<T> {
*self = Self::Leaf(handler);
Ok(())
} else {
Err(AlreadyHandledError(()))
Err(HandlerMapError::AlreadyHandled.into())
}
}
}

View file

@ -1,29 +1,41 @@
mod maps;
pub mod protocol;
use std::iter;
use std::pin::Pin;
use std::sync::atomic::AtomicBool;
use std::sync::{atomic, Arc, Mutex};
use std::task::Poll;
use std::time::Duration;
use std::{
iter,
pin::Pin,
sync::{
atomic::{self, AtomicBool},
Arc,
},
task::Poll,
time::Duration,
};
use futures_core::{Future, Stream};
use futures_util::future::join_all;
use futures_util::{SinkExt, StreamExt};
use futures_util::{future::join_all, SinkExt, StreamExt};
use parking_lot::Mutex;
use thiserror::Error;
use tokio::select;
use tokio::sync::mpsc::{self, UnboundedReceiver};
use tokio::sync::Semaphore;
use tokio::task::JoinHandle;
use tokio::{
select,
sync::{
mpsc::{self, UnboundedReceiver},
Semaphore,
},
task::JoinHandle,
};
use tokio_tungstenite::tungstenite;
use tungstenite::error::UrlError;
use url::Url;
use self::maps::*;
use self::protocol::*;
use crate::socket;
use crate::util::{keep_flushing, CancelOnDrop, TimeoutOnDrop};
use crate::{
socket,
util::{keep_flushing, CancelOnDrop, TimeoutOnDrop},
Error,
};
type WsMessage = tungstenite::Message;
type WsError = tungstenite::Error;
@ -164,24 +176,38 @@ fn split_uri(s: &str) -> Option<impl Iterator<Item = &'_ str>> {
pub enum AddHandlerError {
#[error("There is already a handler for the given uri")]
AlreadyHandled,
#[error("The specified uri is invalid")]
InvalidUri,
#[error("The specified uri {0} is invalid")]
InvalidUri(String),
}
impl From<AddHandlerError> for Error {
fn from(err: AddHandlerError) -> Self {
match err {
AddHandlerError::AlreadyHandled => Error::aborted(err),
AddHandlerError::InvalidUri(_) => Error::invalid_argument(err),
}
}
}
#[derive(Debug, Clone, Error)]
pub enum SubscriptionError {
#[error("The specified uri is invalid")]
InvalidUri,
InvalidUri(String),
}
impl From<SubscriptionError> for Error {
fn from(err: SubscriptionError) -> Self {
Error::invalid_argument(err)
}
}
fn add_handler(
map: &mut HandlerMap<Box<dyn RequestHandler>>,
uri: &str,
handler: impl RequestHandler,
) -> Result<(), AddHandlerError> {
let split = split_uri(uri).ok_or(AddHandlerError::InvalidUri)?;
) -> Result<(), Error> {
let split = split_uri(uri).ok_or_else(|| AddHandlerError::InvalidUri(uri.to_string()))?;
map.insert(split, Box::new(handler))
.map_err(|_| AddHandlerError::AlreadyHandled)
}
fn remove_handler<T>(map: &mut HandlerMap<T>, uri: &str) -> Option<T> {
@ -191,11 +217,11 @@ fn remove_handler<T>(map: &mut HandlerMap<T>, uri: &str) -> Option<T> {
fn subscribe(
map: &mut SubscriberMap<MessageHandler>,
uris: &[&str],
) -> Result<Subscription, SubscriptionError> {
) -> Result<Subscription, Error> {
let (tx, rx) = mpsc::unbounded_channel();
for &uri in uris {
let split = split_uri(uri).ok_or(SubscriptionError::InvalidUri)?;
let split = split_uri(uri).ok_or_else(|| SubscriptionError::InvalidUri(uri.to_string()))?;
map.insert(split, tx.clone());
}
@ -237,15 +263,11 @@ impl Builder {
Self::default()
}
pub fn add_handler(
&mut self,
uri: &str,
handler: impl RequestHandler,
) -> Result<(), AddHandlerError> {
pub fn add_handler(&mut self, uri: &str, handler: impl RequestHandler) -> Result<(), Error> {
add_handler(&mut self.request_handlers, uri, handler)
}
pub fn subscribe(&mut self, uris: &[&str]) -> Result<Subscription, SubscriptionError> {
pub fn subscribe(&mut self, uris: &[&str]) -> Result<Subscription, Error> {
subscribe(&mut self.message_handlers, uris)
}
@ -289,7 +311,6 @@ impl DealerShared {
if let Some(split) = split_uri(&msg.uri) {
self.message_handlers
.lock()
.unwrap()
.retain(split, &mut |tx| tx.send(msg.clone()).is_ok());
}
}
@ -309,7 +330,7 @@ impl DealerShared {
};
{
let handler_map = self.request_handlers.lock().unwrap();
let handler_map = self.request_handlers.lock();
if let Some(handler) = handler_map.get(split) {
handler.handle_request(request, responder);
@ -328,7 +349,9 @@ impl DealerShared {
}
async fn closed(&self) {
self.notify_drop.acquire().await.unwrap_err();
if self.notify_drop.acquire().await.is_ok() {
error!("should never have gotten a permit");
}
}
fn is_closed(&self) -> bool {
@ -342,23 +365,19 @@ pub struct Dealer {
}
impl Dealer {
pub fn add_handler<H>(&self, uri: &str, handler: H) -> Result<(), AddHandlerError>
pub fn add_handler<H>(&self, uri: &str, handler: H) -> Result<(), Error>
where
H: RequestHandler,
{
add_handler(
&mut self.shared.request_handlers.lock().unwrap(),
uri,
handler,
)
add_handler(&mut self.shared.request_handlers.lock(), uri, handler)
}
pub fn remove_handler(&self, uri: &str) -> Option<Box<dyn RequestHandler>> {
remove_handler(&mut self.shared.request_handlers.lock().unwrap(), uri)
remove_handler(&mut self.shared.request_handlers.lock(), uri)
}
pub fn subscribe(&self, uris: &[&str]) -> Result<Subscription, SubscriptionError> {
subscribe(&mut self.shared.message_handlers.lock().unwrap(), uris)
pub fn subscribe(&self, uris: &[&str]) -> Result<Subscription, Error> {
subscribe(&mut self.shared.message_handlers.lock(), uris)
}
pub async fn close(mut self) {
@ -367,7 +386,9 @@ impl Dealer {
self.shared.notify_drop.close();
if let Some(handle) = self.handle.take() {
CancelOnDrop(handle).await.unwrap();
if let Err(e) = CancelOnDrop(handle).await {
error!("error aborting dealer operations: {}", e);
}
}
}
}
@ -401,7 +422,7 @@ async fn connect(
// Spawn a task that will forward messages from the channel to the websocket.
let send_task = {
let shared = Arc::clone(&shared);
let shared = Arc::clone(shared);
tokio::spawn(async move {
let result = loop {
@ -450,7 +471,7 @@ async fn connect(
})
};
let shared = Arc::clone(&shared);
let shared = Arc::clone(shared);
// A task that receives messages from the web socket.
let receive_task = tokio::spawn(async {
@ -556,11 +577,15 @@ async fn run<F, Fut>(
select! {
() = shared.closed() => break,
r = t0 => {
r.unwrap(); // Whatever has gone wrong (probably panicked), we can't handle it, so let's panic too.
if let Err(e) = r {
error!("timeout on task 0: {}", e);
}
tasks.0.take();
},
r = t1 => {
r.unwrap();
if let Err(e) = r {
error!("timeout on task 1: {}", e);
}
tasks.1.take();
}
}
@ -576,7 +601,7 @@ async fn run<F, Fut>(
match connect(&url, proxy.as_ref(), &shared).await {
Ok((s, r)) => tasks = (init_task(s), init_task(r)),
Err(e) => {
warn!("Error while connecting: {}", e);
error!("Error while connecting: {}", e);
tokio::time::sleep(RECONNECT_INTERVAL).await;
}
}

447
core/src/error.rs Normal file
View file

@ -0,0 +1,447 @@
use std::{error, fmt, num::ParseIntError, str::Utf8Error, string::FromUtf8Error};
use base64::DecodeError;
use http::{
header::{InvalidHeaderName, InvalidHeaderValue, ToStrError},
method::InvalidMethod,
status::InvalidStatusCode,
uri::{InvalidUri, InvalidUriParts},
};
use protobuf::ProtobufError;
use thiserror::Error;
use tokio::sync::{mpsc::error::SendError, oneshot::error::RecvError};
use url::ParseError;
#[cfg(feature = "with-dns-sd")]
use dns_sd::DNSError;
#[derive(Debug)]
pub struct Error {
pub kind: ErrorKind,
pub error: Box<dyn error::Error + Send + Sync>,
}
#[derive(Clone, Copy, Debug, Eq, Error, Hash, Ord, PartialEq, PartialOrd)]
pub enum ErrorKind {
#[error("The operation was cancelled by the caller")]
Cancelled = 1,
#[error("Unknown error")]
Unknown = 2,
#[error("Client specified an invalid argument")]
InvalidArgument = 3,
#[error("Deadline expired before operation could complete")]
DeadlineExceeded = 4,
#[error("Requested entity was not found")]
NotFound = 5,
#[error("Attempt to create entity that already exists")]
AlreadyExists = 6,
#[error("Permission denied")]
PermissionDenied = 7,
#[error("No valid authentication credentials")]
Unauthenticated = 16,
#[error("Resource has been exhausted")]
ResourceExhausted = 8,
#[error("Invalid state")]
FailedPrecondition = 9,
#[error("Operation aborted")]
Aborted = 10,
#[error("Operation attempted past the valid range")]
OutOfRange = 11,
#[error("Not implemented")]
Unimplemented = 12,
#[error("Internal error")]
Internal = 13,
#[error("Service unavailable")]
Unavailable = 14,
#[error("Unrecoverable data loss or corruption")]
DataLoss = 15,
#[error("Operation must not be used")]
DoNotUse = -1,
}
#[derive(Debug, Error)]
struct ErrorMessage(String);
impl fmt::Display for ErrorMessage {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl Error {
pub fn new<E>(kind: ErrorKind, error: E) -> Error
where
E: Into<Box<dyn error::Error + Send + Sync>>,
{
Self {
kind,
error: error.into(),
}
}
pub fn aborted<E>(error: E) -> Error
where
E: Into<Box<dyn error::Error + Send + Sync>>,
{
Self {
kind: ErrorKind::Aborted,
error: error.into(),
}
}
pub fn already_exists<E>(error: E) -> Error
where
E: Into<Box<dyn error::Error + Send + Sync>>,
{
Self {
kind: ErrorKind::AlreadyExists,
error: error.into(),
}
}
pub fn cancelled<E>(error: E) -> Error
where
E: Into<Box<dyn error::Error + Send + Sync>>,
{
Self {
kind: ErrorKind::Cancelled,
error: error.into(),
}
}
pub fn data_loss<E>(error: E) -> Error
where
E: Into<Box<dyn error::Error + Send + Sync>>,
{
Self {
kind: ErrorKind::DataLoss,
error: error.into(),
}
}
pub fn deadline_exceeded<E>(error: E) -> Error
where
E: Into<Box<dyn error::Error + Send + Sync>>,
{
Self {
kind: ErrorKind::DeadlineExceeded,
error: error.into(),
}
}
pub fn do_not_use<E>(error: E) -> Error
where
E: Into<Box<dyn error::Error + Send + Sync>>,
{
Self {
kind: ErrorKind::DoNotUse,
error: error.into(),
}
}
pub fn failed_precondition<E>(error: E) -> Error
where
E: Into<Box<dyn error::Error + Send + Sync>>,
{
Self {
kind: ErrorKind::FailedPrecondition,
error: error.into(),
}
}
pub fn internal<E>(error: E) -> Error
where
E: Into<Box<dyn error::Error + Send + Sync>>,
{
Self {
kind: ErrorKind::Internal,
error: error.into(),
}
}
pub fn invalid_argument<E>(error: E) -> Error
where
E: Into<Box<dyn error::Error + Send + Sync>>,
{
Self {
kind: ErrorKind::InvalidArgument,
error: error.into(),
}
}
pub fn not_found<E>(error: E) -> Error
where
E: Into<Box<dyn error::Error + Send + Sync>>,
{
Self {
kind: ErrorKind::NotFound,
error: error.into(),
}
}
pub fn out_of_range<E>(error: E) -> Error
where
E: Into<Box<dyn error::Error + Send + Sync>>,
{
Self {
kind: ErrorKind::OutOfRange,
error: error.into(),
}
}
pub fn permission_denied<E>(error: E) -> Error
where
E: Into<Box<dyn error::Error + Send + Sync>>,
{
Self {
kind: ErrorKind::PermissionDenied,
error: error.into(),
}
}
pub fn resource_exhausted<E>(error: E) -> Error
where
E: Into<Box<dyn error::Error + Send + Sync>>,
{
Self {
kind: ErrorKind::ResourceExhausted,
error: error.into(),
}
}
pub fn unauthenticated<E>(error: E) -> Error
where
E: Into<Box<dyn error::Error + Send + Sync>>,
{
Self {
kind: ErrorKind::Unauthenticated,
error: error.into(),
}
}
pub fn unavailable<E>(error: E) -> Error
where
E: Into<Box<dyn error::Error + Send + Sync>>,
{
Self {
kind: ErrorKind::Unavailable,
error: error.into(),
}
}
pub fn unimplemented<E>(error: E) -> Error
where
E: Into<Box<dyn error::Error + Send + Sync>>,
{
Self {
kind: ErrorKind::Unimplemented,
error: error.into(),
}
}
pub fn unknown<E>(error: E) -> Error
where
E: Into<Box<dyn error::Error + Send + Sync>>,
{
Self {
kind: ErrorKind::Unknown,
error: error.into(),
}
}
}
impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
self.error.source()
}
}
impl fmt::Display for Error {
fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(fmt, "{} {{ ", self.kind)?;
self.error.fmt(fmt)?;
write!(fmt, " }}")
}
}
impl From<DecodeError> for Error {
fn from(err: DecodeError) -> Self {
Self::new(ErrorKind::FailedPrecondition, err)
}
}
#[cfg(feature = "with-dns-sd")]
impl From<DNSError> for Error {
fn from(err: DNSError) -> Self {
Self::new(ErrorKind::Unavailable, err)
}
}
impl From<http::Error> for Error {
fn from(err: http::Error) -> Self {
if err.is::<InvalidHeaderName>()
|| err.is::<InvalidHeaderValue>()
|| err.is::<InvalidMethod>()
|| err.is::<InvalidUri>()
|| err.is::<InvalidUriParts>()
{
return Self::new(ErrorKind::InvalidArgument, err);
}
if err.is::<InvalidStatusCode>() {
return Self::new(ErrorKind::FailedPrecondition, err);
}
Self::new(ErrorKind::Unknown, err)
}
}
impl From<hyper::Error> for Error {
fn from(err: hyper::Error) -> Self {
if err.is_parse() || err.is_parse_too_large() || err.is_parse_status() || err.is_user() {
return Self::new(ErrorKind::Internal, err);
}
if err.is_canceled() {
return Self::new(ErrorKind::Cancelled, err);
}
if err.is_connect() {
return Self::new(ErrorKind::Unavailable, err);
}
if err.is_incomplete_message() {
return Self::new(ErrorKind::DataLoss, err);
}
if err.is_body_write_aborted() || err.is_closed() {
return Self::new(ErrorKind::Aborted, err);
}
if err.is_timeout() {
return Self::new(ErrorKind::DeadlineExceeded, err);
}
Self::new(ErrorKind::Unknown, err)
}
}
impl From<quick_xml::Error> for Error {
fn from(err: quick_xml::Error) -> Self {
Self::new(ErrorKind::FailedPrecondition, err)
}
}
impl From<serde_json::Error> for Error {
fn from(err: serde_json::Error) -> Self {
Self::new(ErrorKind::FailedPrecondition, err)
}
}
impl From<std::io::Error> for Error {
fn from(err: std::io::Error) -> Self {
use std::io::ErrorKind as IoErrorKind;
match err.kind() {
IoErrorKind::NotFound => Self::new(ErrorKind::NotFound, err),
IoErrorKind::PermissionDenied => Self::new(ErrorKind::PermissionDenied, err),
IoErrorKind::AddrInUse | IoErrorKind::AlreadyExists => {
Self::new(ErrorKind::AlreadyExists, err)
}
IoErrorKind::AddrNotAvailable
| IoErrorKind::ConnectionRefused
| IoErrorKind::NotConnected => Self::new(ErrorKind::Unavailable, err),
IoErrorKind::BrokenPipe
| IoErrorKind::ConnectionReset
| IoErrorKind::ConnectionAborted => Self::new(ErrorKind::Aborted, err),
IoErrorKind::Interrupted | IoErrorKind::WouldBlock => {
Self::new(ErrorKind::Cancelled, err)
}
IoErrorKind::InvalidData | IoErrorKind::UnexpectedEof => {
Self::new(ErrorKind::FailedPrecondition, err)
}
IoErrorKind::TimedOut => Self::new(ErrorKind::DeadlineExceeded, err),
IoErrorKind::InvalidInput => Self::new(ErrorKind::InvalidArgument, err),
IoErrorKind::WriteZero => Self::new(ErrorKind::ResourceExhausted, err),
_ => Self::new(ErrorKind::Unknown, err),
}
}
}
impl From<FromUtf8Error> for Error {
fn from(err: FromUtf8Error) -> Self {
Self::new(ErrorKind::FailedPrecondition, err)
}
}
impl From<InvalidHeaderValue> for Error {
fn from(err: InvalidHeaderValue) -> Self {
Self::new(ErrorKind::InvalidArgument, err)
}
}
impl From<InvalidUri> for Error {
fn from(err: InvalidUri) -> Self {
Self::new(ErrorKind::InvalidArgument, err)
}
}
impl From<ParseError> for Error {
fn from(err: ParseError) -> Self {
Self::new(ErrorKind::FailedPrecondition, err)
}
}
impl From<ParseIntError> for Error {
fn from(err: ParseIntError) -> Self {
Self::new(ErrorKind::FailedPrecondition, err)
}
}
impl From<ProtobufError> for Error {
fn from(err: ProtobufError) -> Self {
Self::new(ErrorKind::FailedPrecondition, err)
}
}
impl From<RecvError> for Error {
fn from(err: RecvError) -> Self {
Self::new(ErrorKind::Internal, err)
}
}
impl<T> From<SendError<T>> for Error {
fn from(err: SendError<T>) -> Self {
Self {
kind: ErrorKind::Internal,
error: ErrorMessage(err.to_string()).into(),
}
}
}
impl From<ToStrError> for Error {
fn from(err: ToStrError) -> Self {
Self::new(ErrorKind::FailedPrecondition, err)
}
}
impl From<Utf8Error> for Error {
fn from(err: Utf8Error) -> Self {
Self::new(ErrorKind::FailedPrecondition, err)
}
}

55
core/src/file_id.rs Normal file
View file

@ -0,0 +1,55 @@
use std::fmt;
use librespot_protocol as protocol;
use crate::spotify_id::to_base16;
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct FileId(pub [u8; 20]);
impl FileId {
pub fn from_raw(src: &[u8]) -> FileId {
let mut dst = [0u8; 20];
dst.clone_from_slice(src);
FileId(dst)
}
pub fn to_base16(&self) -> String {
to_base16(&self.0, &mut [0u8; 40])
}
}
impl fmt::Debug for FileId {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.debug_tuple("FileId").field(&self.to_base16()).finish()
}
}
impl fmt::Display for FileId {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str(&self.to_base16())
}
}
impl From<&[u8]> for FileId {
fn from(src: &[u8]) -> Self {
Self::from_raw(src)
}
}
impl From<&protocol::metadata::Image> for FileId {
fn from(image: &protocol::metadata::Image) -> Self {
Self::from(image.get_file_id())
}
}
impl From<&protocol::metadata::AudioFile> for FileId {
fn from(file: &protocol::metadata::AudioFile) -> Self {
Self::from(file.get_file_id())
}
}
impl From<&protocol::metadata::VideoFile> for FileId {
fn from(video: &protocol::metadata::VideoFile) -> Self {
Self::from(video.get_file_id())
}
}

View file

@ -1,34 +1,180 @@
use hyper::client::HttpConnector;
use hyper::{Body, Client, Request, Response};
use std::env::consts::OS;
use bytes::Bytes;
use futures_util::{future::IntoStream, FutureExt};
use http::header::HeaderValue;
use hyper::{
client::{HttpConnector, ResponseFuture},
header::USER_AGENT,
Body, Client, Request, Response, StatusCode,
};
use hyper_proxy::{Intercept, Proxy, ProxyConnector};
use hyper_rustls::HttpsConnector;
use rustls::{ClientConfig, RootCertStore};
use thiserror::Error;
use url::Url;
use crate::{
version::{FALLBACK_USER_AGENT, SPOTIFY_MOBILE_VERSION, SPOTIFY_VERSION, VERSION_STRING},
Error,
};
#[derive(Debug, Error)]
pub enum HttpClientError {
#[error("Response status code: {0}")]
StatusCode(hyper::StatusCode),
}
impl From<HttpClientError> for Error {
fn from(err: HttpClientError) -> Self {
match err {
HttpClientError::StatusCode(code) => {
// not exhaustive, but what reasonably could be expected
match code {
StatusCode::GATEWAY_TIMEOUT | StatusCode::REQUEST_TIMEOUT => {
Error::deadline_exceeded(err)
}
StatusCode::GONE
| StatusCode::NOT_FOUND
| StatusCode::MOVED_PERMANENTLY
| StatusCode::PERMANENT_REDIRECT
| StatusCode::TEMPORARY_REDIRECT => Error::not_found(err),
StatusCode::FORBIDDEN | StatusCode::PAYMENT_REQUIRED => {
Error::permission_denied(err)
}
StatusCode::NETWORK_AUTHENTICATION_REQUIRED
| StatusCode::PROXY_AUTHENTICATION_REQUIRED
| StatusCode::UNAUTHORIZED => Error::unauthenticated(err),
StatusCode::EXPECTATION_FAILED
| StatusCode::PRECONDITION_FAILED
| StatusCode::PRECONDITION_REQUIRED => Error::failed_precondition(err),
StatusCode::RANGE_NOT_SATISFIABLE => Error::out_of_range(err),
StatusCode::INTERNAL_SERVER_ERROR
| StatusCode::MISDIRECTED_REQUEST
| StatusCode::SERVICE_UNAVAILABLE
| StatusCode::UNAVAILABLE_FOR_LEGAL_REASONS => Error::unavailable(err),
StatusCode::BAD_REQUEST
| StatusCode::HTTP_VERSION_NOT_SUPPORTED
| StatusCode::LENGTH_REQUIRED
| StatusCode::METHOD_NOT_ALLOWED
| StatusCode::NOT_ACCEPTABLE
| StatusCode::PAYLOAD_TOO_LARGE
| StatusCode::REQUEST_HEADER_FIELDS_TOO_LARGE
| StatusCode::UNSUPPORTED_MEDIA_TYPE
| StatusCode::URI_TOO_LONG => Error::invalid_argument(err),
StatusCode::TOO_MANY_REQUESTS => Error::resource_exhausted(err),
StatusCode::NOT_IMPLEMENTED => Error::unimplemented(err),
_ => Error::unknown(err),
}
}
}
}
}
pub struct HttpClient {
user_agent: HeaderValue,
proxy: Option<Url>,
tls_config: ClientConfig,
}
impl HttpClient {
pub fn new(proxy: Option<&Url>) -> Self {
let spotify_version = match OS {
"android" | "ios" => SPOTIFY_MOBILE_VERSION.to_owned(),
_ => SPOTIFY_VERSION.to_string(),
};
let spotify_platform = match OS {
"android" => "Android/31",
"ios" => "iOS/15.2",
"macos" => "OSX/0",
"windows" => "Win32/0",
_ => "Linux/0",
};
let user_agent_str = &format!(
"Spotify/{} {} ({})",
spotify_version, spotify_platform, VERSION_STRING
);
let user_agent = HeaderValue::from_str(user_agent_str).unwrap_or_else(|err| {
error!("Invalid user agent <{}>: {}", user_agent_str, err);
error!("Please report this as a bug.");
HeaderValue::from_static(FALLBACK_USER_AGENT)
});
// configuring TLS is expensive and should be done once per process
let root_store = match rustls_native_certs::load_native_certs() {
Ok(store) => store,
Err((Some(store), err)) => {
warn!("Could not load all certificates: {:?}", err);
store
}
Err((None, err)) => {
error!("Cannot access native certificate store: {}", err);
error!("Continuing, but most requests will probably fail until you fix your system certificate store.");
RootCertStore::empty()
}
};
let mut tls_config = ClientConfig::new();
tls_config.root_store = root_store;
tls_config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()];
Self {
user_agent,
proxy: proxy.cloned(),
tls_config,
}
}
pub async fn request(&self, req: Request<Body>) -> Result<Response<Body>, hyper::Error> {
if let Some(url) = &self.proxy {
// Panic safety: all URLs are valid URIs
let uri = url.to_string().parse().unwrap();
let proxy = Proxy::new(Intercept::All, uri);
let connector = HttpConnector::new();
let proxy_connector = ProxyConnector::from_proxy_unsecured(connector, proxy);
Client::builder().build(proxy_connector).request(req).await
} else {
Client::new().request(req).await
pub async fn request(&self, req: Request<Body>) -> Result<Response<Body>, Error> {
debug!("Requesting {:?}", req.uri().to_string());
let request = self.request_fut(req)?;
let response = request.await;
if let Ok(response) = &response {
let code = response.status();
if code != StatusCode::OK {
return Err(HttpClientError::StatusCode(code).into());
}
}
Ok(response?)
}
pub async fn request_body(&self, req: Request<Body>) -> Result<bytes::Bytes, hyper::Error> {
pub async fn request_body(&self, req: Request<Body>) -> Result<Bytes, Error> {
let response = self.request(req).await?;
hyper::body::to_bytes(response.into_body()).await
Ok(hyper::body::to_bytes(response.into_body()).await?)
}
pub fn request_stream(&self, req: Request<Body>) -> Result<IntoStream<ResponseFuture>, Error> {
Ok(self.request_fut(req)?.into_stream())
}
pub fn request_fut(&self, mut req: Request<Body>) -> Result<ResponseFuture, Error> {
let mut http = HttpConnector::new();
http.enforce_http(false);
let https_connector = HttpsConnector::from((http, self.tls_config.clone()));
let headers_mut = req.headers_mut();
headers_mut.insert(USER_AGENT, self.user_agent.clone());
let request = if let Some(url) = &self.proxy {
let proxy_uri = url.to_string().parse()?;
let proxy = Proxy::new(Intercept::All, proxy_uri);
let proxy_connector = ProxyConnector::from_proxy(https_connector, proxy)?;
Client::builder().build(proxy_connector).request(req)
} else {
Client::builder()
.http2_adaptive_window(true)
.build(https_connector)
.request(req)
};
Ok(request)
}
}

View file

@ -7,26 +7,37 @@ use librespot_protocol as protocol;
#[macro_use]
mod component;
mod apresolve;
pub mod apresolve;
pub mod audio_key;
pub mod authentication;
pub mod cache;
pub mod cdn_url;
pub mod channel;
pub mod config;
mod connection;
pub mod date;
#[allow(dead_code)]
mod dealer;
#[doc(hidden)]
pub mod diffie_hellman;
pub mod error;
pub mod file_id;
mod http_client;
pub mod mercury;
pub mod packet;
mod proxytunnel;
pub mod session;
mod socket;
mod spclient;
#[allow(dead_code)]
pub mod spclient;
pub mod spotify_id;
mod token;
pub mod token;
#[doc(hidden)]
pub mod util;
pub mod version;
pub use config::SessionConfig;
pub use error::Error;
pub use file_id::FileId;
pub use session::Session;
pub use spotify_id::SpotifyId;

View file

@ -1,9 +1,10 @@
use std::collections::HashMap;
use std::future::Future;
use std::mem;
use std::pin::Pin;
use std::task::Context;
use std::task::Poll;
use std::{
collections::HashMap,
future::Future,
mem,
pin::Pin,
task::{Context, Poll},
};
use byteorder::{BigEndian, ByteOrder};
use bytes::Bytes;
@ -11,9 +12,7 @@ use futures_util::FutureExt;
use protobuf::Message;
use tokio::sync::{mpsc, oneshot};
use crate::packet::PacketType;
use crate::protocol;
use crate::util::SeqGenerator;
use crate::{packet::PacketType, protocol, util::SeqGenerator, Error};
mod types;
pub use self::types::*;
@ -33,18 +32,18 @@ component! {
pub struct MercuryPending {
parts: Vec<Vec<u8>>,
partial: Option<Vec<u8>>,
callback: Option<oneshot::Sender<Result<MercuryResponse, MercuryError>>>,
callback: Option<oneshot::Sender<Result<MercuryResponse, Error>>>,
}
pub struct MercuryFuture<T> {
receiver: oneshot::Receiver<Result<T, MercuryError>>,
receiver: oneshot::Receiver<Result<T, Error>>,
}
impl<T> Future for MercuryFuture<T> {
type Output = Result<T, MercuryError>;
type Output = Result<T, Error>;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
self.receiver.poll_unpin(cx).map_err(|_| MercuryError)?
self.receiver.poll_unpin(cx)?
}
}
@ -55,7 +54,7 @@ impl MercuryManager {
seq
}
fn request(&self, req: MercuryRequest) -> MercuryFuture<MercuryResponse> {
fn request(&self, req: MercuryRequest) -> Result<MercuryFuture<MercuryResponse>, Error> {
let (tx, rx) = oneshot::channel();
let pending = MercuryPending {
@ -72,13 +71,13 @@ impl MercuryManager {
});
let cmd = req.method.command();
let data = req.encode(&seq);
let data = req.encode(&seq)?;
self.session().send_packet(cmd, data);
MercuryFuture { receiver: rx }
self.session().send_packet(cmd, data)?;
Ok(MercuryFuture { receiver: rx })
}
pub fn get<T: Into<String>>(&self, uri: T) -> MercuryFuture<MercuryResponse> {
pub fn get<T: Into<String>>(&self, uri: T) -> Result<MercuryFuture<MercuryResponse>, Error> {
self.request(MercuryRequest {
method: MercuryMethod::Get,
uri: uri.into(),
@ -87,7 +86,11 @@ impl MercuryManager {
})
}
pub fn send<T: Into<String>>(&self, uri: T, data: Vec<u8>) -> MercuryFuture<MercuryResponse> {
pub fn send<T: Into<String>>(
&self,
uri: T,
data: Vec<u8>,
) -> Result<MercuryFuture<MercuryResponse>, Error> {
self.request(MercuryRequest {
method: MercuryMethod::Send,
uri: uri.into(),
@ -103,7 +106,7 @@ impl MercuryManager {
pub fn subscribe<T: Into<String>>(
&self,
uri: T,
) -> impl Future<Output = Result<mpsc::UnboundedReceiver<MercuryResponse>, MercuryError>> + 'static
) -> impl Future<Output = Result<mpsc::UnboundedReceiver<MercuryResponse>, Error>> + 'static
{
let uri = uri.into();
let request = self.request(MercuryRequest {
@ -115,7 +118,7 @@ impl MercuryManager {
let manager = self.clone();
async move {
let response = request.await?;
let response = request?.await?;
let (tx, rx) = mpsc::unbounded_channel();
@ -125,13 +128,18 @@ impl MercuryManager {
if !response.payload.is_empty() {
// Old subscription protocol, watch the provided list of URIs
for sub in response.payload {
let mut sub =
protocol::pubsub::Subscription::parse_from_bytes(&sub).unwrap();
let sub_uri = sub.take_uri();
match protocol::pubsub::Subscription::parse_from_bytes(&sub) {
Ok(mut sub) => {
let sub_uri = sub.take_uri();
debug!("subscribed sub_uri={}", sub_uri);
debug!("subscribed sub_uri={}", sub_uri);
inner.subscriptions.push((sub_uri, tx.clone()));
inner.subscriptions.push((sub_uri, tx.clone()));
}
Err(e) => {
error!("could not subscribe to {}: {}", uri, e);
}
}
}
} else {
// New subscription protocol, watch the requested URI
@ -144,7 +152,28 @@ impl MercuryManager {
}
}
pub(crate) fn dispatch(&self, cmd: PacketType, mut data: Bytes) {
pub fn listen_for<T: Into<String>>(
&self,
uri: T,
) -> impl Future<Output = mpsc::UnboundedReceiver<MercuryResponse>> + 'static {
let uri = uri.into();
let manager = self.clone();
async move {
let (tx, rx) = mpsc::unbounded_channel();
manager.lock(move |inner| {
if !inner.invalid {
debug!("listening to uri={}", uri);
inner.subscriptions.push((uri, tx));
}
});
rx
}
}
pub(crate) fn dispatch(&self, cmd: PacketType, mut data: Bytes) -> Result<(), Error> {
let seq_len = BigEndian::read_u16(data.split_to(2).as_ref()) as usize;
let seq = data.split_to(seq_len).as_ref().to_owned();
@ -164,7 +193,7 @@ impl MercuryManager {
}
} else {
warn!("Ignore seq {:?} cmd {:x}", seq, cmd as u8);
return;
return Err(MercuryError::Command(cmd).into());
}
}
};
@ -184,10 +213,12 @@ impl MercuryManager {
}
if flags == 0x1 {
self.complete_request(cmd, pending);
self.complete_request(cmd, pending)?;
} else {
self.lock(move |inner| inner.pending.insert(seq, pending));
}
Ok(())
}
fn parse_part(data: &mut Bytes) -> Vec<u8> {
@ -195,9 +226,9 @@ impl MercuryManager {
data.split_to(size).as_ref().to_owned()
}
fn complete_request(&self, cmd: PacketType, mut pending: MercuryPending) {
fn complete_request(&self, cmd: PacketType, mut pending: MercuryPending) -> Result<(), Error> {
let header_data = pending.parts.remove(0);
let header = protocol::mercury::Header::parse_from_bytes(&header_data).unwrap();
let header = protocol::mercury::Header::parse_from_bytes(&header_data)?;
let response = MercuryResponse {
uri: header.get_uri().to_string(),
@ -205,13 +236,17 @@ impl MercuryManager {
payload: pending.parts,
};
if response.status_code >= 500 {
panic!("Spotify servers returned an error. Restart librespot.");
} else if response.status_code >= 400 {
warn!("error {} for uri {}", response.status_code, &response.uri);
let status_code = response.status_code;
if status_code >= 500 {
error!("error {} for uri {}", status_code, &response.uri);
Err(MercuryError::Response(response).into())
} else if status_code >= 400 {
error!("error {} for uri {}", status_code, &response.uri);
if let Some(cb) = pending.callback {
let _ = cb.send(Err(MercuryError));
cb.send(Err(MercuryError::Response(response.clone()).into()))
.map_err(|_| MercuryError::Channel)?;
}
Err(MercuryError::Response(response).into())
} else if let PacketType::MercuryEvent = cmd {
self.lock(|inner| {
let mut found = false;
@ -221,7 +256,7 @@ impl MercuryManager {
// before sending while saving the subscription under its unencoded form.
let mut uri_split = response.uri.split('/');
let encoded_uri = std::iter::once(uri_split.next().unwrap().to_string())
let encoded_uri = std::iter::once(uri_split.next().unwrap_or_default().to_string())
.chain(uri_split.map(|component| {
form_urlencoded::byte_serialize(component.as_bytes()).collect::<String>()
}))
@ -242,11 +277,19 @@ impl MercuryManager {
});
if !found {
debug!("unknown subscription uri={}", response.uri);
debug!("unknown subscription uri={}", &response.uri);
trace!("response pushed over Mercury: {:?}", response);
Err(MercuryError::Response(response).into())
} else {
Ok(())
}
})
} else if let Some(cb) = pending.callback {
let _ = cb.send(Ok(response));
cb.send(Ok(response)).map_err(|_| MercuryError::Channel)?;
Ok(())
} else {
error!("can't handle Mercury response: {:?}", response);
Err(MercuryError::Response(response).into())
}
}

View file

@ -1,6 +1,8 @@
use std::collections::VecDeque;
use super::*;
use super::{MercuryFuture, MercuryManager, MercuryResponse};
use crate::Error;
pub struct MercurySender {
mercury: MercuryManager,
@ -23,12 +25,13 @@ impl MercurySender {
self.buffered_future.is_none() && self.pending.is_empty()
}
pub fn send(&mut self, item: Vec<u8>) {
let task = self.mercury.send(self.uri.clone(), item);
pub fn send(&mut self, item: Vec<u8>) -> Result<(), Error> {
let task = self.mercury.send(self.uri.clone(), item)?;
self.pending.push_back(task);
Ok(())
}
pub async fn flush(&mut self) -> Result<(), MercuryError> {
pub async fn flush(&mut self) -> Result<(), Error> {
if self.buffered_future.is_none() {
self.buffered_future = self.pending.pop_front();
}

View file

@ -1,9 +1,10 @@
use byteorder::{BigEndian, WriteBytesExt};
use protobuf::Message;
use std::io::Write;
use crate::packet::PacketType;
use crate::protocol;
use byteorder::{BigEndian, WriteBytesExt};
use protobuf::Message;
use thiserror::Error;
use crate::{packet::PacketType, protocol, Error};
#[derive(Debug, PartialEq, Eq)]
pub enum MercuryMethod {
@ -28,8 +29,25 @@ pub struct MercuryResponse {
pub payload: Vec<Vec<u8>>,
}
#[derive(Debug, Hash, PartialEq, Eq, Copy, Clone)]
pub struct MercuryError;
#[derive(Debug, Error)]
pub enum MercuryError {
#[error("callback receiver was disconnected")]
Channel,
#[error("error handling packet type: {0:?}")]
Command(PacketType),
#[error("error handling Mercury response: {0:?}")]
Response(MercuryResponse),
}
impl From<MercuryError> for Error {
fn from(err: MercuryError) -> Self {
match err {
MercuryError::Channel => Error::aborted(err),
MercuryError::Command(_) => Error::unimplemented(err),
MercuryError::Response(_) => Error::unavailable(err),
}
}
}
impl ToString for MercuryMethod {
fn to_string(&self) -> String {
@ -55,14 +73,12 @@ impl MercuryMethod {
}
impl MercuryRequest {
pub fn encode(&self, seq: &[u8]) -> Vec<u8> {
pub fn encode(&self, seq: &[u8]) -> Result<Vec<u8>, Error> {
let mut packet = Vec::new();
packet.write_u16::<BigEndian>(seq.len() as u16).unwrap();
packet.write_all(seq).unwrap();
packet.write_u8(1).unwrap(); // Flags: FINAL
packet
.write_u16::<BigEndian>(1 + self.payload.len() as u16)
.unwrap(); // Part count
packet.write_u16::<BigEndian>(seq.len() as u16)?;
packet.write_all(seq)?;
packet.write_u8(1)?; // Flags: FINAL
packet.write_u16::<BigEndian>(1 + self.payload.len() as u16)?; // Part count
let mut header = protocol::mercury::Header::new();
header.set_uri(self.uri.clone());
@ -72,16 +88,14 @@ impl MercuryRequest {
header.set_content_type(content_type.clone());
}
packet
.write_u16::<BigEndian>(header.compute_size() as u16)
.unwrap();
header.write_to_writer(&mut packet).unwrap();
packet.write_u16::<BigEndian>(header.compute_size() as u16)?;
header.write_to_writer(&mut packet)?;
for p in &self.payload {
packet.write_u16::<BigEndian>(p.len() as u16).unwrap();
packet.write_all(p).unwrap();
packet.write_u16::<BigEndian>(p.len() as u16)?;
packet.write_all(p)?;
}
packet
Ok(packet)
}
}

View file

@ -2,7 +2,7 @@
use num_derive::{FromPrimitive, ToPrimitive};
#[derive(Debug, FromPrimitive, ToPrimitive)]
#[derive(Debug, Copy, Clone, FromPrimitive, ToPrimitive)]
pub enum PacketType {
SecretBlock = 0x02,
Ping = 0x04,

View file

@ -1,11 +1,16 @@
use std::future::Future;
use std::io;
use std::pin::Pin;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, RwLock, Weak};
use std::task::Context;
use std::task::Poll;
use std::time::{SystemTime, UNIX_EPOCH};
use std::{
collections::HashMap,
future::Future,
io,
pin::Pin,
process::exit,
sync::{
atomic::{AtomicUsize, Ordering},
Arc, Weak,
},
task::{Context, Poll},
time::{SystemTime, UNIX_EPOCH},
};
use byteorder::{BigEndian, ByteOrder};
use bytes::Bytes;
@ -13,21 +18,27 @@ use futures_core::TryStream;
use futures_util::{future, ready, StreamExt, TryStreamExt};
use num_traits::FromPrimitive;
use once_cell::sync::OnceCell;
use parking_lot::RwLock;
use quick_xml::events::Event;
use thiserror::Error;
use tokio::sync::mpsc;
use tokio_stream::wrappers::UnboundedReceiverStream;
use crate::apresolve::ApResolver;
use crate::audio_key::AudioKeyManager;
use crate::authentication::Credentials;
use crate::cache::Cache;
use crate::channel::ChannelManager;
use crate::config::SessionConfig;
use crate::connection::{self, AuthenticationError};
use crate::http_client::HttpClient;
use crate::mercury::MercuryManager;
use crate::packet::PacketType;
use crate::token::TokenProvider;
use crate::{
apresolve::ApResolver,
audio_key::AudioKeyManager,
authentication::Credentials,
cache::Cache,
channel::ChannelManager,
config::SessionConfig,
connection::{self, AuthenticationError},
http_client::HttpClient,
mercury::MercuryManager,
packet::PacketType,
spclient::SpClient,
token::TokenProvider,
Error,
};
#[derive(Debug, Error)]
pub enum SessionError {
@ -35,13 +46,35 @@ pub enum SessionError {
AuthenticationError(#[from] AuthenticationError),
#[error("Cannot create session: {0}")]
IoError(#[from] io::Error),
#[error("packet {0} unknown")]
Packet(u8),
}
impl From<SessionError> for Error {
fn from(err: SessionError) -> Self {
match err {
SessionError::AuthenticationError(_) => Error::unauthenticated(err),
SessionError::IoError(_) => Error::unavailable(err),
SessionError::Packet(_) => Error::unimplemented(err),
}
}
}
pub type UserAttributes = HashMap<String, String>;
#[derive(Debug, Clone, Default)]
pub struct UserData {
pub country: String,
pub canonical_username: String,
pub attributes: UserAttributes,
}
#[derive(Debug, Clone, Default)]
struct SessionData {
country: String,
connection_id: String,
time_delta: i64,
canonical_username: String,
invalid: bool,
user_data: UserData,
}
struct SessionInternal {
@ -55,6 +88,7 @@ struct SessionInternal {
audio_key: OnceCell<AudioKeyManager>,
channel: OnceCell<ChannelManager>,
mercury: OnceCell<MercuryManager>,
spclient: OnceCell<SpClient>,
token_provider: OnceCell<TokenProvider>,
cache: Option<Arc<Cache>>,
@ -73,7 +107,7 @@ impl Session {
config: SessionConfig,
credentials: Credentials,
cache: Option<Cache>,
) -> Result<Session, SessionError> {
) -> Result<Session, Error> {
let http_client = HttpClient::new(config.proxy.as_ref());
let (sender_tx, sender_rx) = mpsc::unbounded_channel();
let session_id = SESSION_COUNTER.fetch_add(1, Ordering::Relaxed);
@ -82,12 +116,7 @@ impl Session {
let session = Session(Arc::new(SessionInternal {
config,
data: RwLock::new(SessionData {
country: String::new(),
canonical_username: String::new(),
invalid: false,
time_delta: 0,
}),
data: RwLock::new(SessionData::default()),
http_client,
tx_connection: sender_tx,
cache: cache.map(Arc::new),
@ -95,6 +124,7 @@ impl Session {
audio_key: OnceCell::new(),
channel: OnceCell::new(),
mercury: OnceCell::new(),
spclient: OnceCell::new(),
token_provider: OnceCell::new(),
handle: tokio::runtime::Handle::current(),
session_id,
@ -109,7 +139,7 @@ impl Session {
connection::authenticate(&mut transport, credentials, &session.config().device_id)
.await?;
info!("Authenticated as \"{}\" !", reusable_credentials.username);
session.0.data.write().unwrap().canonical_username = reusable_credentials.username.clone();
session.0.data.write().user_data.canonical_username = reusable_credentials.username.clone();
if let Some(cache) = session.cache() {
cache.save_credentials(&reusable_credentials);
}
@ -159,6 +189,10 @@ impl Session {
.get_or_init(|| MercuryManager::new(self.weak()))
}
pub fn spclient(&self) -> &SpClient {
self.0.spclient.get_or_init(|| SpClient::new(self.weak()))
}
pub fn token_provider(&self) -> &TokenProvider {
self.0
.token_provider
@ -166,7 +200,7 @@ impl Session {
}
pub fn time_delta(&self) -> i64 {
self.0.data.read().unwrap().time_delta
self.0.data.read().time_delta
}
pub fn spawn<T>(&self, task: T)
@ -186,9 +220,30 @@ impl Session {
);
}
fn dispatch(&self, cmd: u8, data: Bytes) {
fn check_catalogue(attributes: &UserAttributes) {
if let Some(account_type) = attributes.get("type") {
if account_type != "premium" {
error!("librespot does not support {:?} accounts.", account_type);
info!("Please support Spotify and your artists and sign up for a premium account.");
// TODO: logout instead of exiting
exit(1);
}
}
}
fn dispatch(&self, cmd: u8, data: Bytes) -> Result<(), Error> {
use PacketType::*;
let packet_type = FromPrimitive::from_u8(cmd);
let cmd = match packet_type {
Some(cmd) => cmd,
None => {
trace!("Ignoring unknown packet {:x}", cmd);
return Err(SessionError::Packet(cmd).into());
}
};
match packet_type {
Some(Ping) => {
let server_timestamp = BigEndian::read_u32(data.as_ref()) as i64;
@ -198,43 +253,77 @@ impl Session {
}
.as_secs() as i64;
self.0.data.write().unwrap().time_delta = server_timestamp - timestamp;
self.0.data.write().time_delta = server_timestamp - timestamp;
self.debug_info();
self.send_packet(Pong, vec![0, 0, 0, 0]);
self.send_packet(Pong, vec![0, 0, 0, 0])
}
Some(CountryCode) => {
let country = String::from_utf8(data.as_ref().to_owned()).unwrap();
let country = String::from_utf8(data.as_ref().to_owned())?;
info!("Country: {:?}", country);
self.0.data.write().unwrap().country = country;
}
Some(StreamChunkRes) | Some(ChannelError) => {
self.channel().dispatch(packet_type.unwrap(), data);
}
Some(AesKey) | Some(AesKeyError) => {
self.audio_key().dispatch(packet_type.unwrap(), data);
self.0.data.write().user_data.country = country;
Ok(())
}
Some(StreamChunkRes) | Some(ChannelError) => self.channel().dispatch(cmd, data),
Some(AesKey) | Some(AesKeyError) => self.audio_key().dispatch(cmd, data),
Some(MercuryReq) | Some(MercurySub) | Some(MercuryUnsub) | Some(MercuryEvent) => {
self.mercury().dispatch(packet_type.unwrap(), data);
self.mercury().dispatch(cmd, data)
}
Some(ProductInfo) => {
let data = std::str::from_utf8(&data)?;
let mut reader = quick_xml::Reader::from_str(data);
let mut buf = Vec::new();
let mut current_element = String::new();
let mut user_attributes: UserAttributes = HashMap::new();
loop {
match reader.read_event(&mut buf) {
Ok(Event::Start(ref element)) => {
current_element = std::str::from_utf8(element.name())?.to_owned()
}
Ok(Event::End(_)) => {
current_element = String::new();
}
Ok(Event::Text(ref value)) => {
if !current_element.is_empty() {
let _ = user_attributes.insert(
current_element.clone(),
value.unescape_and_decode(&reader)?,
);
}
}
Ok(Event::Eof) => break,
Ok(_) => (),
Err(e) => error!(
"Error parsing XML at position {}: {:?}",
reader.buffer_position(),
e
),
}
}
trace!("Received product info: {:#?}", user_attributes);
Self::check_catalogue(&user_attributes);
self.0.data.write().user_data.attributes = user_attributes;
Ok(())
}
Some(PongAck)
| Some(SecretBlock)
| Some(LegacyWelcome)
| Some(UnknownDataAllZeros)
| Some(ProductInfo)
| Some(LicenseVersion) => {}
| Some(LicenseVersion) => Ok(()),
_ => {
if let Some(packet_type) = PacketType::from_u8(cmd) {
trace!("Ignoring {:?} packet with data {:?}", packet_type, data);
} else {
trace!("Ignoring unknown packet {:x}", cmd);
}
trace!("Ignoring {:?} packet with data {:#?}", cmd, data);
Err(SessionError::Packet(cmd as u8).into())
}
}
}
pub fn send_packet(&self, cmd: PacketType, data: Vec<u8>) {
self.0.tx_connection.send((cmd as u8, data)).unwrap();
pub fn send_packet(&self, cmd: PacketType, data: Vec<u8>) -> Result<(), Error> {
self.0.tx_connection.send((cmd as u8, data))?;
Ok(())
}
pub fn cache(&self) -> Option<&Arc<Cache>> {
@ -245,18 +334,45 @@ impl Session {
&self.0.config
}
pub fn username(&self) -> String {
self.0.data.read().unwrap().canonical_username.clone()
}
pub fn country(&self) -> String {
self.0.data.read().unwrap().country.clone()
pub fn user_data(&self) -> UserData {
self.0.data.read().user_data.clone()
}
pub fn device_id(&self) -> &str {
&self.config().device_id
}
pub fn connection_id(&self) -> String {
self.0.data.read().connection_id.clone()
}
pub fn set_connection_id(&self, connection_id: String) {
self.0.data.write().connection_id = connection_id;
}
pub fn username(&self) -> String {
self.0.data.read().user_data.canonical_username.clone()
}
pub fn set_user_attribute(&self, key: &str, value: &str) -> Option<String> {
let mut dummy_attributes = UserAttributes::new();
dummy_attributes.insert(key.to_owned(), value.to_owned());
Self::check_catalogue(&dummy_attributes);
self.0
.data
.write()
.user_data
.attributes
.insert(key.to_owned(), value.to_owned())
}
pub fn set_user_attributes(&self, attributes: UserAttributes) {
Self::check_catalogue(&attributes);
self.0.data.write().user_data.attributes.extend(attributes)
}
fn weak(&self) -> SessionWeak {
SessionWeak(Arc::downgrade(&self.0))
}
@ -266,14 +382,14 @@ impl Session {
}
pub fn shutdown(&self) {
debug!("Invalidating session[{}]", self.0.session_id);
self.0.data.write().unwrap().invalid = true;
debug!("Invalidating session [{}]", self.0.session_id);
self.0.data.write().invalid = true;
self.mercury().shutdown();
self.channel().shutdown();
}
pub fn is_invalid(&self) -> bool {
self.0.data.read().unwrap().invalid
self.0.data.read().invalid
}
}
@ -286,7 +402,8 @@ impl SessionWeak {
}
pub(crate) fn upgrade(&self) -> Session {
self.try_upgrade().expect("Session died")
self.try_upgrade()
.expect("session was dropped and so should have this component")
}
}
@ -327,7 +444,9 @@ where
}
};
session.dispatch(cmd, data);
if let Err(e) = session.dispatch(cmd, data) {
error!("could not dispatch command: {}", e);
}
}
}
}

View file

@ -1,5 +1,4 @@
use std::io;
use std::net::ToSocketAddrs;
use std::{io, net::ToSocketAddrs};
use tokio::net::TcpStream;
use url::Url;

View file

@ -1 +1,293 @@
// https://github.com/librespot-org/librespot-java/blob/27783e06f456f95228c5ac37acf2bff8c1a8a0c4/lib/src/main/java/xyz/gianlu/librespot/dealer/ApiClient.java
use std::time::Duration;
use bytes::Bytes;
use futures_util::future::IntoStream;
use http::header::HeaderValue;
use hyper::{
client::ResponseFuture,
header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE, RANGE},
Body, HeaderMap, Method, Request,
};
use protobuf::Message;
use rand::Rng;
use crate::{
apresolve::SocketAddress,
cdn_url::CdnUrl,
error::ErrorKind,
protocol::{
canvaz::EntityCanvazRequest, connect::PutStateRequest,
extended_metadata::BatchedEntityRequest,
},
Error, FileId, SpotifyId,
};
component! {
SpClient : SpClientInner {
accesspoint: Option<SocketAddress> = None,
strategy: RequestStrategy = RequestStrategy::default(),
}
}
pub type SpClientResult = Result<Bytes, Error>;
#[derive(Copy, Clone, Debug)]
pub enum RequestStrategy {
TryTimes(usize),
Infinitely,
}
impl Default for RequestStrategy {
fn default() -> Self {
RequestStrategy::TryTimes(10)
}
}
impl SpClient {
pub fn set_strategy(&self, strategy: RequestStrategy) {
self.lock(|inner| inner.strategy = strategy)
}
pub async fn flush_accesspoint(&self) {
self.lock(|inner| inner.accesspoint = None)
}
pub async fn get_accesspoint(&self) -> SocketAddress {
// Memoize the current access point.
let ap = self.lock(|inner| inner.accesspoint.clone());
match ap {
Some(tuple) => tuple,
None => {
let tuple = self.session().apresolver().resolve("spclient").await;
self.lock(|inner| inner.accesspoint = Some(tuple.clone()));
info!(
"Resolved \"{}:{}\" as spclient access point",
tuple.0, tuple.1
);
tuple
}
}
}
pub async fn base_url(&self) -> String {
let ap = self.get_accesspoint().await;
format!("https://{}:{}", ap.0, ap.1)
}
pub async fn request_with_protobuf(
&self,
method: &Method,
endpoint: &str,
headers: Option<HeaderMap>,
message: &dyn Message,
) -> SpClientResult {
let body = protobuf::text_format::print_to_string(message);
let mut headers = headers.unwrap_or_else(HeaderMap::new);
headers.insert(CONTENT_TYPE, "application/protobuf".parse()?);
self.request(method, endpoint, Some(headers), Some(body))
.await
}
pub async fn request_as_json(
&self,
method: &Method,
endpoint: &str,
headers: Option<HeaderMap>,
body: Option<String>,
) -> SpClientResult {
let mut headers = headers.unwrap_or_else(HeaderMap::new);
headers.insert(ACCEPT, "application/json".parse()?);
self.request(method, endpoint, Some(headers), body).await
}
pub async fn request(
&self,
method: &Method,
endpoint: &str,
headers: Option<HeaderMap>,
body: Option<String>,
) -> SpClientResult {
let mut tries: usize = 0;
let mut last_response;
let body = body.unwrap_or_else(String::new);
loop {
tries += 1;
// Reconnection logic: retrieve the endpoint every iteration, so we can try
// another access point when we are experiencing network issues (see below).
let mut url = self.base_url().await;
url.push_str(endpoint);
let mut request = Request::builder()
.method(method)
.uri(url)
.body(Body::from(body.clone()))?;
// Reconnection logic: keep getting (cached) tokens because they might have expired.
let headers_mut = request.headers_mut();
if let Some(ref hdrs) = headers {
*headers_mut = hdrs.clone();
}
headers_mut.insert(
AUTHORIZATION,
HeaderValue::from_str(&format!(
"Bearer {}",
self.session()
.token_provider()
.get_token("playlist-read")
.await?
.access_token
))?,
);
last_response = self.session().http_client().request_body(request).await;
if last_response.is_ok() {
return last_response;
}
// Break before the reconnection logic below, so that the current access point
// is retained when max_tries == 1. Leave it up to the caller when to flush.
if let RequestStrategy::TryTimes(max_tries) = self.lock(|inner| inner.strategy) {
if tries >= max_tries {
break;
}
}
// Reconnection logic: drop the current access point if we are experiencing issues.
// This will cause the next call to base_url() to resolve a new one.
if let Err(ref network_error) = last_response {
match network_error.kind {
ErrorKind::Unavailable | ErrorKind::DeadlineExceeded => {
// Keep trying the current access point three times before dropping it.
if tries % 3 == 0 {
self.flush_accesspoint().await
}
}
_ => break, // if we can't build the request now, then we won't ever
}
}
// When retrying, avoid hammering the Spotify infrastructure by sleeping a while.
// The backoff time is chosen randomly from an ever-increasing range.
let max_seconds = u64::pow(tries as u64, 2) * 3;
let backoff = Duration::from_secs(rand::thread_rng().gen_range(1..=max_seconds));
warn!(
"Unable to complete API request, waiting {} seconds before retrying...",
backoff.as_secs(),
);
debug!("Error was: {:?}", last_response);
tokio::time::sleep(backoff).await;
}
last_response
}
pub async fn put_connect_state(
&self,
connection_id: String,
state: PutStateRequest,
) -> SpClientResult {
let endpoint = format!("/connect-state/v1/devices/{}", self.session().device_id());
let mut headers = HeaderMap::new();
headers.insert("X-Spotify-Connection-Id", connection_id.parse()?);
self.request_with_protobuf(&Method::PUT, &endpoint, Some(headers), &state)
.await
}
pub async fn get_metadata(&self, scope: &str, id: SpotifyId) -> SpClientResult {
let endpoint = format!("/metadata/4/{}/{}", scope, id.to_base16());
self.request(&Method::GET, &endpoint, None, None).await
}
pub async fn get_track_metadata(&self, track_id: SpotifyId) -> SpClientResult {
self.get_metadata("track", track_id).await
}
pub async fn get_episode_metadata(&self, episode_id: SpotifyId) -> SpClientResult {
self.get_metadata("episode", episode_id).await
}
pub async fn get_album_metadata(&self, album_id: SpotifyId) -> SpClientResult {
self.get_metadata("album", album_id).await
}
pub async fn get_artist_metadata(&self, artist_id: SpotifyId) -> SpClientResult {
self.get_metadata("artist", artist_id).await
}
pub async fn get_show_metadata(&self, show_id: SpotifyId) -> SpClientResult {
self.get_metadata("show", show_id).await
}
pub async fn get_lyrics(&self, track_id: SpotifyId) -> SpClientResult {
let endpoint = format!("/color-lyrics/v1/track/{}", track_id.to_base62());
self.request_as_json(&Method::GET, &endpoint, None, None)
.await
}
pub async fn get_lyrics_for_image(
&self,
track_id: SpotifyId,
image_id: FileId,
) -> SpClientResult {
let endpoint = format!(
"/color-lyrics/v2/track/{}/image/spotify:image:{}",
track_id.to_base62(),
image_id
);
self.request_as_json(&Method::GET, &endpoint, None, None)
.await
}
// TODO: Find endpoint for newer canvas.proto and upgrade to that.
pub async fn get_canvases(&self, request: EntityCanvazRequest) -> SpClientResult {
let endpoint = "/canvaz-cache/v0/canvases";
self.request_with_protobuf(&Method::POST, endpoint, None, &request)
.await
}
pub async fn get_extended_metadata(&self, request: BatchedEntityRequest) -> SpClientResult {
let endpoint = "/extended-metadata/v0/extended-metadata";
self.request_with_protobuf(&Method::POST, endpoint, None, &request)
.await
}
pub async fn get_audio_storage(&self, file_id: FileId) -> SpClientResult {
let endpoint = format!(
"/storage-resolve/files/audio/interactive/{}",
file_id.to_base16()
);
self.request(&Method::GET, &endpoint, None, None).await
}
pub fn stream_from_cdn(
&self,
cdn_url: &CdnUrl,
offset: usize,
length: usize,
) -> Result<IntoStream<ResponseFuture>, Error> {
let url = cdn_url.try_get_url()?;
let req = Request::builder()
.method(&Method::GET)
.uri(url)
.header(
RANGE,
HeaderValue::from_str(&format!("bytes={}-{}", offset, offset + length - 1))?,
)
.body(Body::empty())?;
let stream = self.session().http_client().request_stream(req)?;
Ok(stream)
}
}

View file

@ -1,43 +1,81 @@
#![allow(clippy::wrong_self_convention)]
use std::{
convert::{TryFrom, TryInto},
fmt,
ops::Deref,
};
use std::convert::TryInto;
use std::fmt;
use thiserror::Error;
use crate::Error;
use librespot_protocol as protocol;
// re-export FileId for historic reasons, when it was part of this mod
pub use crate::FileId;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SpotifyAudioType {
pub enum SpotifyItemType {
Album,
Artist,
Episode,
Playlist,
Show,
Track,
Podcast,
NonPlayable,
Unknown,
}
impl From<&str> for SpotifyAudioType {
impl From<&str> for SpotifyItemType {
fn from(v: &str) -> Self {
match v {
"track" => SpotifyAudioType::Track,
"episode" => SpotifyAudioType::Podcast,
_ => SpotifyAudioType::NonPlayable,
"album" => Self::Album,
"artist" => Self::Artist,
"episode" => Self::Episode,
"playlist" => Self::Playlist,
"show" => Self::Show,
"track" => Self::Track,
_ => Self::Unknown,
}
}
}
impl From<SpotifyAudioType> for &str {
fn from(audio_type: SpotifyAudioType) -> &'static str {
match audio_type {
SpotifyAudioType::Track => "track",
SpotifyAudioType::Podcast => "episode",
SpotifyAudioType::NonPlayable => "unknown",
impl From<SpotifyItemType> for &str {
fn from(item_type: SpotifyItemType) -> &'static str {
match item_type {
SpotifyItemType::Album => "album",
SpotifyItemType::Artist => "artist",
SpotifyItemType::Episode => "episode",
SpotifyItemType::Playlist => "playlist",
SpotifyItemType::Show => "show",
SpotifyItemType::Track => "track",
_ => "unknown",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
pub struct SpotifyId {
pub id: u128,
pub audio_type: SpotifyAudioType,
pub item_type: SpotifyItemType,
}
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
pub struct SpotifyIdError;
#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)]
pub enum SpotifyIdError {
#[error("ID cannot be parsed")]
InvalidId,
#[error("not a valid Spotify URI")]
InvalidFormat,
#[error("URI does not belong to Spotify")]
InvalidRoot,
}
impl From<SpotifyIdError> for Error {
fn from(err: SpotifyIdError) -> Self {
Error::invalid_argument(err)
}
}
pub type SpotifyIdResult = Result<SpotifyId, Error>;
pub type NamedSpotifyIdResult = Result<NamedSpotifyId, Error>;
const BASE62_DIGITS: &[u8; 62] = b"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
const BASE16_DIGITS: &[u8; 16] = b"0123456789abcdef";
@ -47,11 +85,12 @@ impl SpotifyId {
const SIZE_BASE16: usize = 32;
const SIZE_BASE62: usize = 22;
fn track(n: u128) -> SpotifyId {
SpotifyId {
id: n,
audio_type: SpotifyAudioType::Track,
}
/// Returns whether this `SpotifyId` is for a playable audio item, if known.
pub fn is_playable(&self) -> bool {
return matches!(
self.item_type,
SpotifyItemType::Episode | SpotifyItemType::Track
);
}
/// Parses a base16 (hex) encoded [Spotify ID] into a `SpotifyId`.
@ -59,29 +98,32 @@ impl SpotifyId {
/// `src` is expected to be 32 bytes long and encoded using valid characters.
///
/// [Spotify ID]: https://developer.spotify.com/documentation/web-api/#spotify-uris-and-ids
pub fn from_base16(src: &str) -> Result<SpotifyId, SpotifyIdError> {
pub fn from_base16(src: &str) -> SpotifyIdResult {
let mut dst: u128 = 0;
for c in src.as_bytes() {
let p = match c {
b'0'..=b'9' => c - b'0',
b'a'..=b'f' => c - b'a' + 10,
_ => return Err(SpotifyIdError),
_ => return Err(SpotifyIdError::InvalidId.into()),
} as u128;
dst <<= 4;
dst += p;
}
Ok(SpotifyId::track(dst))
Ok(Self {
id: dst,
item_type: SpotifyItemType::Unknown,
})
}
/// Parses a base62 encoded [Spotify ID] into a `SpotifyId`.
/// Parses a base62 encoded [Spotify ID] into a `u128`.
///
/// `src` is expected to be 22 bytes long and encoded using valid characters.
///
/// [Spotify ID]: https://developer.spotify.com/documentation/web-api/#spotify-uris-and-ids
pub fn from_base62(src: &str) -> Result<SpotifyId, SpotifyIdError> {
pub fn from_base62(src: &str) -> SpotifyIdResult {
let mut dst: u128 = 0;
for c in src.as_bytes() {
@ -89,23 +131,29 @@ impl SpotifyId {
b'0'..=b'9' => c - b'0',
b'a'..=b'z' => c - b'a' + 10,
b'A'..=b'Z' => c - b'A' + 36,
_ => return Err(SpotifyIdError),
_ => return Err(SpotifyIdError::InvalidId.into()),
} as u128;
dst *= 62;
dst += p;
}
Ok(SpotifyId::track(dst))
Ok(Self {
id: dst,
item_type: SpotifyItemType::Unknown,
})
}
/// Creates a `SpotifyId` from a copy of `SpotifyId::SIZE` (16) bytes in big-endian order.
/// Creates a `u128` from a copy of `SpotifyId::SIZE` (16) bytes in big-endian order.
///
/// The resulting `SpotifyId` will default to a `SpotifyAudioType::TRACK`.
pub fn from_raw(src: &[u8]) -> Result<SpotifyId, SpotifyIdError> {
/// The resulting `SpotifyId` will default to a `SpotifyItemType::Unknown`.
pub fn from_raw(src: &[u8]) -> SpotifyIdResult {
match src.try_into() {
Ok(dst) => Ok(SpotifyId::track(u128::from_be_bytes(dst))),
Err(_) => Err(SpotifyIdError),
Ok(dst) => Ok(Self {
id: u128::from_be_bytes(dst),
item_type: SpotifyItemType::Unknown,
}),
Err(_) => Err(SpotifyIdError::InvalidId.into()),
}
}
@ -114,30 +162,37 @@ impl SpotifyId {
/// `uri` is expected to be in the canonical form `spotify:{type}:{id}`, where `{type}`
/// can be arbitrary while `{id}` is a 22-character long, base62 encoded Spotify ID.
///
/// Note that this should not be used for playlists, which have the form of
/// `spotify:user:{owner_username}:playlist:{id}`.
///
/// [Spotify URI]: https://developer.spotify.com/documentation/web-api/#spotify-uris-and-ids
pub fn from_uri(src: &str) -> Result<SpotifyId, SpotifyIdError> {
let src = src.strip_prefix("spotify:").ok_or(SpotifyIdError)?;
pub fn from_uri(src: &str) -> SpotifyIdResult {
let mut uri_parts: Vec<&str> = src.split(':').collect();
if src.len() <= SpotifyId::SIZE_BASE62 {
return Err(SpotifyIdError);
// At minimum, should be `spotify:{type}:{id}`
if uri_parts.len() < 3 {
return Err(SpotifyIdError::InvalidFormat.into());
}
let colon_index = src.len() - SpotifyId::SIZE_BASE62 - 1;
if src.as_bytes()[colon_index] != b':' {
return Err(SpotifyIdError);
if uri_parts[0] != "spotify" {
return Err(SpotifyIdError::InvalidRoot.into());
}
let mut id = SpotifyId::from_base62(&src[colon_index + 1..])?;
id.audio_type = src[..colon_index].into();
let id = uri_parts.pop().unwrap_or_default();
if id.len() != Self::SIZE_BASE62 {
return Err(SpotifyIdError::InvalidId.into());
}
Ok(id)
Ok(Self {
item_type: uri_parts.pop().unwrap_or_default().into(),
..Self::from_base62(id)?
})
}
/// Returns the `SpotifyId` as a base16 (hex) encoded, `SpotifyId::SIZE_BASE16` (32)
/// character long `String`.
pub fn to_base16(&self) -> String {
to_base16(&self.to_raw(), &mut [0u8; SpotifyId::SIZE_BASE16])
to_base16(&self.to_raw(), &mut [0u8; Self::SIZE_BASE16])
}
/// Returns the `SpotifyId` as a [canonically] base62 encoded, `SpotifyId::SIZE_BASE62` (22)
@ -190,7 +245,7 @@ impl SpotifyId {
/// Returns a copy of the `SpotifyId` as an array of `SpotifyId::SIZE` (16) bytes in
/// big-endian order.
pub fn to_raw(&self) -> [u8; SpotifyId::SIZE] {
pub fn to_raw(&self) -> [u8; Self::SIZE] {
self.id.to_be_bytes()
}
@ -204,11 +259,11 @@ impl SpotifyId {
/// [Spotify URI]: https://developer.spotify.com/documentation/web-api/#spotify-uris-and-ids
pub fn to_uri(&self) -> String {
// 8 chars for the "spotify:" prefix + 1 colon + 22 chars base62 encoded ID = 31
// + unknown size audio_type.
let audio_type: &str = self.audio_type.into();
let mut dst = String::with_capacity(31 + audio_type.len());
// + unknown size item_type.
let item_type: &str = self.item_type.into();
let mut dst = String::with_capacity(31 + item_type.len());
dst.push_str("spotify:");
dst.push_str(audio_type);
dst.push_str(item_type);
dst.push(':');
dst.push_str(&self.to_base62());
@ -216,29 +271,231 @@ impl SpotifyId {
}
}
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct FileId(pub [u8; 20]);
impl FileId {
pub fn to_base16(&self) -> String {
to_base16(&self.0, &mut [0u8; 40])
}
}
impl fmt::Debug for FileId {
impl fmt::Debug for SpotifyId {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.debug_tuple("FileId").field(&self.to_base16()).finish()
f.debug_tuple("SpotifyId").field(&self.to_uri()).finish()
}
}
impl fmt::Display for FileId {
impl fmt::Display for SpotifyId {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str(&self.to_base16())
f.write_str(&self.to_uri())
}
}
#[inline]
fn to_base16(src: &[u8], buf: &mut [u8]) -> String {
#[derive(Clone, PartialEq, Eq, Hash)]
pub struct NamedSpotifyId {
pub inner_id: SpotifyId,
pub username: String,
}
impl NamedSpotifyId {
pub fn from_uri(src: &str) -> NamedSpotifyIdResult {
let uri_parts: Vec<&str> = src.split(':').collect();
// At minimum, should be `spotify:user:{username}:{type}:{id}`
if uri_parts.len() < 5 {
return Err(SpotifyIdError::InvalidFormat.into());
}
if uri_parts[0] != "spotify" {
return Err(SpotifyIdError::InvalidRoot.into());
}
if uri_parts[1] != "user" {
return Err(SpotifyIdError::InvalidFormat.into());
}
Ok(Self {
inner_id: SpotifyId::from_uri(src)?,
username: uri_parts[2].to_owned(),
})
}
pub fn to_uri(&self) -> String {
let item_type: &str = self.inner_id.item_type.into();
let mut dst = String::with_capacity(37 + self.username.len() + item_type.len());
dst.push_str("spotify:user:");
dst.push_str(&self.username);
dst.push_str(item_type);
dst.push(':');
dst.push_str(&self.to_base62());
dst
}
pub fn from_spotify_id(id: SpotifyId, username: String) -> Self {
Self {
inner_id: id,
username,
}
}
}
impl Deref for NamedSpotifyId {
type Target = SpotifyId;
fn deref(&self) -> &Self::Target {
&self.inner_id
}
}
impl fmt::Debug for NamedSpotifyId {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.debug_tuple("NamedSpotifyId")
.field(&self.inner_id.to_uri())
.finish()
}
}
impl fmt::Display for NamedSpotifyId {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str(&self.inner_id.to_uri())
}
}
impl TryFrom<&[u8]> for SpotifyId {
type Error = crate::Error;
fn try_from(src: &[u8]) -> Result<Self, Self::Error> {
Self::from_raw(src)
}
}
impl TryFrom<&str> for SpotifyId {
type Error = crate::Error;
fn try_from(src: &str) -> Result<Self, Self::Error> {
Self::from_base62(src)
}
}
impl TryFrom<String> for SpotifyId {
type Error = crate::Error;
fn try_from(src: String) -> Result<Self, Self::Error> {
Self::try_from(src.as_str())
}
}
impl TryFrom<&Vec<u8>> for SpotifyId {
type Error = crate::Error;
fn try_from(src: &Vec<u8>) -> Result<Self, Self::Error> {
Self::try_from(src.as_slice())
}
}
impl TryFrom<&protocol::spirc::TrackRef> for SpotifyId {
type Error = crate::Error;
fn try_from(track: &protocol::spirc::TrackRef) -> Result<Self, Self::Error> {
match SpotifyId::from_raw(track.get_gid()) {
Ok(mut id) => {
id.item_type = SpotifyItemType::Track;
Ok(id)
}
Err(_) => SpotifyId::from_uri(track.get_uri()),
}
}
}
impl TryFrom<&protocol::metadata::Album> for SpotifyId {
type Error = crate::Error;
fn try_from(album: &protocol::metadata::Album) -> Result<Self, Self::Error> {
Ok(Self {
item_type: SpotifyItemType::Album,
..Self::from_raw(album.get_gid())?
})
}
}
impl TryFrom<&protocol::metadata::Artist> for SpotifyId {
type Error = crate::Error;
fn try_from(artist: &protocol::metadata::Artist) -> Result<Self, Self::Error> {
Ok(Self {
item_type: SpotifyItemType::Artist,
..Self::from_raw(artist.get_gid())?
})
}
}
impl TryFrom<&protocol::metadata::Episode> for SpotifyId {
type Error = crate::Error;
fn try_from(episode: &protocol::metadata::Episode) -> Result<Self, Self::Error> {
Ok(Self {
item_type: SpotifyItemType::Episode,
..Self::from_raw(episode.get_gid())?
})
}
}
impl TryFrom<&protocol::metadata::Track> for SpotifyId {
type Error = crate::Error;
fn try_from(track: &protocol::metadata::Track) -> Result<Self, Self::Error> {
Ok(Self {
item_type: SpotifyItemType::Track,
..Self::from_raw(track.get_gid())?
})
}
}
impl TryFrom<&protocol::metadata::Show> for SpotifyId {
type Error = crate::Error;
fn try_from(show: &protocol::metadata::Show) -> Result<Self, Self::Error> {
Ok(Self {
item_type: SpotifyItemType::Show,
..Self::from_raw(show.get_gid())?
})
}
}
impl TryFrom<&protocol::metadata::ArtistWithRole> for SpotifyId {
type Error = crate::Error;
fn try_from(artist: &protocol::metadata::ArtistWithRole) -> Result<Self, Self::Error> {
Ok(Self {
item_type: SpotifyItemType::Artist,
..Self::from_raw(artist.get_artist_gid())?
})
}
}
impl TryFrom<&protocol::playlist4_external::Item> for SpotifyId {
type Error = crate::Error;
fn try_from(item: &protocol::playlist4_external::Item) -> Result<Self, Self::Error> {
Ok(Self {
item_type: SpotifyItemType::Track,
..Self::from_uri(item.get_uri())?
})
}
}
// Note that this is the unique revision of an item's metadata on a playlist,
// not the ID of that item or playlist.
impl TryFrom<&protocol::playlist4_external::MetaItem> for SpotifyId {
type Error = crate::Error;
fn try_from(item: &protocol::playlist4_external::MetaItem) -> Result<Self, Self::Error> {
Self::try_from(item.get_revision())
}
}
// Note that this is the unique revision of a playlist, not the ID of that playlist.
impl TryFrom<&protocol::playlist4_external::SelectedListContent> for SpotifyId {
type Error = crate::Error;
fn try_from(
playlist: &protocol::playlist4_external::SelectedListContent,
) -> Result<Self, Self::Error> {
Self::try_from(playlist.get_revision())
}
}
// TODO: check meaning and format of this field in the wild. This might be a FileId,
// which is why we now don't create a separate `Playlist` enum value yet and choose
// to discard any item type.
impl TryFrom<&protocol::playlist_annotate3::TranscodedPicture> for SpotifyId {
type Error = crate::Error;
fn try_from(
picture: &protocol::playlist_annotate3::TranscodedPicture,
) -> Result<Self, Self::Error> {
Self::from_base62(picture.get_uri())
}
}
pub fn to_base16(src: &[u8], buf: &mut [u8]) -> String {
let mut i = 0;
for v in src {
buf[i] = BASE16_DIGITS[(v >> 4) as usize];
@ -258,7 +515,7 @@ mod tests {
struct ConversionCase {
id: u128,
kind: SpotifyAudioType,
kind: SpotifyItemType,
uri: &'static str,
base16: &'static str,
base62: &'static str,
@ -268,7 +525,7 @@ mod tests {
static CONV_VALID: [ConversionCase; 4] = [
ConversionCase {
id: 238762092608182713602505436543891614649,
kind: SpotifyAudioType::Track,
kind: SpotifyItemType::Track,
uri: "spotify:track:5sWHDYs0csV6RS48xBl0tH",
base16: "b39fe8081e1f4c54be38e8d6f9f12bb9",
base62: "5sWHDYs0csV6RS48xBl0tH",
@ -278,7 +535,7 @@ mod tests {
},
ConversionCase {
id: 204841891221366092811751085145916697048,
kind: SpotifyAudioType::Track,
kind: SpotifyItemType::Track,
uri: "spotify:track:4GNcXTGWmnZ3ySrqvol3o4",
base16: "9a1b1cfbc6f244569ae0356c77bbe9d8",
base62: "4GNcXTGWmnZ3ySrqvol3o4",
@ -288,7 +545,7 @@ mod tests {
},
ConversionCase {
id: 204841891221366092811751085145916697048,
kind: SpotifyAudioType::Podcast,
kind: SpotifyItemType::Episode,
uri: "spotify:episode:4GNcXTGWmnZ3ySrqvol3o4",
base16: "9a1b1cfbc6f244569ae0356c77bbe9d8",
base62: "4GNcXTGWmnZ3ySrqvol3o4",
@ -298,8 +555,8 @@ mod tests {
},
ConversionCase {
id: 204841891221366092811751085145916697048,
kind: SpotifyAudioType::NonPlayable,
uri: "spotify:unknown:4GNcXTGWmnZ3ySrqvol3o4",
kind: SpotifyItemType::Show,
uri: "spotify:show:4GNcXTGWmnZ3ySrqvol3o4",
base16: "9a1b1cfbc6f244569ae0356c77bbe9d8",
base62: "4GNcXTGWmnZ3ySrqvol3o4",
raw: &[
@ -311,7 +568,7 @@ mod tests {
static CONV_INVALID: [ConversionCase; 3] = [
ConversionCase {
id: 0,
kind: SpotifyAudioType::NonPlayable,
kind: SpotifyItemType::Unknown,
// Invalid ID in the URI.
uri: "spotify:arbitrarywhatever:5sWHDYs0Bl0tH",
base16: "ZZZZZ8081e1f4c54be38e8d6f9f12bb9",
@ -323,7 +580,7 @@ mod tests {
},
ConversionCase {
id: 0,
kind: SpotifyAudioType::NonPlayable,
kind: SpotifyItemType::Unknown,
// Missing colon between ID and type.
uri: "spotify:arbitrarywhatever5sWHDYs0csV6RS48xBl0tH",
base16: "--------------------",
@ -335,7 +592,7 @@ mod tests {
},
ConversionCase {
id: 0,
kind: SpotifyAudioType::NonPlayable,
kind: SpotifyItemType::Unknown,
// Uri too short
uri: "spotify:azb:aRS48xBl0tH",
base16: "--------------------",
@ -354,7 +611,7 @@ mod tests {
}
for c in &CONV_INVALID {
assert_eq!(SpotifyId::from_base62(c.base62), Err(SpotifyIdError));
assert!(SpotifyId::from_base62(c.base62).is_err(),);
}
}
@ -363,7 +620,7 @@ mod tests {
for c in &CONV_VALID {
let id = SpotifyId {
id: c.id,
audio_type: c.kind,
item_type: c.kind,
};
assert_eq!(id.to_base62(), c.base62);
@ -377,7 +634,7 @@ mod tests {
}
for c in &CONV_INVALID {
assert_eq!(SpotifyId::from_base16(c.base16), Err(SpotifyIdError));
assert!(SpotifyId::from_base16(c.base16).is_err(),);
}
}
@ -386,7 +643,7 @@ mod tests {
for c in &CONV_VALID {
let id = SpotifyId {
id: c.id,
audio_type: c.kind,
item_type: c.kind,
};
assert_eq!(id.to_base16(), c.base16);
@ -399,11 +656,11 @@ mod tests {
let actual = SpotifyId::from_uri(c.uri).unwrap();
assert_eq!(actual.id, c.id);
assert_eq!(actual.audio_type, c.kind);
assert_eq!(actual.item_type, c.kind);
}
for c in &CONV_INVALID {
assert_eq!(SpotifyId::from_uri(c.uri), Err(SpotifyIdError));
assert!(SpotifyId::from_uri(c.uri).is_err());
}
}
@ -412,7 +669,7 @@ mod tests {
for c in &CONV_VALID {
let id = SpotifyId {
id: c.id,
audio_type: c.kind,
item_type: c.kind,
};
assert_eq!(id.to_uri(), c.uri);
@ -426,7 +683,7 @@ mod tests {
}
for c in &CONV_INVALID {
assert_eq!(SpotifyId::from_raw(c.raw), Err(SpotifyIdError));
assert!(SpotifyId::from_raw(c.raw).is_err());
}
}
}

View file

@ -8,12 +8,12 @@
// user-library-modify, user-library-read, user-follow-modify, user-follow-read, streaming,
// app-remote-control
use crate::mercury::MercuryError;
use std::time::{Duration, Instant};
use serde::Deserialize;
use thiserror::Error;
use std::error::Error;
use std::time::{Duration, Instant};
use crate::Error;
component! {
TokenProvider : TokenProviderInner {
@ -21,13 +21,25 @@ component! {
}
}
#[derive(Debug, Error)]
pub enum TokenError {
#[error("no tokens available")]
Empty,
}
impl From<TokenError> for Error {
fn from(err: TokenError) -> Self {
Error::unavailable(err)
}
}
#[derive(Clone, Debug)]
pub struct Token {
access_token: String,
expires_in: Duration,
token_type: String,
scopes: Vec<String>,
timestamp: Instant,
pub access_token: String,
pub expires_in: Duration,
pub token_type: String,
pub scopes: Vec<String>,
pub timestamp: Instant,
}
#[derive(Deserialize)]
@ -54,11 +66,7 @@ impl TokenProvider {
}
// scopes must be comma-separated
pub async fn get_token(&self, scopes: &str) -> Result<Token, MercuryError> {
if scopes.is_empty() {
return Err(MercuryError);
}
pub async fn get_token(&self, scopes: &str) -> Result<Token, Error> {
if let Some(index) = self.find_token(scopes.split(',').collect()) {
let cached_token = self.lock(|inner| inner.tokens[index].clone());
if cached_token.is_expired() {
@ -79,15 +87,11 @@ impl TokenProvider {
Self::KEYMASTER_CLIENT_ID,
self.session().device_id()
);
let request = self.session().mercury().get(query_uri);
let request = self.session().mercury().get(query_uri)?;
let response = request.await?;
let data = response
.payload
.first()
.expect("No tokens received")
.to_vec();
let token = Token::new(String::from_utf8(data).unwrap()).map_err(|_| MercuryError)?;
trace!("Got token: {:?}", token);
let data = response.payload.first().ok_or(TokenError::Empty)?.to_vec();
let token = Token::new(String::from_utf8(data)?)?;
trace!("Got token: {:#?}", token);
self.lock(|inner| inner.tokens.push(token.clone()));
Ok(token)
}
@ -96,7 +100,7 @@ impl TokenProvider {
impl Token {
const EXPIRY_THRESHOLD: Duration = Duration::from_secs(10);
pub fn new(body: String) -> Result<Self, Box<dyn Error>> {
pub fn new(body: String) -> Result<Self, Error> {
let data: TokenData = serde_json::from_slice(body.as_ref())?;
Ok(Self {
access_token: data.access_token,

View file

@ -1,15 +1,13 @@
use std::future::Future;
use std::mem;
use std::pin::Pin;
use std::task::Context;
use std::task::Poll;
use std::{
future::Future,
mem,
pin::Pin,
task::{Context, Poll},
};
use futures_core::ready;
use futures_util::FutureExt;
use futures_util::Sink;
use futures_util::{future, SinkExt};
use tokio::task::JoinHandle;
use tokio::time::timeout;
use futures_util::{future, FutureExt, Sink, SinkExt};
use tokio::{task::JoinHandle, time::timeout};
/// Returns a future that will flush the sink, even if flushing is temporarily completed.
/// Finishes only if the sink throws an error.

View file

@ -15,3 +15,12 @@ pub const SEMVER: &str = env!("CARGO_PKG_VERSION");
/// A random build id.
pub const BUILD_ID: &str = env!("LIBRESPOT_BUILD_ID");
/// The protocol version of the Spotify desktop client.
pub const SPOTIFY_VERSION: u64 = 117300517;
/// The protocol version of the Spotify mobile app.
pub const SPOTIFY_MOBILE_VERSION: &str = "8.6.84";
/// The user agent to fall back to, if one could not be determined dynamically.
pub const FALLBACK_USER_AGENT: &str = "Spotify/117300517 Linux/0 (librespot)";

View file

@ -18,7 +18,7 @@ async fn test_connection() {
match result {
Ok(_) => panic!("Authentication succeeded despite of bad credentials."),
Err(e) => assert_eq!(e.to_string(), "Login failed with reason: Bad credentials"),
Err(e) => assert!(!e.to_string().is_empty()), // there should be some error message
}
})
.await

View file

@ -1,6 +1,6 @@
[package]
name = "librespot-discovery"
version = "0.2.0"
version = "0.3.1"
authors = ["Paul Lietar <paul@lietar.net>"]
description = "The discovery logic for librespot"
license = "MIT"
@ -11,30 +11,28 @@ edition = "2018"
aes-ctr = "0.6"
base64 = "0.13"
cfg-if = "1.0"
dns-sd = { version = "0.1.3", optional = true }
form_urlencoded = "1.0"
futures-core = "0.3"
futures-util = "0.3"
hmac = "0.11"
hyper = { version = "0.14", features = ["server", "http1", "tcp"] }
hyper = { version = "0.14", features = ["http1", "server", "tcp"] }
libmdns = "0.6"
log = "0.4"
rand = "0.8"
serde_json = "1.0.25"
sha-1 = "0.9"
thiserror = "1.0"
tokio = { version = "1.0", features = ["sync", "rt"] }
dns-sd = { version = "0.1.3", optional = true }
tokio = { version = "1.0", features = ["parking_lot", "sync", "rt"] }
[dependencies.librespot-core]
path = "../core"
default_features = false
version = "0.2.0"
version = "0.3.1"
[dev-dependencies]
futures = "0.3"
hex = "0.4"
simple_logger = "1.11"
tokio = { version = "1.0", features = ["macros", "rt"] }
tokio = { version = "1.0", features = ["macros", "parking_lot", "rt"] }
[features]
with-dns-sd = ["dns-sd"]
with-dns-sd = ["dns-sd", "librespot-core/with-dns-sd"]

View file

@ -1,15 +1,9 @@
use futures::StreamExt;
use librespot_discovery::DeviceType;
use sha1::{Digest, Sha1};
use simple_logger::SimpleLogger;
#[tokio::main(flavor = "current_thread")]
async fn main() {
SimpleLogger::new()
.with_level(log::LevelFilter::Debug)
.init()
.unwrap();
let name = "Librespot";
let device_id = hex::encode(Sha1::digest(name.as_bytes()));

View file

@ -7,8 +7,6 @@
//! This library uses mDNS and DNS-SD so that other devices can find it,
//! and spawns an http server to answer requests of Spotify clients.
#![warn(clippy::all, missing_docs, rust_2018_idioms)]
mod server;
use std::borrow::Cow;
@ -29,6 +27,8 @@ pub use crate::core::authentication::Credentials;
/// Determining the icon in the list of available devices.
pub use crate::core::config::DeviceType;
pub use crate::core::Error;
/// Makes this device visible to Spotify clients in the local network.
///
/// `Discovery` implements the [`Stream`] trait. Every time this device
@ -50,13 +50,28 @@ pub struct Builder {
/// Errors that can occur while setting up a [`Discovery`] instance.
#[derive(Debug, Error)]
pub enum Error {
pub enum DiscoveryError {
/// Setting up service discovery via DNS-SD failed.
#[error("Setting up dns-sd failed: {0}")]
DnsSdError(#[from] io::Error),
/// Setting up the http server failed.
#[error("Creating SHA1 HMAC failed for base key {0:?}")]
HmacError(Vec<u8>),
#[error("Setting up the http server failed: {0}")]
HttpServerError(#[from] hyper::Error),
#[error("Missing params for key {0}")]
ParamsError(&'static str),
}
impl From<DiscoveryError> for Error {
fn from(err: DiscoveryError) -> Self {
match err {
DiscoveryError::DnsSdError(_) => Error::unavailable(err),
DiscoveryError::HmacError(_) => Error::invalid_argument(err),
DiscoveryError::HttpServerError(_) => Error::unavailable(err),
DiscoveryError::ParamsError(_) => Error::invalid_argument(err),
}
}
}
impl Builder {
@ -98,7 +113,7 @@ impl Builder {
pub fn launch(self) -> Result<Discovery, Error> {
let mut port = self.port;
let name = self.server_config.name.clone().into_owned();
let server = DiscoveryServer::new(self.server_config, &mut port)?;
let server = DiscoveryServer::new(self.server_config, &mut port)??;
let svc;
@ -111,8 +126,7 @@ impl Builder {
None,
port,
&["VERSION=1.0", "CPath=/"],
)
.unwrap();
)?;
} else {
let responder = libmdns::Responder::spawn(&tokio::runtime::Handle::current())?;

View file

@ -1,26 +1,35 @@
use std::borrow::Cow;
use std::collections::BTreeMap;
use std::convert::Infallible;
use std::net::{Ipv4Addr, SocketAddr};
use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context, Poll};
use std::{
borrow::Cow,
collections::BTreeMap,
convert::Infallible,
net::{Ipv4Addr, SocketAddr},
pin::Pin,
sync::Arc,
task::{Context, Poll},
};
use aes_ctr::cipher::generic_array::GenericArray;
use aes_ctr::cipher::{NewStreamCipher, SyncStreamCipher};
use aes_ctr::Aes128Ctr;
use aes_ctr::{
cipher::generic_array::GenericArray,
cipher::{NewStreamCipher, SyncStreamCipher},
Aes128Ctr,
};
use futures_core::Stream;
use futures_util::{FutureExt, TryFutureExt};
use hmac::{Hmac, Mac, NewMac};
use hyper::service::{make_service_fn, service_fn};
use hyper::{Body, Method, Request, Response, StatusCode};
use log::{debug, warn};
use hyper::{
service::{make_service_fn, service_fn},
Body, Method, Request, Response, StatusCode,
};
use log::{debug, error, warn};
use serde_json::json;
use sha1::{Digest, Sha1};
use tokio::sync::{mpsc, oneshot};
use crate::core::authentication::Credentials;
use crate::core::config::DeviceType;
use crate::core::diffie_hellman::DhLocalKeys;
use super::DiscoveryError;
use crate::core::{
authentication::Credentials, config::DeviceType, diffie_hellman::DhLocalKeys, Error,
};
type Params<'a> = BTreeMap<Cow<'a, str>, Cow<'a, str>>;
@ -57,7 +66,7 @@ impl RequestHandler {
"status": 101,
"statusString": "ERROR-OK",
"spotifyError": 0,
"version": "2.7.1",
"version": crate::core::version::SEMVER,
"deviceID": (self.config.device_id),
"remoteName": (self.config.name),
"activeUser": "",
@ -76,14 +85,26 @@ impl RequestHandler {
Response::new(Body::from(body))
}
fn handle_add_user(&self, params: &Params<'_>) -> Response<hyper::Body> {
let username = params.get("userName").unwrap().as_ref();
let encrypted_blob = params.get("blob").unwrap();
let client_key = params.get("clientKey").unwrap();
fn handle_add_user(&self, params: &Params<'_>) -> Result<Response<hyper::Body>, Error> {
let username_key = "userName";
let username = params
.get(username_key)
.ok_or(DiscoveryError::ParamsError(username_key))?
.as_ref();
let encrypted_blob = base64::decode(encrypted_blob.as_bytes()).unwrap();
let blob_key = "blob";
let encrypted_blob = params
.get(blob_key)
.ok_or(DiscoveryError::ParamsError(blob_key))?;
let client_key = base64::decode(client_key.as_bytes()).unwrap();
let clientkey_key = "clientKey";
let client_key = params
.get(clientkey_key)
.ok_or(DiscoveryError::ParamsError(clientkey_key))?;
let encrypted_blob = base64::decode(encrypted_blob.as_bytes())?;
let client_key = base64::decode(client_key.as_bytes())?;
let shared_key = self.keys.shared_secret(&client_key);
let iv = &encrypted_blob[0..16];
@ -94,21 +115,21 @@ impl RequestHandler {
let base_key = &base_key[..16];
let checksum_key = {
let mut h =
Hmac::<Sha1>::new_from_slice(base_key).expect("HMAC can take key of any size");
let mut h = Hmac::<Sha1>::new_from_slice(base_key)
.map_err(|_| DiscoveryError::HmacError(base_key.to_vec()))?;
h.update(b"checksum");
h.finalize().into_bytes()
};
let encryption_key = {
let mut h =
Hmac::<Sha1>::new_from_slice(base_key).expect("HMAC can take key of any size");
let mut h = Hmac::<Sha1>::new_from_slice(base_key)
.map_err(|_| DiscoveryError::HmacError(base_key.to_vec()))?;
h.update(b"encryption");
h.finalize().into_bytes()
};
let mut h =
Hmac::<Sha1>::new_from_slice(&checksum_key).expect("HMAC can take key of any size");
let mut h = Hmac::<Sha1>::new_from_slice(&checksum_key)
.map_err(|_| DiscoveryError::HmacError(base_key.to_vec()))?;
h.update(encrypted);
if h.verify(cksum).is_err() {
warn!("Login error for user {:?}: MAC mismatch", username);
@ -119,7 +140,7 @@ impl RequestHandler {
});
let body = result.to_string();
return Response::new(Body::from(body));
return Ok(Response::new(Body::from(body)));
}
let decrypted = {
@ -129,13 +150,12 @@ impl RequestHandler {
GenericArray::from_slice(iv),
);
cipher.apply_keystream(&mut data);
String::from_utf8(data).unwrap()
data
};
let credentials =
Credentials::with_blob(username.to_string(), &decrypted, &self.config.device_id);
let credentials = Credentials::with_blob(username, &decrypted, &self.config.device_id)?;
self.tx.send(credentials).unwrap();
self.tx.send(credentials)?;
let result = json!({
"status": 101,
@ -144,7 +164,7 @@ impl RequestHandler {
});
let body = result.to_string();
Response::new(Body::from(body))
Ok(Response::new(Body::from(body)))
}
fn not_found(&self) -> Response<hyper::Body> {
@ -153,7 +173,10 @@ impl RequestHandler {
res
}
async fn handle(self: Arc<Self>, request: Request<Body>) -> hyper::Result<Response<Body>> {
async fn handle(
self: Arc<Self>,
request: Request<Body>,
) -> Result<hyper::Result<Response<Body>>, Error> {
let mut params = Params::new();
let (parts, body) = request.into_parts();
@ -173,11 +196,11 @@ impl RequestHandler {
let action = params.get("action").map(Cow::as_ref);
Ok(match (parts.method, action) {
Ok(Ok(match (parts.method, action) {
(Method::GET, Some("getInfo")) => self.handle_get_info(),
(Method::POST, Some("addUser")) => self.handle_add_user(&params),
(Method::POST, Some("addUser")) => self.handle_add_user(&params)?,
_ => self.not_found(),
})
}))
}
}
@ -187,7 +210,7 @@ pub struct DiscoveryServer {
}
impl DiscoveryServer {
pub fn new(config: Config, port: &mut u16) -> hyper::Result<Self> {
pub fn new(config: Config, port: &mut u16) -> Result<hyper::Result<Self>, Error> {
let (discovery, cred_rx) = RequestHandler::new(config);
let discovery = Arc::new(discovery);
@ -198,7 +221,14 @@ impl DiscoveryServer {
let make_service = make_service_fn(move |_| {
let discovery = discovery.clone();
async move {
Ok::<_, hyper::Error>(service_fn(move |request| discovery.clone().handle(request)))
Ok::<_, hyper::Error>(service_fn(move |request| {
discovery
.clone()
.handle(request)
.inspect_err(|e| error!("could not handle discovery request: {}", e))
.and_then(|x| async move { Ok(x) })
.map(Result::unwrap) // guaranteed by `and_then` above
}))
}
});
@ -210,8 +240,10 @@ impl DiscoveryServer {
tokio::spawn(async {
let result = server
.with_graceful_shutdown(async {
close_rx.await.unwrap_err();
debug!("Shutting down discovery server");
if close_rx.await.is_ok() {
debug!("unable to close discovery Rx channel completely");
}
})
.await;
@ -220,10 +252,10 @@ impl DiscoveryServer {
}
});
Ok(Self {
Ok(Ok(Self {
cred_rx,
_close_tx: close_tx,
})
}))
}
}

View file

@ -57,20 +57,4 @@ login_data = AES192-DECRYPT(key, data)
```
## Facebook based Authentication
The client starts an HTTPS server, and makes the user visit
`https://login.spotify.com/login-facebook-sso/?csrf=CSRF&port=PORT`
in their browser, where CSRF is a random token, and PORT is the HTTPS server's port.
This will redirect to Facebook, where the user must login and authorize Spotify, and
finally make a GET request to
`https://login.spotilocal.com:PORT/login/facebook_login_sso.json?csrf=CSRF&access_token=TOKEN`,
where PORT and CSRF are the same as sent earlier, and TOKEN is the facebook authentication token.
Since `login.spotilocal.com` resolves the 127.0.0.1, the request is received by the client.
The client must then contact Facebook's API at
`https://graph.facebook.com/me?fields=id&access_token=TOKEN`
in order to retrieve the user's Facebook ID.
The Facebook ID is the `username`, the TOKEN the `auth_data`, and `auth_type` is set to `AUTHENTICATION_FACEBOOK_TOKEN`.
Facebook authentication is currently broken due to Spotify changing the authentication flow. The details of how the new flow works are detailed in https://github.com/librespot-org/librespot/issues/244 and will be implemented at some point in the future.

View file

@ -30,7 +30,7 @@ async fn main() {
let plist = Playlist::get(&session, plist_uri).await.unwrap();
println!("{:?}", plist);
for track_id in plist.tracks {
for track_id in plist.tracks() {
let plist_track = Track::get(&session, track_id).await.unwrap();
println!("track: {} ", plist_track.name);
}

View file

@ -1,6 +1,6 @@
[package]
name = "librespot-metadata"
version = "0.2.0"
version = "0.3.1"
authors = ["Paul Lietar <paul@lietar.net>"]
description = "The metadata logic for librespot"
license = "MIT"
@ -10,12 +10,17 @@ edition = "2018"
[dependencies]
async-trait = "0.1"
byteorder = "1.3"
protobuf = "2.14.0"
bytes = "1.0"
chrono = "0.4"
log = "0.4"
protobuf = "2.14.0"
thiserror = "1"
uuid = { version = "0.8", default-features = false }
[dependencies.librespot-core]
path = "../core"
version = "0.2.0"
version = "0.3.1"
[dependencies.librespot-protocol]
path = "../protocol"
version = "0.2.0"
version = "0.3.1"

137
metadata/src/album.rs Normal file
View file

@ -0,0 +1,137 @@
use std::{
convert::{TryFrom, TryInto},
fmt::Debug,
ops::Deref,
};
use crate::{
artist::Artists, availability::Availabilities, copyright::Copyrights, external_id::ExternalIds,
image::Images, request::RequestResult, restriction::Restrictions, sale_period::SalePeriods,
track::Tracks, util::try_from_repeated_message, Metadata,
};
use librespot_core::{date::Date, Error, Session, SpotifyId};
use librespot_protocol as protocol;
pub use protocol::metadata::Album_Type as AlbumType;
use protocol::metadata::Disc as DiscMessage;
#[derive(Debug, Clone)]
pub struct Album {
pub id: SpotifyId,
pub name: String,
pub artists: Artists,
pub album_type: AlbumType,
pub label: String,
pub date: Date,
pub popularity: i32,
pub genres: Vec<String>,
pub covers: Images,
pub external_ids: ExternalIds,
pub discs: Discs,
pub reviews: Vec<String>,
pub copyrights: Copyrights,
pub restrictions: Restrictions,
pub related: Albums,
pub sale_periods: SalePeriods,
pub cover_group: Images,
pub original_title: String,
pub version_title: String,
pub type_str: String,
pub availability: Availabilities,
}
#[derive(Debug, Clone)]
pub struct Albums(pub Vec<SpotifyId>);
impl Deref for Albums {
type Target = Vec<SpotifyId>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
#[derive(Debug, Clone)]
pub struct Disc {
pub number: i32,
pub name: String,
pub tracks: Tracks,
}
#[derive(Debug, Clone)]
pub struct Discs(pub Vec<Disc>);
impl Deref for Discs {
type Target = Vec<Disc>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl Album {
pub fn tracks(&self) -> Tracks {
let result = self
.discs
.iter()
.flat_map(|disc| disc.tracks.deref().clone())
.collect();
Tracks(result)
}
}
#[async_trait]
impl Metadata for Album {
type Message = protocol::metadata::Album;
async fn request(session: &Session, album_id: SpotifyId) -> RequestResult {
session.spclient().get_album_metadata(album_id).await
}
fn parse(msg: &Self::Message, _: SpotifyId) -> Result<Self, Error> {
Self::try_from(msg)
}
}
impl TryFrom<&<Self as Metadata>::Message> for Album {
type Error = librespot_core::Error;
fn try_from(album: &<Self as Metadata>::Message) -> Result<Self, Self::Error> {
Ok(Self {
id: album.try_into()?,
name: album.get_name().to_owned(),
artists: album.get_artist().try_into()?,
album_type: album.get_field_type(),
label: album.get_label().to_owned(),
date: album.get_date().into(),
popularity: album.get_popularity(),
genres: album.get_genre().to_vec(),
covers: album.get_cover().into(),
external_ids: album.get_external_id().into(),
discs: album.get_disc().try_into()?,
reviews: album.get_review().to_vec(),
copyrights: album.get_copyright().into(),
restrictions: album.get_restriction().into(),
related: album.get_related().try_into()?,
sale_periods: album.get_sale_period().into(),
cover_group: album.get_cover_group().get_image().into(),
original_title: album.get_original_title().to_owned(),
version_title: album.get_version_title().to_owned(),
type_str: album.get_type_str().to_owned(),
availability: album.get_availability().into(),
})
}
}
try_from_repeated_message!(<Album as Metadata>::Message, Albums);
impl TryFrom<&DiscMessage> for Disc {
type Error = librespot_core::Error;
fn try_from(disc: &DiscMessage) -> Result<Self, Self::Error> {
Ok(Self {
number: disc.get_number(),
name: disc.get_name().to_owned(),
tracks: disc.get_track().try_into()?,
})
}
}
try_from_repeated_message!(DiscMessage, Discs);

129
metadata/src/artist.rs Normal file
View file

@ -0,0 +1,129 @@
use std::{
convert::{TryFrom, TryInto},
fmt::Debug,
ops::Deref,
};
use crate::{request::RequestResult, track::Tracks, util::try_from_repeated_message, Metadata};
use librespot_core::{Error, Session, SpotifyId};
use librespot_protocol as protocol;
use protocol::metadata::ArtistWithRole as ArtistWithRoleMessage;
pub use protocol::metadata::ArtistWithRole_ArtistRole as ArtistRole;
use protocol::metadata::TopTracks as TopTracksMessage;
#[derive(Debug, Clone)]
pub struct Artist {
pub id: SpotifyId,
pub name: String,
pub top_tracks: CountryTopTracks,
}
#[derive(Debug, Clone)]
pub struct Artists(pub Vec<SpotifyId>);
impl Deref for Artists {
type Target = Vec<SpotifyId>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
#[derive(Debug, Clone)]
pub struct ArtistWithRole {
pub id: SpotifyId,
pub name: String,
pub role: ArtistRole,
}
#[derive(Debug, Clone)]
pub struct ArtistsWithRole(pub Vec<ArtistWithRole>);
impl Deref for ArtistsWithRole {
type Target = Vec<ArtistWithRole>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
#[derive(Debug, Clone)]
pub struct TopTracks {
pub country: String,
pub tracks: Tracks,
}
#[derive(Debug, Clone)]
pub struct CountryTopTracks(pub Vec<TopTracks>);
impl Deref for CountryTopTracks {
type Target = Vec<TopTracks>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl CountryTopTracks {
pub fn for_country(&self, country: &str) -> Tracks {
if let Some(country) = self.0.iter().find(|top_track| top_track.country == country) {
return country.tracks.clone();
}
if let Some(global) = self.0.iter().find(|top_track| top_track.country.is_empty()) {
return global.tracks.clone();
}
Tracks(vec![]) // none found
}
}
#[async_trait]
impl Metadata for Artist {
type Message = protocol::metadata::Artist;
async fn request(session: &Session, artist_id: SpotifyId) -> RequestResult {
session.spclient().get_artist_metadata(artist_id).await
}
fn parse(msg: &Self::Message, _: SpotifyId) -> Result<Self, Error> {
Self::try_from(msg)
}
}
impl TryFrom<&<Self as Metadata>::Message> for Artist {
type Error = librespot_core::Error;
fn try_from(artist: &<Self as Metadata>::Message) -> Result<Self, Self::Error> {
Ok(Self {
id: artist.try_into()?,
name: artist.get_name().to_owned(),
top_tracks: artist.get_top_track().try_into()?,
})
}
}
try_from_repeated_message!(<Artist as Metadata>::Message, Artists);
impl TryFrom<&ArtistWithRoleMessage> for ArtistWithRole {
type Error = librespot_core::Error;
fn try_from(artist_with_role: &ArtistWithRoleMessage) -> Result<Self, Self::Error> {
Ok(Self {
id: artist_with_role.try_into()?,
name: artist_with_role.get_artist_name().to_owned(),
role: artist_with_role.get_role(),
})
}
}
try_from_repeated_message!(ArtistWithRoleMessage, ArtistsWithRole);
impl TryFrom<&TopTracksMessage> for TopTracks {
type Error = librespot_core::Error;
fn try_from(top_tracks: &TopTracksMessage) -> Result<Self, Self::Error> {
Ok(Self {
country: top_tracks.get_country().to_owned(),
tracks: top_tracks.get_track().try_into()?,
})
}
}
try_from_repeated_message!(TopTracksMessage, CountryTopTracks);

View file

@ -0,0 +1,28 @@
use std::{collections::HashMap, fmt::Debug, ops::Deref};
use librespot_core::FileId;
use librespot_protocol as protocol;
use protocol::metadata::AudioFile as AudioFileMessage;
pub use protocol::metadata::AudioFile_Format as AudioFileFormat;
#[derive(Debug, Clone)]
pub struct AudioFiles(pub HashMap<AudioFileFormat, FileId>);
impl Deref for AudioFiles {
type Target = HashMap<AudioFileFormat, FileId>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl From<&[AudioFileMessage]> for AudioFiles {
fn from(files: &[AudioFileMessage]) -> Self {
let audio_files = files
.iter()
.map(|file| (file.get_format(), FileId::from(file.get_file_id())))
.collect();
AudioFiles(audio_files)
}
}

112
metadata/src/audio/item.rs Normal file
View file

@ -0,0 +1,112 @@
use std::fmt::Debug;
use chrono::Local;
use crate::{
availability::{AudioItemAvailability, Availabilities, UnavailabilityReason},
episode::Episode,
error::MetadataError,
restriction::Restrictions,
track::{Track, Tracks},
};
use super::file::AudioFiles;
use librespot_core::{session::UserData, spotify_id::SpotifyItemType, Error, Session, SpotifyId};
pub type AudioItemResult = Result<AudioItem, Error>;
// A wrapper with fields the player needs
#[derive(Debug, Clone)]
pub struct AudioItem {
pub id: SpotifyId,
pub spotify_uri: String,
pub files: AudioFiles,
pub name: String,
pub duration: i32,
pub availability: AudioItemAvailability,
pub alternatives: Option<Tracks>,
}
impl AudioItem {
pub async fn get_file(session: &Session, id: SpotifyId) -> AudioItemResult {
match id.item_type {
SpotifyItemType::Track => Track::get_audio_item(session, id).await,
SpotifyItemType::Episode => Episode::get_audio_item(session, id).await,
_ => Err(Error::unavailable(MetadataError::NonPlayable)),
}
}
}
#[async_trait]
pub trait InnerAudioItem {
async fn get_audio_item(session: &Session, id: SpotifyId) -> AudioItemResult;
fn allowed_for_user(
user_data: &UserData,
restrictions: &Restrictions,
) -> AudioItemAvailability {
let country = &user_data.country;
let user_catalogue = match user_data.attributes.get("catalogue") {
Some(catalogue) => catalogue,
None => "premium",
};
for premium_restriction in restrictions.iter().filter(|restriction| {
restriction
.catalogue_strs
.iter()
.any(|restricted_catalogue| restricted_catalogue == user_catalogue)
}) {
if let Some(allowed_countries) = &premium_restriction.countries_allowed {
// A restriction will specify either a whitelast *or* a blacklist,
// but not both. So restrict availability if there is a whitelist
// and the country isn't on it.
if allowed_countries.iter().any(|allowed| country == allowed) {
return Ok(());
} else {
return Err(UnavailabilityReason::NotWhitelisted);
}
}
if let Some(forbidden_countries) = &premium_restriction.countries_forbidden {
if forbidden_countries
.iter()
.any(|forbidden| country == forbidden)
{
return Err(UnavailabilityReason::Blacklisted);
} else {
return Ok(());
}
}
}
Ok(()) // no restrictions in place
}
fn available(availability: &Availabilities) -> AudioItemAvailability {
if availability.is_empty() {
// not all items have availability specified
return Ok(());
}
if !(availability
.iter()
.any(|availability| Local::now() >= availability.start.as_utc()))
{
return Err(UnavailabilityReason::Embargo);
}
Ok(())
}
fn available_for_user(
user_data: &UserData,
availability: &Availabilities,
restrictions: &Restrictions,
) -> AudioItemAvailability {
Self::available(availability)?;
Self::allowed_for_user(user_data, restrictions)?;
Ok(())
}
}

View file

@ -0,0 +1,5 @@
pub mod file;
pub mod item;
pub use file::AudioFileFormat;
pub use item::AudioItem;

View file

@ -0,0 +1,51 @@
use std::{fmt::Debug, ops::Deref};
use thiserror::Error;
use crate::util::from_repeated_message;
use librespot_core::date::Date;
use librespot_protocol as protocol;
use protocol::metadata::Availability as AvailabilityMessage;
pub type AudioItemAvailability = Result<(), UnavailabilityReason>;
#[derive(Debug, Clone)]
pub struct Availability {
pub catalogue_strs: Vec<String>,
pub start: Date,
}
#[derive(Debug, Clone)]
pub struct Availabilities(pub Vec<Availability>);
impl Deref for Availabilities {
type Target = Vec<Availability>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
#[derive(Debug, Copy, Clone, Error)]
pub enum UnavailabilityReason {
#[error("blacklist present and country on it")]
Blacklisted,
#[error("available date is in the future")]
Embargo,
#[error("required data was not present")]
NoData,
#[error("whitelist present and country not on it")]
NotWhitelisted,
}
impl From<&AvailabilityMessage> for Availability {
fn from(availability: &AvailabilityMessage) -> Self {
Self {
catalogue_strs: availability.get_catalogue_str().to_vec(),
start: availability.get_start().into(),
}
}
}
from_repeated_message!(AvailabilityMessage, Availabilities);

View file

@ -0,0 +1,33 @@
use std::{fmt::Debug, ops::Deref};
use crate::util::from_repeated_message;
use librespot_protocol as protocol;
use protocol::metadata::ContentRating as ContentRatingMessage;
#[derive(Debug, Clone)]
pub struct ContentRating {
pub country: String,
pub tags: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct ContentRatings(pub Vec<ContentRating>);
impl Deref for ContentRatings {
type Target = Vec<ContentRating>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl From<&ContentRatingMessage> for ContentRating {
fn from(content_rating: &ContentRatingMessage) -> Self {
Self {
country: content_rating.get_country().to_owned(),
tags: content_rating.get_tag().to_vec(),
}
}
}
from_repeated_message!(ContentRatingMessage, ContentRatings);

34
metadata/src/copyright.rs Normal file
View file

@ -0,0 +1,34 @@
use std::{fmt::Debug, ops::Deref};
use crate::util::from_repeated_message;
use librespot_protocol as protocol;
use protocol::metadata::Copyright as CopyrightMessage;
pub use protocol::metadata::Copyright_Type as CopyrightType;
#[derive(Debug, Clone)]
pub struct Copyright {
pub copyright_type: CopyrightType,
pub text: String,
}
#[derive(Debug, Clone)]
pub struct Copyrights(pub Vec<Copyright>);
impl Deref for Copyrights {
type Target = Vec<Copyright>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl From<&CopyrightMessage> for Copyright {
fn from(copyright: &CopyrightMessage) -> Self {
Self {
copyright_type: copyright.get_field_type(),
text: copyright.get_text().to_owned(),
}
}
}
from_repeated_message!(CopyrightMessage, Copyrights);

View file

@ -1,20 +0,0 @@
use byteorder::{BigEndian, WriteBytesExt};
use std::io::Write;
use librespot_core::channel::ChannelData;
use librespot_core::packet::PacketType;
use librespot_core::session::Session;
use librespot_core::spotify_id::FileId;
pub fn get(session: &Session, file: FileId) -> ChannelData {
let (channel_id, channel) = session.channel().allocate();
let (_headers, data) = channel.split();
let mut packet: Vec<u8> = Vec::new();
packet.write_u16::<BigEndian>(channel_id).unwrap();
packet.write_u16::<BigEndian>(0).unwrap();
packet.write(&file.0).unwrap();
session.send_packet(PacketType::Image, packet);
data
}

132
metadata/src/episode.rs Normal file
View file

@ -0,0 +1,132 @@
use std::{
convert::{TryFrom, TryInto},
fmt::Debug,
ops::Deref,
};
use crate::{
audio::{
file::AudioFiles,
item::{AudioItem, AudioItemResult, InnerAudioItem},
},
availability::Availabilities,
content_rating::ContentRatings,
image::Images,
request::RequestResult,
restriction::Restrictions,
util::try_from_repeated_message,
video::VideoFiles,
Metadata,
};
use librespot_core::{date::Date, Error, Session, SpotifyId};
use librespot_protocol as protocol;
pub use protocol::metadata::Episode_EpisodeType as EpisodeType;
#[derive(Debug, Clone)]
pub struct Episode {
pub id: SpotifyId,
pub name: String,
pub duration: i32,
pub audio: AudioFiles,
pub description: String,
pub number: i32,
pub publish_time: Date,
pub covers: Images,
pub language: String,
pub is_explicit: bool,
pub show: SpotifyId,
pub videos: VideoFiles,
pub video_previews: VideoFiles,
pub audio_previews: AudioFiles,
pub restrictions: Restrictions,
pub freeze_frames: Images,
pub keywords: Vec<String>,
pub allow_background_playback: bool,
pub availability: Availabilities,
pub external_url: String,
pub episode_type: EpisodeType,
pub has_music_and_talk: bool,
pub content_rating: ContentRatings,
pub is_audiobook_chapter: bool,
}
#[derive(Debug, Clone)]
pub struct Episodes(pub Vec<SpotifyId>);
impl Deref for Episodes {
type Target = Vec<SpotifyId>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
#[async_trait]
impl InnerAudioItem for Episode {
async fn get_audio_item(session: &Session, id: SpotifyId) -> AudioItemResult {
let episode = Self::get(session, id).await?;
let availability = Self::available_for_user(
&session.user_data(),
&episode.availability,
&episode.restrictions,
);
Ok(AudioItem {
id,
spotify_uri: id.to_uri(),
files: episode.audio,
name: episode.name,
duration: episode.duration,
availability,
alternatives: None,
})
}
}
#[async_trait]
impl Metadata for Episode {
type Message = protocol::metadata::Episode;
async fn request(session: &Session, episode_id: SpotifyId) -> RequestResult {
session.spclient().get_episode_metadata(episode_id).await
}
fn parse(msg: &Self::Message, _: SpotifyId) -> Result<Self, Error> {
Self::try_from(msg)
}
}
impl TryFrom<&<Self as Metadata>::Message> for Episode {
type Error = librespot_core::Error;
fn try_from(episode: &<Self as Metadata>::Message) -> Result<Self, Self::Error> {
Ok(Self {
id: episode.try_into()?,
name: episode.get_name().to_owned(),
duration: episode.get_duration().to_owned(),
audio: episode.get_audio().into(),
description: episode.get_description().to_owned(),
number: episode.get_number(),
publish_time: episode.get_publish_time().into(),
covers: episode.get_cover_image().get_image().into(),
language: episode.get_language().to_owned(),
is_explicit: episode.get_explicit().to_owned(),
show: episode.get_show().try_into()?,
videos: episode.get_video().into(),
video_previews: episode.get_video_preview().into(),
audio_previews: episode.get_audio_preview().into(),
restrictions: episode.get_restriction().into(),
freeze_frames: episode.get_freeze_frame().get_image().into(),
keywords: episode.get_keyword().to_vec(),
allow_background_playback: episode.get_allow_background_playback(),
availability: episode.get_availability().into(),
external_url: episode.get_external_url().to_owned(),
episode_type: episode.get_field_type(),
has_music_and_talk: episode.get_music_and_talk(),
content_rating: episode.get_content_rating().into(),
is_audiobook_chapter: episode.get_is_audiobook_chapter(),
})
}
}
try_from_repeated_message!(<Episode as Metadata>::Message, Episodes);

10
metadata/src/error.rs Normal file
View file

@ -0,0 +1,10 @@
use std::fmt::Debug;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum MetadataError {
#[error("empty response")]
Empty,
#[error("audio item is non-playable when it should be")]
NonPlayable,
}

View file

@ -0,0 +1,33 @@
use std::{fmt::Debug, ops::Deref};
use crate::util::from_repeated_message;
use librespot_protocol as protocol;
use protocol::metadata::ExternalId as ExternalIdMessage;
#[derive(Debug, Clone)]
pub struct ExternalId {
pub external_type: String,
pub id: String, // this can be anything from a URL to a ISRC, EAN or UPC
}
#[derive(Debug, Clone)]
pub struct ExternalIds(pub Vec<ExternalId>);
impl Deref for ExternalIds {
type Target = Vec<ExternalId>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl From<&ExternalIdMessage> for ExternalId {
fn from(external_id: &ExternalIdMessage) -> Self {
Self {
external_type: external_id.get_field_type().to_owned(),
id: external_id.get_id().to_owned(),
}
}
}
from_repeated_message!(ExternalIdMessage, ExternalIds);

101
metadata/src/image.rs Normal file
View file

@ -0,0 +1,101 @@
use std::{
convert::{TryFrom, TryInto},
fmt::Debug,
ops::Deref,
};
use crate::util::{from_repeated_message, try_from_repeated_message};
use librespot_core::{FileId, SpotifyId};
use librespot_protocol as protocol;
use protocol::metadata::Image as ImageMessage;
pub use protocol::metadata::Image_Size as ImageSize;
use protocol::playlist4_external::PictureSize as PictureSizeMessage;
use protocol::playlist_annotate3::TranscodedPicture as TranscodedPictureMessage;
#[derive(Debug, Clone)]
pub struct Image {
pub id: FileId,
pub size: ImageSize,
pub width: i32,
pub height: i32,
}
#[derive(Debug, Clone)]
pub struct Images(pub Vec<Image>);
impl Deref for Images {
type Target = Vec<Image>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
#[derive(Debug, Clone)]
pub struct PictureSize {
pub target_name: String,
pub url: String,
}
#[derive(Debug, Clone)]
pub struct PictureSizes(pub Vec<PictureSize>);
impl Deref for PictureSizes {
type Target = Vec<PictureSize>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
#[derive(Debug, Clone)]
pub struct TranscodedPicture {
pub target_name: String,
pub uri: SpotifyId,
}
#[derive(Debug, Clone)]
pub struct TranscodedPictures(pub Vec<TranscodedPicture>);
impl Deref for TranscodedPictures {
type Target = Vec<TranscodedPicture>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl From<&ImageMessage> for Image {
fn from(image: &ImageMessage) -> Self {
Self {
id: image.into(),
size: image.get_size(),
width: image.get_width(),
height: image.get_height(),
}
}
}
from_repeated_message!(ImageMessage, Images);
impl From<&PictureSizeMessage> for PictureSize {
fn from(size: &PictureSizeMessage) -> Self {
Self {
target_name: size.get_target_name().to_owned(),
url: size.get_url().to_owned(),
}
}
}
from_repeated_message!(PictureSizeMessage, PictureSizes);
impl TryFrom<&TranscodedPictureMessage> for TranscodedPicture {
type Error = librespot_core::Error;
fn try_from(picture: &TranscodedPictureMessage) -> Result<Self, Self::Error> {
Ok(Self {
target_name: picture.get_target_name().to_owned(),
uri: picture.try_into()?,
})
}
}
try_from_repeated_message!(TranscodedPictureMessage, TranscodedPictures);

View file

@ -1,460 +1,56 @@
#![allow(clippy::unused_io_amount)]
#[macro_use]
extern crate log;
#[macro_use]
extern crate async_trait;
pub mod cover;
use std::collections::HashMap;
use librespot_core::mercury::MercuryError;
use librespot_core::session::Session;
use librespot_core::spotify_id::{FileId, SpotifyAudioType, SpotifyId};
use librespot_protocol as protocol;
use protobuf::Message;
pub use crate::protocol::metadata::AudioFile_Format as FileFormat;
use librespot_core::{Error, Session, SpotifyId};
fn countrylist_contains(list: &str, country: &str) -> bool {
list.chunks(2).any(|cc| cc == country)
}
pub mod album;
pub mod artist;
pub mod audio;
pub mod availability;
pub mod content_rating;
pub mod copyright;
pub mod episode;
pub mod error;
pub mod external_id;
pub mod image;
pub mod playlist;
mod request;
pub mod restriction;
pub mod sale_period;
pub mod show;
pub mod track;
mod util;
pub mod video;
fn parse_restrictions<'s, I>(restrictions: I, country: &str, catalogue: &str) -> bool
where
I: IntoIterator<Item = &'s protocol::metadata::Restriction>,
{
let mut forbidden = "".to_string();
let mut has_forbidden = false;
pub use error::MetadataError;
use request::RequestResult;
let mut allowed = "".to_string();
let mut has_allowed = false;
let rs = restrictions
.into_iter()
.filter(|r| r.get_catalogue_str().contains(&catalogue.to_owned()));
for r in rs {
if r.has_countries_forbidden() {
forbidden.push_str(r.get_countries_forbidden());
has_forbidden = true;
}
if r.has_countries_allowed() {
allowed.push_str(r.get_countries_allowed());
has_allowed = true;
}
}
(has_forbidden || has_allowed)
&& (!has_forbidden || !countrylist_contains(forbidden.as_str(), country))
&& (!has_allowed || countrylist_contains(allowed.as_str(), country))
}
// A wrapper with fields the player needs
#[derive(Debug, Clone)]
pub struct AudioItem {
pub id: SpotifyId,
pub uri: String,
pub files: HashMap<FileFormat, FileId>,
pub name: String,
pub duration: i32,
pub available: bool,
pub alternatives: Option<Vec<SpotifyId>>,
}
impl AudioItem {
pub async fn get_audio_item(session: &Session, id: SpotifyId) -> Result<Self, MercuryError> {
match id.audio_type {
SpotifyAudioType::Track => Track::get_audio_item(session, id).await,
SpotifyAudioType::Podcast => Episode::get_audio_item(session, id).await,
SpotifyAudioType::NonPlayable => Err(MercuryError),
}
}
}
#[async_trait]
trait AudioFiles {
async fn get_audio_item(session: &Session, id: SpotifyId) -> Result<AudioItem, MercuryError>;
}
#[async_trait]
impl AudioFiles for Track {
async fn get_audio_item(session: &Session, id: SpotifyId) -> Result<AudioItem, MercuryError> {
let item = Self::get(session, id).await?;
Ok(AudioItem {
id,
uri: format!("spotify:track:{}", id.to_base62()),
files: item.files,
name: item.name,
duration: item.duration,
available: item.available,
alternatives: Some(item.alternatives),
})
}
}
#[async_trait]
impl AudioFiles for Episode {
async fn get_audio_item(session: &Session, id: SpotifyId) -> Result<AudioItem, MercuryError> {
let item = Self::get(session, id).await?;
Ok(AudioItem {
id,
uri: format!("spotify:episode:{}", id.to_base62()),
files: item.files,
name: item.name,
duration: item.duration,
available: item.available,
alternatives: None,
})
}
}
pub use album::Album;
pub use artist::Artist;
pub use episode::Episode;
pub use playlist::Playlist;
pub use show::Show;
pub use track::Track;
#[async_trait]
pub trait Metadata: Send + Sized + 'static {
type Message: protobuf::Message;
fn request_url(id: SpotifyId) -> String;
fn parse(msg: &Self::Message, session: &Session) -> Self;
// Request a protobuf
async fn request(session: &Session, id: SpotifyId) -> RequestResult;
async fn get(session: &Session, id: SpotifyId) -> Result<Self, MercuryError> {
let uri = Self::request_url(id);
let response = session.mercury().get(uri).await?;
let data = response.payload.first().expect("Empty payload");
let msg = Self::Message::parse_from_bytes(data).unwrap();
Ok(Self::parse(&msg, &session))
}
}
#[derive(Debug, Clone)]
pub struct Track {
pub id: SpotifyId,
pub name: String,
pub duration: i32,
pub album: SpotifyId,
pub artists: Vec<SpotifyId>,
pub files: HashMap<FileFormat, FileId>,
pub alternatives: Vec<SpotifyId>,
pub available: bool,
}
#[derive(Debug, Clone)]
pub struct Album {
pub id: SpotifyId,
pub name: String,
pub artists: Vec<SpotifyId>,
pub tracks: Vec<SpotifyId>,
pub covers: Vec<FileId>,
}
#[derive(Debug, Clone)]
pub struct Episode {
pub id: SpotifyId,
pub name: String,
pub external_url: String,
pub duration: i32,
pub language: String,
pub show: SpotifyId,
pub files: HashMap<FileFormat, FileId>,
pub covers: Vec<FileId>,
pub available: bool,
pub explicit: bool,
}
#[derive(Debug, Clone)]
pub struct Show {
pub id: SpotifyId,
pub name: String,
pub publisher: String,
pub episodes: Vec<SpotifyId>,
pub covers: Vec<FileId>,
}
#[derive(Debug, Clone)]
pub struct Playlist {
pub revision: Vec<u8>,
pub user: String,
pub name: String,
pub tracks: Vec<SpotifyId>,
}
#[derive(Debug, Clone)]
pub struct Artist {
pub id: SpotifyId,
pub name: String,
pub top_tracks: Vec<SpotifyId>,
}
impl Metadata for Track {
type Message = protocol::metadata::Track;
fn request_url(id: SpotifyId) -> String {
format!("hm://metadata/3/track/{}", id.to_base16())
// Request a metadata struct
async fn get(session: &Session, id: SpotifyId) -> Result<Self, Error> {
let response = Self::request(session, id).await?;
let msg = Self::Message::parse_from_bytes(&response)?;
trace!("Received metadata: {:#?}", msg);
Self::parse(&msg, id)
}
fn parse(msg: &Self::Message, session: &Session) -> Self {
let country = session.country();
let artists = msg
.get_artist()
.iter()
.filter(|artist| artist.has_gid())
.map(|artist| SpotifyId::from_raw(artist.get_gid()).unwrap())
.collect::<Vec<_>>();
let files = msg
.get_file()
.iter()
.filter(|file| file.has_file_id())
.map(|file| {
let mut dst = [0u8; 20];
dst.clone_from_slice(file.get_file_id());
(file.get_format(), FileId(dst))
})
.collect();
Track {
id: SpotifyId::from_raw(msg.get_gid()).unwrap(),
name: msg.get_name().to_owned(),
duration: msg.get_duration(),
album: SpotifyId::from_raw(msg.get_album().get_gid()).unwrap(),
artists,
files,
alternatives: msg
.get_alternative()
.iter()
.map(|alt| SpotifyId::from_raw(alt.get_gid()).unwrap())
.collect(),
available: parse_restrictions(msg.get_restriction(), &country, "premium"),
}
}
}
impl Metadata for Album {
type Message = protocol::metadata::Album;
fn request_url(id: SpotifyId) -> String {
format!("hm://metadata/3/album/{}", id.to_base16())
}
fn parse(msg: &Self::Message, _: &Session) -> Self {
let artists = msg
.get_artist()
.iter()
.filter(|artist| artist.has_gid())
.map(|artist| SpotifyId::from_raw(artist.get_gid()).unwrap())
.collect::<Vec<_>>();
let tracks = msg
.get_disc()
.iter()
.flat_map(|disc| disc.get_track())
.filter(|track| track.has_gid())
.map(|track| SpotifyId::from_raw(track.get_gid()).unwrap())
.collect::<Vec<_>>();
let covers = msg
.get_cover_group()
.get_image()
.iter()
.filter(|image| image.has_file_id())
.map(|image| {
let mut dst = [0u8; 20];
dst.clone_from_slice(image.get_file_id());
FileId(dst)
})
.collect::<Vec<_>>();
Album {
id: SpotifyId::from_raw(msg.get_gid()).unwrap(),
name: msg.get_name().to_owned(),
artists,
tracks,
covers,
}
}
}
impl Metadata for Playlist {
type Message = protocol::playlist4changes::SelectedListContent;
fn request_url(id: SpotifyId) -> String {
format!("hm://playlist/v2/playlist/{}", id.to_base62())
}
fn parse(msg: &Self::Message, _: &Session) -> Self {
let tracks = msg
.get_contents()
.get_items()
.iter()
.map(|item| {
let uri_split = item.get_uri().split(':');
let uri_parts: Vec<&str> = uri_split.collect();
SpotifyId::from_base62(uri_parts[2]).unwrap()
})
.collect::<Vec<_>>();
if tracks.len() != msg.get_length() as usize {
warn!(
"Got {} tracks, but the playlist should contain {} tracks.",
tracks.len(),
msg.get_length()
);
}
Playlist {
revision: msg.get_revision().to_vec(),
name: msg.get_attributes().get_name().to_owned(),
tracks,
user: msg.get_owner_username().to_string(),
}
}
}
impl Metadata for Artist {
type Message = protocol::metadata::Artist;
fn request_url(id: SpotifyId) -> String {
format!("hm://metadata/3/artist/{}", id.to_base16())
}
fn parse(msg: &Self::Message, session: &Session) -> Self {
let country = session.country();
let top_tracks: Vec<SpotifyId> = match msg
.get_top_track()
.iter()
.find(|tt| !tt.has_country() || countrylist_contains(tt.get_country(), &country))
{
Some(tracks) => tracks
.get_track()
.iter()
.filter(|track| track.has_gid())
.map(|track| SpotifyId::from_raw(track.get_gid()).unwrap())
.collect::<Vec<_>>(),
None => Vec::new(),
};
Artist {
id: SpotifyId::from_raw(msg.get_gid()).unwrap(),
name: msg.get_name().to_owned(),
top_tracks,
}
}
}
// Podcast
impl Metadata for Episode {
type Message = protocol::metadata::Episode;
fn request_url(id: SpotifyId) -> String {
format!("hm://metadata/3/episode/{}", id.to_base16())
}
fn parse(msg: &Self::Message, session: &Session) -> Self {
let country = session.country();
let files = msg
.get_audio()
.iter()
.filter(|file| file.has_file_id())
.map(|file| {
let mut dst = [0u8; 20];
dst.clone_from_slice(file.get_file_id());
(file.get_format(), FileId(dst))
})
.collect();
let covers = msg
.get_cover_image()
.get_image()
.iter()
.filter(|image| image.has_file_id())
.map(|image| {
let mut dst = [0u8; 20];
dst.clone_from_slice(image.get_file_id());
FileId(dst)
})
.collect::<Vec<_>>();
Episode {
id: SpotifyId::from_raw(msg.get_gid()).unwrap(),
name: msg.get_name().to_owned(),
external_url: msg.get_external_url().to_owned(),
duration: msg.get_duration().to_owned(),
language: msg.get_language().to_owned(),
show: SpotifyId::from_raw(msg.get_show().get_gid()).unwrap(),
covers,
files,
available: parse_restrictions(msg.get_restriction(), &country, "premium"),
explicit: msg.get_explicit().to_owned(),
}
}
}
impl Metadata for Show {
type Message = protocol::metadata::Show;
fn request_url(id: SpotifyId) -> String {
format!("hm://metadata/3/show/{}", id.to_base16())
}
fn parse(msg: &Self::Message, _: &Session) -> Self {
let episodes = msg
.get_episode()
.iter()
.filter(|episode| episode.has_gid())
.map(|episode| SpotifyId::from_raw(episode.get_gid()).unwrap())
.collect::<Vec<_>>();
let covers = msg
.get_cover_image()
.get_image()
.iter()
.filter(|image| image.has_file_id())
.map(|image| {
let mut dst = [0u8; 20];
dst.clone_from_slice(image.get_file_id());
FileId(dst)
})
.collect::<Vec<_>>();
Show {
id: SpotifyId::from_raw(msg.get_gid()).unwrap(),
name: msg.get_name().to_owned(),
publisher: msg.get_publisher().to_owned(),
episodes,
covers,
}
}
}
struct StrChunks<'s>(&'s str, usize);
trait StrChunksExt {
fn chunks(&self, size: usize) -> StrChunks;
}
impl StrChunksExt for str {
fn chunks(&self, size: usize) -> StrChunks {
StrChunks(self, size)
}
}
impl<'s> Iterator for StrChunks<'s> {
type Item = &'s str;
fn next(&mut self) -> Option<&'s str> {
let &mut StrChunks(data, size) = self;
if data.is_empty() {
None
} else {
let ret = Some(&data[..size]);
self.0 = &data[size..];
ret
}
}
fn parse(msg: &Self::Message, _: SpotifyId) -> Result<Self, Error>;
}

View file

@ -0,0 +1,87 @@
use std::convert::{TryFrom, TryInto};
use std::fmt::Debug;
use protobuf::Message;
use crate::{
image::TranscodedPictures,
request::{MercuryRequest, RequestResult},
Metadata,
};
use librespot_core::{Error, Session, SpotifyId};
use librespot_protocol as protocol;
pub use protocol::playlist_annotate3::AbuseReportState;
#[derive(Debug, Clone)]
pub struct PlaylistAnnotation {
pub description: String,
pub picture: String,
pub transcoded_pictures: TranscodedPictures,
pub has_abuse_reporting: bool,
pub abuse_report_state: AbuseReportState,
}
#[async_trait]
impl Metadata for PlaylistAnnotation {
type Message = protocol::playlist_annotate3::PlaylistAnnotation;
async fn request(session: &Session, playlist_id: SpotifyId) -> RequestResult {
let current_user = session.username();
Self::request_for_user(session, &current_user, playlist_id).await
}
fn parse(msg: &Self::Message, _: SpotifyId) -> Result<Self, Error> {
Ok(Self {
description: msg.get_description().to_owned(),
picture: msg.get_picture().to_owned(), // TODO: is this a URL or Spotify URI?
transcoded_pictures: msg.get_transcoded_picture().try_into()?,
has_abuse_reporting: msg.get_is_abuse_reporting_enabled(),
abuse_report_state: msg.get_abuse_report_state(),
})
}
}
impl PlaylistAnnotation {
async fn request_for_user(
session: &Session,
username: &str,
playlist_id: SpotifyId,
) -> RequestResult {
let uri = format!(
"hm://playlist-annotate/v1/annotation/user/{}/playlist/{}",
username,
playlist_id.to_base62()
);
<Self as MercuryRequest>::request(session, &uri).await
}
#[allow(dead_code)]
async fn get_for_user(
session: &Session,
username: &str,
playlist_id: SpotifyId,
) -> Result<Self, Error> {
let response = Self::request_for_user(session, username, playlist_id).await?;
let msg = <Self as Metadata>::Message::parse_from_bytes(&response)?;
Self::parse(&msg, playlist_id)
}
}
impl MercuryRequest for PlaylistAnnotation {}
impl TryFrom<&<PlaylistAnnotation as Metadata>::Message> for PlaylistAnnotation {
type Error = librespot_core::Error;
fn try_from(
annotation: &<PlaylistAnnotation as Metadata>::Message,
) -> Result<Self, Self::Error> {
Ok(Self {
description: annotation.get_description().to_owned(),
picture: annotation.get_picture().to_owned(),
transcoded_pictures: annotation.get_transcoded_picture().try_into()?,
has_abuse_reporting: annotation.get_is_abuse_reporting_enabled(),
abuse_report_state: annotation.get_abuse_report_state(),
})
}
}

View file

@ -0,0 +1,196 @@
use std::{
collections::HashMap,
convert::{TryFrom, TryInto},
fmt::Debug,
ops::Deref,
};
use crate::{image::PictureSizes, util::from_repeated_enum};
use librespot_core::{date::Date, SpotifyId};
use librespot_protocol as protocol;
use protocol::playlist4_external::FormatListAttribute as PlaylistFormatAttributeMessage;
pub use protocol::playlist4_external::ItemAttributeKind as PlaylistItemAttributeKind;
use protocol::playlist4_external::ItemAttributes as PlaylistItemAttributesMessage;
use protocol::playlist4_external::ItemAttributesPartialState as PlaylistPartialItemAttributesMessage;
pub use protocol::playlist4_external::ListAttributeKind as PlaylistAttributeKind;
use protocol::playlist4_external::ListAttributes as PlaylistAttributesMessage;
use protocol::playlist4_external::ListAttributesPartialState as PlaylistPartialAttributesMessage;
use protocol::playlist4_external::UpdateItemAttributes as PlaylistUpdateItemAttributesMessage;
use protocol::playlist4_external::UpdateListAttributes as PlaylistUpdateAttributesMessage;
#[derive(Debug, Clone)]
pub struct PlaylistAttributes {
pub name: String,
pub description: String,
pub picture: SpotifyId,
pub is_collaborative: bool,
pub pl3_version: String,
pub is_deleted_by_owner: bool,
pub client_id: String,
pub format: String,
pub format_attributes: PlaylistFormatAttribute,
pub picture_sizes: PictureSizes,
}
#[derive(Debug, Clone)]
pub struct PlaylistAttributeKinds(pub Vec<PlaylistAttributeKind>);
impl Deref for PlaylistAttributeKinds {
type Target = Vec<PlaylistAttributeKind>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
from_repeated_enum!(PlaylistAttributeKind, PlaylistAttributeKinds);
#[derive(Debug, Clone)]
pub struct PlaylistFormatAttribute(pub HashMap<String, String>);
impl Deref for PlaylistFormatAttribute {
type Target = HashMap<String, String>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
#[derive(Debug, Clone)]
pub struct PlaylistItemAttributes {
pub added_by: String,
pub timestamp: Date,
pub seen_at: Date,
pub is_public: bool,
pub format_attributes: PlaylistFormatAttribute,
pub item_id: SpotifyId,
}
#[derive(Debug, Clone)]
pub struct PlaylistItemAttributeKinds(pub Vec<PlaylistItemAttributeKind>);
impl Deref for PlaylistItemAttributeKinds {
type Target = Vec<PlaylistItemAttributeKind>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
from_repeated_enum!(PlaylistItemAttributeKind, PlaylistItemAttributeKinds);
#[derive(Debug, Clone)]
pub struct PlaylistPartialAttributes {
#[allow(dead_code)]
values: PlaylistAttributes,
#[allow(dead_code)]
no_value: PlaylistAttributeKinds,
}
#[derive(Debug, Clone)]
pub struct PlaylistPartialItemAttributes {
#[allow(dead_code)]
values: PlaylistItemAttributes,
#[allow(dead_code)]
no_value: PlaylistItemAttributeKinds,
}
#[derive(Debug, Clone)]
pub struct PlaylistUpdateAttributes {
pub new_attributes: PlaylistPartialAttributes,
pub old_attributes: PlaylistPartialAttributes,
}
#[derive(Debug, Clone)]
pub struct PlaylistUpdateItemAttributes {
pub index: i32,
pub new_attributes: PlaylistPartialItemAttributes,
pub old_attributes: PlaylistPartialItemAttributes,
}
impl TryFrom<&PlaylistAttributesMessage> for PlaylistAttributes {
type Error = librespot_core::Error;
fn try_from(attributes: &PlaylistAttributesMessage) -> Result<Self, Self::Error> {
Ok(Self {
name: attributes.get_name().to_owned(),
description: attributes.get_description().to_owned(),
picture: attributes.get_picture().try_into()?,
is_collaborative: attributes.get_collaborative(),
pl3_version: attributes.get_pl3_version().to_owned(),
is_deleted_by_owner: attributes.get_deleted_by_owner(),
client_id: attributes.get_client_id().to_owned(),
format: attributes.get_format().to_owned(),
format_attributes: attributes.get_format_attributes().into(),
picture_sizes: attributes.get_picture_size().into(),
})
}
}
impl From<&[PlaylistFormatAttributeMessage]> for PlaylistFormatAttribute {
fn from(attributes: &[PlaylistFormatAttributeMessage]) -> Self {
let format_attributes = attributes
.iter()
.map(|attribute| {
(
attribute.get_key().to_owned(),
attribute.get_value().to_owned(),
)
})
.collect();
PlaylistFormatAttribute(format_attributes)
}
}
impl TryFrom<&PlaylistItemAttributesMessage> for PlaylistItemAttributes {
type Error = librespot_core::Error;
fn try_from(attributes: &PlaylistItemAttributesMessage) -> Result<Self, Self::Error> {
Ok(Self {
added_by: attributes.get_added_by().to_owned(),
timestamp: attributes.get_timestamp().try_into()?,
seen_at: attributes.get_seen_at().try_into()?,
is_public: attributes.get_public(),
format_attributes: attributes.get_format_attributes().into(),
item_id: attributes.get_item_id().try_into()?,
})
}
}
impl TryFrom<&PlaylistPartialAttributesMessage> for PlaylistPartialAttributes {
type Error = librespot_core::Error;
fn try_from(attributes: &PlaylistPartialAttributesMessage) -> Result<Self, Self::Error> {
Ok(Self {
values: attributes.get_values().try_into()?,
no_value: attributes.get_no_value().into(),
})
}
}
impl TryFrom<&PlaylistPartialItemAttributesMessage> for PlaylistPartialItemAttributes {
type Error = librespot_core::Error;
fn try_from(attributes: &PlaylistPartialItemAttributesMessage) -> Result<Self, Self::Error> {
Ok(Self {
values: attributes.get_values().try_into()?,
no_value: attributes.get_no_value().into(),
})
}
}
impl TryFrom<&PlaylistUpdateAttributesMessage> for PlaylistUpdateAttributes {
type Error = librespot_core::Error;
fn try_from(update: &PlaylistUpdateAttributesMessage) -> Result<Self, Self::Error> {
Ok(Self {
new_attributes: update.get_new_attributes().try_into()?,
old_attributes: update.get_old_attributes().try_into()?,
})
}
}
impl TryFrom<&PlaylistUpdateItemAttributesMessage> for PlaylistUpdateItemAttributes {
type Error = librespot_core::Error;
fn try_from(update: &PlaylistUpdateItemAttributesMessage) -> Result<Self, Self::Error> {
Ok(Self {
index: update.get_index(),
new_attributes: update.get_new_attributes().try_into()?,
old_attributes: update.get_old_attributes().try_into()?,
})
}
}

View file

@ -0,0 +1,29 @@
use std::{
convert::{TryFrom, TryInto},
fmt::Debug,
};
use super::operation::PlaylistOperations;
use librespot_core::SpotifyId;
use librespot_protocol as protocol;
use protocol::playlist4_external::Diff as DiffMessage;
#[derive(Debug, Clone)]
pub struct PlaylistDiff {
pub from_revision: SpotifyId,
pub operations: PlaylistOperations,
pub to_revision: SpotifyId,
}
impl TryFrom<&DiffMessage> for PlaylistDiff {
type Error = librespot_core::Error;
fn try_from(diff: &DiffMessage) -> Result<Self, Self::Error> {
Ok(Self {
from_revision: diff.get_from_revision().try_into()?,
operations: diff.get_ops().try_into()?,
to_revision: diff.get_to_revision().try_into()?,
})
}
}

View file

@ -0,0 +1,105 @@
use std::{
convert::{TryFrom, TryInto},
fmt::Debug,
ops::Deref,
};
use crate::util::try_from_repeated_message;
use super::{
attribute::{PlaylistAttributes, PlaylistItemAttributes},
permission::Capabilities,
};
use librespot_core::{date::Date, SpotifyId};
use librespot_protocol as protocol;
use protocol::playlist4_external::Item as PlaylistItemMessage;
use protocol::playlist4_external::ListItems as PlaylistItemsMessage;
use protocol::playlist4_external::MetaItem as PlaylistMetaItemMessage;
#[derive(Debug, Clone)]
pub struct PlaylistItem {
pub id: SpotifyId,
pub attributes: PlaylistItemAttributes,
}
#[derive(Debug, Clone)]
pub struct PlaylistItems(pub Vec<PlaylistItem>);
impl Deref for PlaylistItems {
type Target = Vec<PlaylistItem>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
#[derive(Debug, Clone)]
pub struct PlaylistItemList {
pub position: i32,
pub is_truncated: bool,
pub items: PlaylistItems,
pub meta_items: PlaylistMetaItems,
}
#[derive(Debug, Clone)]
pub struct PlaylistMetaItem {
pub revision: SpotifyId,
pub attributes: PlaylistAttributes,
pub length: i32,
pub timestamp: Date,
pub owner_username: String,
pub has_abuse_reporting: bool,
pub capabilities: Capabilities,
}
#[derive(Debug, Clone)]
pub struct PlaylistMetaItems(pub Vec<PlaylistMetaItem>);
impl Deref for PlaylistMetaItems {
type Target = Vec<PlaylistMetaItem>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl TryFrom<&PlaylistItemMessage> for PlaylistItem {
type Error = librespot_core::Error;
fn try_from(item: &PlaylistItemMessage) -> Result<Self, Self::Error> {
Ok(Self {
id: item.try_into()?,
attributes: item.get_attributes().try_into()?,
})
}
}
try_from_repeated_message!(PlaylistItemMessage, PlaylistItems);
impl TryFrom<&PlaylistItemsMessage> for PlaylistItemList {
type Error = librespot_core::Error;
fn try_from(list_items: &PlaylistItemsMessage) -> Result<Self, Self::Error> {
Ok(Self {
position: list_items.get_pos(),
is_truncated: list_items.get_truncated(),
items: list_items.get_items().try_into()?,
meta_items: list_items.get_meta_items().try_into()?,
})
}
}
impl TryFrom<&PlaylistMetaItemMessage> for PlaylistMetaItem {
type Error = librespot_core::Error;
fn try_from(item: &PlaylistMetaItemMessage) -> Result<Self, Self::Error> {
Ok(Self {
revision: item.try_into()?,
attributes: item.get_attributes().try_into()?,
length: item.get_length(),
timestamp: item.get_timestamp().try_into()?,
owner_username: item.get_owner_username().to_owned(),
has_abuse_reporting: item.get_abuse_reporting_enabled(),
capabilities: item.get_capabilities().into(),
})
}
}
try_from_repeated_message!(PlaylistMetaItemMessage, PlaylistMetaItems);

View file

@ -0,0 +1,225 @@
use std::{
convert::{TryFrom, TryInto},
fmt::Debug,
ops::Deref,
};
use protobuf::Message;
use crate::{
request::{MercuryRequest, RequestResult},
util::{from_repeated_enum, try_from_repeated_message},
Metadata,
};
use super::{
attribute::PlaylistAttributes, diff::PlaylistDiff, item::PlaylistItemList,
permission::Capabilities,
};
use librespot_core::{
date::Date,
spotify_id::{NamedSpotifyId, SpotifyId},
Error, Session,
};
use librespot_protocol as protocol;
use protocol::playlist4_external::GeoblockBlockingType as Geoblock;
#[derive(Debug, Clone)]
pub struct Geoblocks(Vec<Geoblock>);
impl Deref for Geoblocks {
type Target = Vec<Geoblock>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
#[derive(Debug, Clone)]
pub struct Playlist {
pub id: NamedSpotifyId,
pub revision: SpotifyId,
pub length: i32,
pub attributes: PlaylistAttributes,
pub contents: PlaylistItemList,
pub diff: PlaylistDiff,
pub sync_result: PlaylistDiff,
pub resulting_revisions: Playlists,
pub has_multiple_heads: bool,
pub is_up_to_date: bool,
pub nonces: Vec<i64>,
pub timestamp: Date,
pub has_abuse_reporting: bool,
pub capabilities: Capabilities,
pub geoblocks: Geoblocks,
}
#[derive(Debug, Clone)]
pub struct Playlists(pub Vec<SpotifyId>);
impl Deref for Playlists {
type Target = Vec<SpotifyId>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
#[derive(Debug, Clone)]
pub struct RootPlaylist(pub SelectedListContent);
impl Deref for RootPlaylist {
type Target = SelectedListContent;
fn deref(&self) -> &Self::Target {
&self.0
}
}
#[derive(Debug, Clone)]
pub struct SelectedListContent {
pub revision: SpotifyId,
pub length: i32,
pub attributes: PlaylistAttributes,
pub contents: PlaylistItemList,
pub diff: PlaylistDiff,
pub sync_result: PlaylistDiff,
pub resulting_revisions: Playlists,
pub has_multiple_heads: bool,
pub is_up_to_date: bool,
pub nonces: Vec<i64>,
pub timestamp: Date,
pub owner_username: String,
pub has_abuse_reporting: bool,
pub capabilities: Capabilities,
pub geoblocks: Geoblocks,
}
impl Playlist {
#[allow(dead_code)]
async fn request_for_user(
session: &Session,
username: &str,
playlist_id: SpotifyId,
) -> RequestResult {
let uri = format!(
"hm://playlist/user/{}/playlist/{}",
username,
playlist_id.to_base62()
);
<Self as MercuryRequest>::request(session, &uri).await
}
#[allow(dead_code)]
pub async fn get_for_user(
session: &Session,
username: &str,
playlist_id: SpotifyId,
) -> Result<Self, Error> {
let response = Self::request_for_user(session, username, playlist_id).await?;
let msg = <Self as Metadata>::Message::parse_from_bytes(&response)?;
Self::parse(&msg, playlist_id)
}
pub fn tracks(&self) -> Vec<SpotifyId> {
let tracks = self
.contents
.items
.iter()
.map(|item| item.id)
.collect::<Vec<_>>();
let length = tracks.len();
let expected_length = self.length as usize;
if length != expected_length {
warn!(
"Got {} tracks, but the list should contain {} tracks.",
length, expected_length,
);
}
tracks
}
pub fn name(&self) -> &str {
&self.attributes.name
}
}
impl MercuryRequest for Playlist {}
#[async_trait]
impl Metadata for Playlist {
type Message = protocol::playlist4_external::SelectedListContent;
async fn request(session: &Session, playlist_id: SpotifyId) -> RequestResult {
let uri = format!("hm://playlist/v2/playlist/{}", playlist_id.to_base62());
<Self as MercuryRequest>::request(session, &uri).await
}
fn parse(msg: &Self::Message, id: SpotifyId) -> Result<Self, Error> {
// the playlist proto doesn't contain the id so we decorate it
let playlist = SelectedListContent::try_from(msg)?;
let id = NamedSpotifyId::from_spotify_id(id, playlist.owner_username);
Ok(Self {
id,
revision: playlist.revision,
length: playlist.length,
attributes: playlist.attributes,
contents: playlist.contents,
diff: playlist.diff,
sync_result: playlist.sync_result,
resulting_revisions: playlist.resulting_revisions,
has_multiple_heads: playlist.has_multiple_heads,
is_up_to_date: playlist.is_up_to_date,
nonces: playlist.nonces,
timestamp: playlist.timestamp,
has_abuse_reporting: playlist.has_abuse_reporting,
capabilities: playlist.capabilities,
geoblocks: playlist.geoblocks,
})
}
}
impl MercuryRequest for RootPlaylist {}
impl RootPlaylist {
#[allow(dead_code)]
async fn request_for_user(session: &Session, username: &str) -> RequestResult {
let uri = format!("hm://playlist/user/{}/rootlist", username,);
<Self as MercuryRequest>::request(session, &uri).await
}
#[allow(dead_code)]
pub async fn get_root_for_user(session: &Session, username: &str) -> Result<Self, Error> {
let response = Self::request_for_user(session, username).await?;
let msg = protocol::playlist4_external::SelectedListContent::parse_from_bytes(&response)?;
Ok(Self(SelectedListContent::try_from(&msg)?))
}
}
impl TryFrom<&<Playlist as Metadata>::Message> for SelectedListContent {
type Error = librespot_core::Error;
fn try_from(playlist: &<Playlist as Metadata>::Message) -> Result<Self, Self::Error> {
Ok(Self {
revision: playlist.get_revision().try_into()?,
length: playlist.get_length(),
attributes: playlist.get_attributes().try_into()?,
contents: playlist.get_contents().try_into()?,
diff: playlist.get_diff().try_into()?,
sync_result: playlist.get_sync_result().try_into()?,
resulting_revisions: playlist.get_resulting_revisions().try_into()?,
has_multiple_heads: playlist.get_multiple_heads(),
is_up_to_date: playlist.get_up_to_date(),
nonces: playlist.get_nonces().into(),
timestamp: playlist.get_timestamp().try_into()?,
owner_username: playlist.get_owner_username().to_owned(),
has_abuse_reporting: playlist.get_abuse_reporting_enabled(),
capabilities: playlist.get_capabilities().into(),
geoblocks: playlist.get_geoblock().into(),
})
}
}
from_repeated_enum!(Geoblock, Geoblocks);
try_from_repeated_message!(Vec<u8>, Playlists);

View file

@ -0,0 +1,10 @@
pub mod annotation;
pub mod attribute;
pub mod diff;
pub mod item;
pub mod list;
pub mod operation;
pub mod permission;
pub use annotation::PlaylistAnnotation;
pub use list::Playlist;

View file

@ -0,0 +1,113 @@
use std::{
convert::{TryFrom, TryInto},
fmt::Debug,
ops::Deref,
};
use crate::{
playlist::{
attribute::{PlaylistUpdateAttributes, PlaylistUpdateItemAttributes},
item::PlaylistItems,
},
util::try_from_repeated_message,
};
use librespot_protocol as protocol;
use protocol::playlist4_external::Add as PlaylistAddMessage;
use protocol::playlist4_external::Mov as PlaylistMoveMessage;
use protocol::playlist4_external::Op as PlaylistOperationMessage;
pub use protocol::playlist4_external::Op_Kind as PlaylistOperationKind;
use protocol::playlist4_external::Rem as PlaylistRemoveMessage;
#[derive(Debug, Clone)]
pub struct PlaylistOperation {
pub kind: PlaylistOperationKind,
pub add: PlaylistOperationAdd,
pub rem: PlaylistOperationRemove,
pub mov: PlaylistOperationMove,
pub update_item_attributes: PlaylistUpdateItemAttributes,
pub update_list_attributes: PlaylistUpdateAttributes,
}
#[derive(Debug, Clone)]
pub struct PlaylistOperations(pub Vec<PlaylistOperation>);
impl Deref for PlaylistOperations {
type Target = Vec<PlaylistOperation>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
#[derive(Debug, Clone)]
pub struct PlaylistOperationAdd {
pub from_index: i32,
pub items: PlaylistItems,
pub add_last: bool,
pub add_first: bool,
}
#[derive(Debug, Clone)]
pub struct PlaylistOperationMove {
pub from_index: i32,
pub length: i32,
pub to_index: i32,
}
#[derive(Debug, Clone)]
pub struct PlaylistOperationRemove {
pub from_index: i32,
pub length: i32,
pub items: PlaylistItems,
pub has_items_as_key: bool,
}
impl TryFrom<&PlaylistOperationMessage> for PlaylistOperation {
type Error = librespot_core::Error;
fn try_from(operation: &PlaylistOperationMessage) -> Result<Self, Self::Error> {
Ok(Self {
kind: operation.get_kind(),
add: operation.get_add().try_into()?,
rem: operation.get_rem().try_into()?,
mov: operation.get_mov().into(),
update_item_attributes: operation.get_update_item_attributes().try_into()?,
update_list_attributes: operation.get_update_list_attributes().try_into()?,
})
}
}
try_from_repeated_message!(PlaylistOperationMessage, PlaylistOperations);
impl TryFrom<&PlaylistAddMessage> for PlaylistOperationAdd {
type Error = librespot_core::Error;
fn try_from(add: &PlaylistAddMessage) -> Result<Self, Self::Error> {
Ok(Self {
from_index: add.get_from_index(),
items: add.get_items().try_into()?,
add_last: add.get_add_last(),
add_first: add.get_add_first(),
})
}
}
impl From<&PlaylistMoveMessage> for PlaylistOperationMove {
fn from(mov: &PlaylistMoveMessage) -> Self {
Self {
from_index: mov.get_from_index(),
length: mov.get_length(),
to_index: mov.get_to_index(),
}
}
}
impl TryFrom<&PlaylistRemoveMessage> for PlaylistOperationRemove {
type Error = librespot_core::Error;
fn try_from(remove: &PlaylistRemoveMessage) -> Result<Self, Self::Error> {
Ok(Self {
from_index: remove.get_from_index(),
length: remove.get_length(),
items: remove.get_items().try_into()?,
has_items_as_key: remove.get_items_as_key(),
})
}
}

View file

@ -0,0 +1,42 @@
use std::{fmt::Debug, ops::Deref};
use crate::util::from_repeated_enum;
use librespot_protocol as protocol;
use protocol::playlist_permission::Capabilities as CapabilitiesMessage;
use protocol::playlist_permission::PermissionLevel;
#[derive(Debug, Clone)]
pub struct Capabilities {
pub can_view: bool,
pub can_administrate_permissions: bool,
pub grantable_levels: PermissionLevels,
pub can_edit_metadata: bool,
pub can_edit_items: bool,
pub can_cancel_membership: bool,
}
#[derive(Debug, Clone)]
pub struct PermissionLevels(pub Vec<PermissionLevel>);
impl Deref for PermissionLevels {
type Target = Vec<PermissionLevel>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl From<&CapabilitiesMessage> for Capabilities {
fn from(playlist: &CapabilitiesMessage) -> Self {
Self {
can_view: playlist.get_can_view(),
can_administrate_permissions: playlist.get_can_administrate_permissions(),
grantable_levels: playlist.get_grantable_level().into(),
can_edit_metadata: playlist.get_can_edit_metadata(),
can_edit_items: playlist.get_can_edit_items(),
can_cancel_membership: playlist.get_can_cancel_membership(),
}
}
}
from_repeated_enum!(PermissionLevel, PermissionLevels);

21
metadata/src/request.rs Normal file
View file

@ -0,0 +1,21 @@
use crate::MetadataError;
use librespot_core::{Error, Session};
pub type RequestResult = Result<bytes::Bytes, Error>;
#[async_trait]
pub trait MercuryRequest {
async fn request(session: &Session, uri: &str) -> RequestResult {
let request = session.mercury().get(uri)?;
let response = request.await?;
match response.payload.first() {
Some(data) => {
let data = data.to_vec().into();
trace!("Received metadata: {:?}", data);
Ok(data)
}
None => Err(Error::unavailable(MetadataError::Empty)),
}
}
}

104
metadata/src/restriction.rs Normal file
View file

@ -0,0 +1,104 @@
use std::{fmt::Debug, ops::Deref};
use crate::util::{from_repeated_enum, from_repeated_message};
use protocol::metadata::Restriction as RestrictionMessage;
use librespot_protocol as protocol;
pub use protocol::metadata::Restriction_Catalogue as RestrictionCatalogue;
pub use protocol::metadata::Restriction_Type as RestrictionType;
#[derive(Debug, Clone)]
pub struct Restriction {
pub catalogues: RestrictionCatalogues,
pub restriction_type: RestrictionType,
pub catalogue_strs: Vec<String>,
pub countries_allowed: Option<Vec<String>>,
pub countries_forbidden: Option<Vec<String>>,
}
#[derive(Debug, Clone)]
pub struct Restrictions(pub Vec<Restriction>);
impl Deref for Restrictions {
type Target = Vec<Restriction>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
#[derive(Debug, Clone)]
pub struct RestrictionCatalogues(pub Vec<RestrictionCatalogue>);
impl Deref for RestrictionCatalogues {
type Target = Vec<RestrictionCatalogue>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl Restriction {
fn parse_country_codes(country_codes: &str) -> Vec<String> {
country_codes
.chunks(2)
.map(|country_code| country_code.to_owned())
.collect()
}
}
impl From<&RestrictionMessage> for Restriction {
fn from(restriction: &RestrictionMessage) -> Self {
let countries_allowed = if restriction.has_countries_allowed() {
Some(Self::parse_country_codes(
restriction.get_countries_allowed(),
))
} else {
None
};
let countries_forbidden = if restriction.has_countries_forbidden() {
Some(Self::parse_country_codes(
restriction.get_countries_forbidden(),
))
} else {
None
};
Self {
catalogues: restriction.get_catalogue().into(),
restriction_type: restriction.get_field_type(),
catalogue_strs: restriction.get_catalogue_str().to_vec(),
countries_allowed,
countries_forbidden,
}
}
}
from_repeated_message!(RestrictionMessage, Restrictions);
from_repeated_enum!(RestrictionCatalogue, RestrictionCatalogues);
struct StrChunks<'s>(&'s str, usize);
trait StrChunksExt {
fn chunks(&self, size: usize) -> StrChunks;
}
impl StrChunksExt for str {
fn chunks(&self, size: usize) -> StrChunks {
StrChunks(self, size)
}
}
impl<'s> Iterator for StrChunks<'s> {
type Item = &'s str;
fn next(&mut self) -> Option<&'s str> {
let &mut StrChunks(data, size) = self;
if data.is_empty() {
None
} else {
let ret = Some(&data[..size]);
self.0 = &data[size..];
ret
}
}
}

View file

@ -0,0 +1,37 @@
use std::{fmt::Debug, ops::Deref};
use crate::{restriction::Restrictions, util::from_repeated_message};
use librespot_core::date::Date;
use librespot_protocol as protocol;
use protocol::metadata::SalePeriod as SalePeriodMessage;
#[derive(Debug, Clone)]
pub struct SalePeriod {
pub restrictions: Restrictions,
pub start: Date,
pub end: Date,
}
#[derive(Debug, Clone)]
pub struct SalePeriods(pub Vec<SalePeriod>);
impl Deref for SalePeriods {
type Target = Vec<SalePeriod>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl From<&SalePeriodMessage> for SalePeriod {
fn from(sale_period: &SalePeriodMessage) -> Self {
Self {
restrictions: sale_period.get_restriction().into(),
start: sale_period.get_start().into(),
end: sale_period.get_end().into(),
}
}
}
from_repeated_message!(SalePeriodMessage, SalePeriods);

74
metadata/src/show.rs Normal file
View file

@ -0,0 +1,74 @@
use std::{
convert::{TryFrom, TryInto},
fmt::Debug,
};
use crate::{
availability::Availabilities, copyright::Copyrights, episode::Episodes, image::Images,
restriction::Restrictions, Metadata, RequestResult,
};
use librespot_core::{Error, Session, SpotifyId};
use librespot_protocol as protocol;
pub use protocol::metadata::Show_ConsumptionOrder as ShowConsumptionOrder;
pub use protocol::metadata::Show_MediaType as ShowMediaType;
#[derive(Debug, Clone)]
pub struct Show {
pub id: SpotifyId,
pub name: String,
pub description: String,
pub publisher: String,
pub language: String,
pub is_explicit: bool,
pub covers: Images,
pub episodes: Episodes,
pub copyrights: Copyrights,
pub restrictions: Restrictions,
pub keywords: Vec<String>,
pub media_type: ShowMediaType,
pub consumption_order: ShowConsumptionOrder,
pub availability: Availabilities,
pub trailer_uri: SpotifyId,
pub has_music_and_talk: bool,
pub is_audiobook: bool,
}
#[async_trait]
impl Metadata for Show {
type Message = protocol::metadata::Show;
async fn request(session: &Session, show_id: SpotifyId) -> RequestResult {
session.spclient().get_show_metadata(show_id).await
}
fn parse(msg: &Self::Message, _: SpotifyId) -> Result<Self, Error> {
Self::try_from(msg)
}
}
impl TryFrom<&<Self as Metadata>::Message> for Show {
type Error = librespot_core::Error;
fn try_from(show: &<Self as Metadata>::Message) -> Result<Self, Self::Error> {
Ok(Self {
id: show.try_into()?,
name: show.get_name().to_owned(),
description: show.get_description().to_owned(),
publisher: show.get_publisher().to_owned(),
language: show.get_language().to_owned(),
is_explicit: show.get_explicit(),
covers: show.get_cover_image().get_image().into(),
episodes: show.get_episode().try_into()?,
copyrights: show.get_copyright().into(),
restrictions: show.get_restriction().into(),
keywords: show.get_keyword().to_vec(),
media_type: show.get_media_type(),
consumption_order: show.get_consumption_order(),
availability: show.get_availability().into(),
trailer_uri: SpotifyId::from_uri(show.get_trailer_uri())?,
has_music_and_talk: show.get_music_and_talk(),
is_audiobook: show.get_is_audiobook(),
})
}
}

149
metadata/src/track.rs Normal file
View file

@ -0,0 +1,149 @@
use std::{
convert::{TryFrom, TryInto},
fmt::Debug,
ops::Deref,
};
use chrono::Local;
use uuid::Uuid;
use crate::{
artist::{Artists, ArtistsWithRole},
audio::{
file::AudioFiles,
item::{AudioItem, AudioItemResult, InnerAudioItem},
},
availability::{Availabilities, UnavailabilityReason},
content_rating::ContentRatings,
external_id::ExternalIds,
restriction::Restrictions,
sale_period::SalePeriods,
util::try_from_repeated_message,
Metadata, RequestResult,
};
use librespot_core::{date::Date, Error, Session, SpotifyId};
use librespot_protocol as protocol;
#[derive(Debug, Clone)]
pub struct Track {
pub id: SpotifyId,
pub name: String,
pub album: SpotifyId,
pub artists: Artists,
pub number: i32,
pub disc_number: i32,
pub duration: i32,
pub popularity: i32,
pub is_explicit: bool,
pub external_ids: ExternalIds,
pub restrictions: Restrictions,
pub files: AudioFiles,
pub alternatives: Tracks,
pub sale_periods: SalePeriods,
pub previews: AudioFiles,
pub tags: Vec<String>,
pub earliest_live_timestamp: Date,
pub has_lyrics: bool,
pub availability: Availabilities,
pub licensor: Uuid,
pub language_of_performance: Vec<String>,
pub content_ratings: ContentRatings,
pub original_title: String,
pub version_title: String,
pub artists_with_role: ArtistsWithRole,
}
#[derive(Debug, Clone)]
pub struct Tracks(pub Vec<SpotifyId>);
impl Deref for Tracks {
type Target = Vec<SpotifyId>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
#[async_trait]
impl InnerAudioItem for Track {
async fn get_audio_item(session: &Session, id: SpotifyId) -> AudioItemResult {
let track = Self::get(session, id).await?;
let alternatives = {
if track.alternatives.is_empty() {
None
} else {
Some(track.alternatives.clone())
}
};
// TODO: check meaning of earliest_live_timestamp in
let availability = if Local::now() < track.earliest_live_timestamp.as_utc() {
Err(UnavailabilityReason::Embargo)
} else {
Self::available_for_user(
&session.user_data(),
&track.availability,
&track.restrictions,
)
};
Ok(AudioItem {
id,
spotify_uri: id.to_uri(),
files: track.files,
name: track.name,
duration: track.duration,
availability,
alternatives,
})
}
}
#[async_trait]
impl Metadata for Track {
type Message = protocol::metadata::Track;
async fn request(session: &Session, track_id: SpotifyId) -> RequestResult {
session.spclient().get_track_metadata(track_id).await
}
fn parse(msg: &Self::Message, _: SpotifyId) -> Result<Self, Error> {
Self::try_from(msg)
}
}
impl TryFrom<&<Self as Metadata>::Message> for Track {
type Error = librespot_core::Error;
fn try_from(track: &<Self as Metadata>::Message) -> Result<Self, Self::Error> {
Ok(Self {
id: track.try_into()?,
name: track.get_name().to_owned(),
album: track.get_album().try_into()?,
artists: track.get_artist().try_into()?,
number: track.get_number(),
disc_number: track.get_disc_number(),
duration: track.get_duration(),
popularity: track.get_popularity(),
is_explicit: track.get_explicit(),
external_ids: track.get_external_id().into(),
restrictions: track.get_restriction().into(),
files: track.get_file().into(),
alternatives: track.get_alternative().try_into()?,
sale_periods: track.get_sale_period().into(),
previews: track.get_preview().into(),
tags: track.get_tags().to_vec(),
earliest_live_timestamp: track.get_earliest_live_timestamp().try_into()?,
has_lyrics: track.get_has_lyrics(),
availability: track.get_availability().into(),
licensor: Uuid::from_slice(track.get_licensor().get_uuid())
.unwrap_or_else(|_| Uuid::nil()),
language_of_performance: track.get_language_of_performance().to_vec(),
content_ratings: track.get_content_rating().into(),
original_title: track.get_original_title().to_owned(),
version_title: track.get_version_title().to_owned(),
artists_with_role: track.get_artist_with_role().try_into()?,
})
}
}
try_from_repeated_message!(<Track as Metadata>::Message, Tracks);

39
metadata/src/util.rs Normal file
View file

@ -0,0 +1,39 @@
macro_rules! from_repeated_message {
($src:ty, $dst:ty) => {
impl From<&[$src]> for $dst {
fn from(src: &[$src]) -> Self {
let result = src.iter().map(From::from).collect();
Self(result)
}
}
};
}
pub(crate) use from_repeated_message;
macro_rules! from_repeated_enum {
($src:ty, $dst:ty) => {
impl From<&[$src]> for $dst {
fn from(src: &[$src]) -> Self {
let result = src.iter().map(|x| <$src>::from(*x)).collect();
Self(result)
}
}
};
}
pub(crate) use from_repeated_enum;
macro_rules! try_from_repeated_message {
($src:ty, $dst:ty) => {
impl TryFrom<&[$src]> for $dst {
type Error = librespot_core::Error;
fn try_from(src: &[$src]) -> Result<Self, Self::Error> {
let result: Result<Vec<_>, _> = src.iter().map(TryFrom::try_from).collect();
Ok(Self(result?))
}
}
};
}
pub(crate) use try_from_repeated_message;

20
metadata/src/video.rs Normal file
View file

@ -0,0 +1,20 @@
use std::{fmt::Debug, ops::Deref};
use crate::util::from_repeated_message;
use librespot_core::FileId;
use librespot_protocol as protocol;
use protocol::metadata::VideoFile as VideoFileMessage;
#[derive(Debug, Clone)]
pub struct VideoFiles(pub Vec<FileId>);
impl Deref for VideoFiles {
type Target = Vec<FileId>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
from_repeated_message!(VideoFileMessage, VideoFiles);

View file

@ -1,6 +1,6 @@
[package]
name = "librespot-playback"
version = "0.2.0"
version = "0.3.1"
authors = ["Sasha Hilton <sashahilton00@gmail.com>"]
description = "The audio playback logic for librespot"
license = "MIT"
@ -9,13 +9,13 @@ edition = "2018"
[dependencies.librespot-audio]
path = "../audio"
version = "0.2.0"
version = "0.3.1"
[dependencies.librespot-core]
path = "../core"
version = "0.2.0"
version = "0.3.1"
[dependencies.librespot-metadata]
path = "../metadata"
version = "0.2.0"
version = "0.3.1"
[dependencies]
futures-executor = "0.3"
@ -23,7 +23,8 @@ futures-util = { version = "0.3", default_features = false, features = ["alloc"]
log = "0.4"
byteorder = "1.4"
shell-words = "1.0.0"
tokio = { version = "1", features = ["sync"] }
thiserror = "1.0"
tokio = { version = "1", features = ["parking_lot", "rt", "rt-multi-thread", "sync"] }
zerocopy = { version = "0.3" }
# Backends
@ -40,22 +41,21 @@ glib = { version = "0.10", optional = true }
# Rodio dependencies
rodio = { version = "0.14", optional = true, default-features = false }
cpal = { version = "0.13", optional = true }
thiserror = { version = "1", optional = true }
# Decoder
lewton = "0.10"
ogg = "0.8"
# Dithering
rand = "0.8"
rand = { version = "0.8", features = ["small_rng"] }
rand_distr = "0.4"
[features]
alsa-backend = ["alsa", "thiserror"]
alsa-backend = ["alsa"]
portaudio-backend = ["portaudio-rs"]
pulseaudio-backend = ["libpulse-binding", "libpulse-simple-binding"]
jackaudio-backend = ["jack"]
rodio-backend = ["rodio", "cpal", "thiserror"]
rodiojack-backend = ["rodio", "cpal/jack", "thiserror"]
rodio-backend = ["rodio", "cpal"]
rodiojack-backend = ["rodio", "cpal/jack"]
sdl-backend = ["sdl2"]
gstreamer-backend = ["gstreamer", "gstreamer-app", "glib"]

View file

@ -1,51 +1,102 @@
use super::{Open, Sink, SinkAsBytes};
use super::{Open, Sink, SinkAsBytes, SinkError, SinkResult};
use crate::config::AudioFormat;
use crate::convert::Converter;
use crate::decoder::AudioPacket;
use crate::{NUM_CHANNELS, SAMPLE_RATE};
use alsa::device_name::HintIter;
use alsa::pcm::{Access, Format, HwParams, PCM};
use alsa::pcm::{Access, Format, Frames, HwParams, PCM};
use alsa::{Direction, ValueOr};
use std::cmp::min;
use std::io;
use std::process::exit;
use std::time::Duration;
use thiserror::Error;
// 125 ms Period time * 4 periods = 0.5 sec buffer.
const PERIOD_TIME: Duration = Duration::from_millis(125);
const NUM_PERIODS: u32 = 4;
const MAX_BUFFER: Frames = (SAMPLE_RATE / 2) as Frames;
const MIN_BUFFER: Frames = (SAMPLE_RATE / 10) as Frames;
const ZERO_FRAMES: Frames = 0;
const MAX_PERIOD_DIVISOR: Frames = 4;
const MIN_PERIOD_DIVISOR: Frames = 10;
#[derive(Debug, Error)]
enum AlsaError {
#[error("AlsaSink, device {device} may be invalid or busy, {err}")]
PcmSetUp { device: String, err: alsa::Error },
#[error("AlsaSink, device {device} unsupported access type RWInterleaved, {err}")]
UnsupportedAccessType { device: String, err: alsa::Error },
#[error("AlsaSink, device {device} unsupported format {format:?}, {err}")]
#[error("<AlsaSink> Device {device} Unsupported Format {alsa_format:?} ({format:?}), {e}")]
UnsupportedFormat {
device: String,
alsa_format: Format,
format: AudioFormat,
err: alsa::Error,
e: alsa::Error,
},
#[error("AlsaSink, device {device} unsupported sample rate {samplerate}, {err}")]
UnsupportedSampleRate {
device: String,
samplerate: u32,
err: alsa::Error,
},
#[error("AlsaSink, device {device} unsupported channel count {channel_count}, {err}")]
#[error("<AlsaSink> Device {device} Unsupported Channel Count {channel_count}, {e}")]
UnsupportedChannelCount {
device: String,
channel_count: u8,
err: alsa::Error,
e: alsa::Error,
},
#[error("AlsaSink Hardware Parameters Error, {0}")]
#[error("<AlsaSink> Device {device} Unsupported Sample Rate {samplerate}, {e}")]
UnsupportedSampleRate {
device: String,
samplerate: u32,
e: alsa::Error,
},
#[error("<AlsaSink> Device {device} Unsupported Access Type RWInterleaved, {e}")]
UnsupportedAccessType { device: String, e: alsa::Error },
#[error("<AlsaSink> Device {device} May be Invalid, Busy, or Already in Use, {e}")]
PcmSetUp { device: String, e: alsa::Error },
#[error("<AlsaSink> Failed to Drain PCM Buffer, {0}")]
DrainFailure(alsa::Error),
#[error("<AlsaSink> {0}")]
OnWrite(alsa::Error),
#[error("<AlsaSink> Hardware, {0}")]
HwParams(alsa::Error),
#[error("AlsaSink Software Parameters Error, {0}")]
#[error("<AlsaSink> Software, {0}")]
SwParams(alsa::Error),
#[error("AlsaSink PCM Error, {0}")]
#[error("<AlsaSink> PCM, {0}")]
Pcm(alsa::Error),
#[error("<AlsaSink> Could Not Parse Ouput Name(s) and/or Description(s)")]
Parsing,
#[error("<AlsaSink>")]
NotConnected,
}
impl From<AlsaError> for SinkError {
fn from(e: AlsaError) -> SinkError {
use AlsaError::*;
let es = e.to_string();
match e {
DrainFailure(_) | OnWrite(_) => SinkError::OnWrite(es),
PcmSetUp { .. } => SinkError::ConnectionRefused(es),
NotConnected => SinkError::NotConnected(es),
_ => SinkError::InvalidParams(es),
}
}
}
impl From<AudioFormat> for Format {
fn from(f: AudioFormat) -> Format {
use AudioFormat::*;
match f {
F64 => Format::float64(),
F32 => Format::float(),
S32 => Format::s32(),
S24 => Format::s24(),
S16 => Format::s16(),
#[cfg(target_endian = "little")]
S24_3 => Format::S243LE,
#[cfg(target_endian = "big")]
S24_3 => Format::S243BE,
}
}
}
pub struct AlsaSink {
@ -55,26 +106,50 @@ pub struct AlsaSink {
period_buffer: Vec<u8>,
}
fn list_outputs() -> io::Result<()> {
println!("Listing available Alsa outputs:");
for t in &["pcm", "ctl", "hwdep"] {
println!("{} devices:", t);
let i = match HintIter::new_str(None, &t) {
Ok(i) => i,
Err(e) => {
return Err(io::Error::new(io::ErrorKind::Other, e));
}
};
for a in i {
if let Some(Direction::Playback) = a.direction {
// mimic aplay -L
let name = a
.name
.ok_or_else(|| io::Error::new(io::ErrorKind::Other, "Could not parse name"))?;
let desc = a
.desc
.ok_or_else(|| io::Error::new(io::ErrorKind::Other, "Could not parse desc"))?;
println!("{}\n\t{}\n", name, desc.replace("\n", "\n\t"));
fn list_compatible_devices() -> SinkResult<()> {
println!("\n\n\tCompatible alsa device(s):\n");
println!("\t------------------------------------------------------\n");
let i = HintIter::new_str(None, "pcm").map_err(|_| AlsaError::Parsing)?;
for a in i {
if let Some(Direction::Playback) = a.direction {
let name = a.name.ok_or(AlsaError::Parsing)?;
let desc = a.desc.ok_or(AlsaError::Parsing)?;
if let Ok(pcm) = PCM::new(&name, Direction::Playback, false) {
if let Ok(hwp) = HwParams::any(&pcm) {
// Only show devices that support
// 2 ch 44.1 Interleaved.
if hwp.set_access(Access::RWInterleaved).is_ok()
&& hwp.set_rate(SAMPLE_RATE, ValueOr::Nearest).is_ok()
&& hwp.set_channels(NUM_CHANNELS as u32).is_ok()
{
println!("\tDevice:\n\n\t\t{}\n", name);
println!("\tDescription:\n\n\t\t{}\n", desc.replace("\n", "\n\t\t"));
let mut supported_formats = vec![];
for f in &[
AudioFormat::S16,
AudioFormat::S24,
AudioFormat::S24_3,
AudioFormat::S32,
AudioFormat::F32,
AudioFormat::F64,
] {
if hwp.test_format(Format::from(*f)).is_ok() {
supported_formats.push(format!("{:?}", f));
}
}
println!(
"\tSupported Format(s):\n\n\t\t{}\n",
supported_formats.join(" ")
);
println!("\t------------------------------------------------------\n");
}
};
}
}
}
@ -82,45 +157,36 @@ fn list_outputs() -> io::Result<()> {
Ok(())
}
fn open_device(dev_name: &str, format: AudioFormat) -> Result<(PCM, usize), AlsaError> {
fn open_device(dev_name: &str, format: AudioFormat) -> SinkResult<(PCM, usize)> {
let pcm = PCM::new(dev_name, Direction::Playback, false).map_err(|e| AlsaError::PcmSetUp {
device: dev_name.to_string(),
err: e,
e,
})?;
let alsa_format = match format {
AudioFormat::F64 => Format::float64(),
AudioFormat::F32 => Format::float(),
AudioFormat::S32 => Format::s32(),
AudioFormat::S24 => Format::s24(),
AudioFormat::S16 => Format::s16(),
#[cfg(target_endian = "little")]
AudioFormat::S24_3 => Format::S243LE,
#[cfg(target_endian = "big")]
AudioFormat::S24_3 => Format::S243BE,
};
let bytes_per_period = {
let hwp = HwParams::any(&pcm).map_err(AlsaError::HwParams)?;
hwp.set_access(Access::RWInterleaved)
.map_err(|e| AlsaError::UnsupportedAccessType {
device: dev_name.to_string(),
err: e,
e,
})?;
let alsa_format = Format::from(format);
hwp.set_format(alsa_format)
.map_err(|e| AlsaError::UnsupportedFormat {
device: dev_name.to_string(),
alsa_format,
format,
err: e,
e,
})?;
hwp.set_rate(SAMPLE_RATE, ValueOr::Nearest).map_err(|e| {
AlsaError::UnsupportedSampleRate {
device: dev_name.to_string(),
samplerate: SAMPLE_RATE,
err: e,
e,
}
})?;
@ -128,47 +194,209 @@ fn open_device(dev_name: &str, format: AudioFormat) -> Result<(PCM, usize), Alsa
.map_err(|e| AlsaError::UnsupportedChannelCount {
device: dev_name.to_string(),
channel_count: NUM_CHANNELS,
err: e,
e,
})?;
// Deal strictly in time and periods.
hwp.set_periods(NUM_PERIODS, ValueOr::Nearest)
.map_err(AlsaError::HwParams)?;
// Clone the hwp while it's in
// a good working state so that
// in the event of an error setting
// the buffer and period sizes
// we can use the good working clone
// instead of the hwp that's in an
// error state.
let hwp_clone = hwp.clone();
hwp.set_period_time_near(PERIOD_TIME.as_micros() as u32, ValueOr::Nearest)
.map_err(AlsaError::HwParams)?;
// At a sampling rate of 44100:
// The largest buffer is 22050 Frames (500ms) with 5512 Frame periods (125ms).
// The smallest buffer is 4410 Frames (100ms) with 441 Frame periods (10ms).
// Actual values may vary.
//
// Larger buffer and period sizes are preferred as extremely small values
// will cause high CPU useage.
//
// If no buffer or period size is in those ranges or an error happens
// trying to set the buffer or period size use the device's defaults
// which may not be ideal but are *hopefully* serviceable.
pcm.hw_params(&hwp).map_err(AlsaError::Pcm)?;
let buffer_size = {
let max = match hwp.get_buffer_size_max() {
Err(e) => {
trace!("Error getting the device's max Buffer size: {}", e);
ZERO_FRAMES
}
Ok(s) => s,
};
let swp = pcm.sw_params_current().map_err(AlsaError::Pcm)?;
let min = match hwp.get_buffer_size_min() {
Err(e) => {
trace!("Error getting the device's min Buffer size: {}", e);
ZERO_FRAMES
}
Ok(s) => s,
};
// Don't assume we got what we wanted.
// Ask to make sure.
let buffer_size = if min < max {
match (MIN_BUFFER..=MAX_BUFFER)
.rev()
.find(|f| (min..=max).contains(f))
{
Some(size) => {
trace!("Desired Frames per Buffer: {:?}", size);
match hwp.set_buffer_size_near(size) {
Err(e) => {
trace!("Error setting the device's Buffer size: {}", e);
ZERO_FRAMES
}
Ok(s) => s,
}
}
None => {
trace!("No Desired Buffer size in range reported by the device.");
ZERO_FRAMES
}
}
} else {
trace!("The device's min reported Buffer size was greater than or equal to it's max reported Buffer size.");
ZERO_FRAMES
};
if buffer_size == ZERO_FRAMES {
trace!(
"Desired Buffer Frame range: {:?} - {:?}",
MIN_BUFFER,
MAX_BUFFER
);
trace!(
"Actual Buffer Frame range as reported by the device: {:?} - {:?}",
min,
max
);
}
buffer_size
};
let period_size = {
if buffer_size == ZERO_FRAMES {
ZERO_FRAMES
} else {
let max = match hwp.get_period_size_max() {
Err(e) => {
trace!("Error getting the device's max Period size: {}", e);
ZERO_FRAMES
}
Ok(s) => s,
};
let min = match hwp.get_period_size_min() {
Err(e) => {
trace!("Error getting the device's min Period size: {}", e);
ZERO_FRAMES
}
Ok(s) => s,
};
let max_period = buffer_size / MAX_PERIOD_DIVISOR;
let min_period = buffer_size / MIN_PERIOD_DIVISOR;
let period_size = if min < max && min_period < max_period {
match (min_period..=max_period)
.rev()
.find(|f| (min..=max).contains(f))
{
Some(size) => {
trace!("Desired Frames per Period: {:?}", size);
match hwp.set_period_size_near(size, ValueOr::Nearest) {
Err(e) => {
trace!("Error setting the device's Period size: {}", e);
ZERO_FRAMES
}
Ok(s) => s,
}
}
None => {
trace!("No Desired Period size in range reported by the device.");
ZERO_FRAMES
}
}
} else {
trace!("The device's min reported Period size was greater than or equal to it's max reported Period size,");
trace!("or the desired min Period size was greater than or equal to the desired max Period size.");
ZERO_FRAMES
};
if period_size == ZERO_FRAMES {
trace!("Buffer size: {:?}", buffer_size);
trace!(
"Desired Period Frame range: {:?} (Buffer size / {:?}) - {:?} (Buffer size / {:?})",
min_period,
MIN_PERIOD_DIVISOR,
max_period,
MAX_PERIOD_DIVISOR,
);
trace!(
"Actual Period Frame range as reported by the device: {:?} - {:?}",
min,
max
);
}
period_size
}
};
if buffer_size == ZERO_FRAMES || period_size == ZERO_FRAMES {
trace!(
"Failed to set Buffer and/or Period size, falling back to the device's defaults."
);
trace!("You may experience higher than normal CPU usage and/or audio issues.");
pcm.hw_params(&hwp_clone).map_err(AlsaError::Pcm)?;
} else {
pcm.hw_params(&hwp).map_err(AlsaError::Pcm)?;
}
let hwp = pcm.hw_params_current().map_err(AlsaError::Pcm)?;
// Don't assume we got what we wanted. Ask to make sure.
let frames_per_period = hwp.get_period_size().map_err(AlsaError::HwParams)?;
let frames_per_buffer = hwp.get_buffer_size().map_err(AlsaError::HwParams)?;
let swp = pcm.sw_params_current().map_err(AlsaError::Pcm)?;
swp.set_start_threshold(frames_per_buffer - frames_per_period)
.map_err(AlsaError::SwParams)?;
pcm.sw_params(&swp).map_err(AlsaError::Pcm)?;
trace!("Actual Frames per Buffer: {:?}", frames_per_buffer);
trace!("Actual Frames per Period: {:?}", frames_per_period);
// Let ALSA do the math for us.
pcm.frames_to_bytes(frames_per_period) as usize
};
trace!("Period Buffer size in bytes: {:?}", bytes_per_period);
Ok((pcm, bytes_per_period))
}
impl Open for AlsaSink {
fn open(device: Option<String>, format: AudioFormat) -> Self {
let name = match device.as_deref() {
Some("?") => match list_outputs() {
Some("?") => match list_compatible_devices() {
Ok(_) => {
exit(0);
}
Err(err) => {
error!("Error listing Alsa outputs, {}", err);
Err(e) => {
error!("{}", e);
exit(1);
}
},
@ -189,38 +417,35 @@ impl Open for AlsaSink {
}
impl Sink for AlsaSink {
fn start(&mut self) -> io::Result<()> {
fn start(&mut self) -> SinkResult<()> {
if self.pcm.is_none() {
match open_device(&self.device, self.format) {
Ok((pcm, bytes_per_period)) => {
self.pcm = Some(pcm);
self.period_buffer = Vec::with_capacity(bytes_per_period);
}
Err(e) => {
return Err(io::Error::new(io::ErrorKind::Other, e));
}
let (pcm, bytes_per_period) = open_device(&self.device, self.format)?;
self.pcm = Some(pcm);
if self.period_buffer.capacity() != bytes_per_period {
self.period_buffer = Vec::with_capacity(bytes_per_period);
}
// Should always match the "Period Buffer size in bytes: " trace! message.
trace!(
"Period Buffer capacity: {:?}",
self.period_buffer.capacity()
);
}
Ok(())
}
fn stop(&mut self) -> io::Result<()> {
{
// Write any leftover data in the period buffer
// before draining the actual buffer
self.write_bytes(&[])?;
let pcm = self.pcm.as_mut().ok_or_else(|| {
io::Error::new(io::ErrorKind::Other, "Error stopping AlsaSink, PCM is None")
})?;
pcm.drain().map_err(|e| {
io::Error::new(
io::ErrorKind::Other,
format!("Error stopping AlsaSink {}", e),
)
})?
}
self.pcm = None;
fn stop(&mut self) -> SinkResult<()> {
// Zero fill the remainder of the period buffer and
// write any leftover data before draining the actual PCM buffer.
self.period_buffer.resize(self.period_buffer.capacity(), 0);
self.write_buf()?;
let pcm = self.pcm.take().ok_or(AlsaError::NotConnected)?;
pcm.drain().map_err(AlsaError::DrainFailure)?;
Ok(())
}
@ -228,55 +453,51 @@ impl Sink for AlsaSink {
}
impl SinkAsBytes for AlsaSink {
fn write_bytes(&mut self, data: &[u8]) -> io::Result<()> {
let mut processed_data = 0;
while processed_data < data.len() {
let data_to_buffer = min(
self.period_buffer.capacity() - self.period_buffer.len(),
data.len() - processed_data,
);
self.period_buffer
.extend_from_slice(&data[processed_data..processed_data + data_to_buffer]);
processed_data += data_to_buffer;
if self.period_buffer.len() == self.period_buffer.capacity() {
self.write_buf()?;
self.period_buffer.clear();
}
}
fn write_bytes(&mut self, data: &[u8]) -> SinkResult<()> {
let mut start_index = 0;
let data_len = data.len();
let capacity = self.period_buffer.capacity();
Ok(())
loop {
let data_left = data_len - start_index;
let space_left = capacity - self.period_buffer.len();
let data_to_buffer = min(data_left, space_left);
let end_index = start_index + data_to_buffer;
self.period_buffer
.extend_from_slice(&data[start_index..end_index]);
if self.period_buffer.len() == capacity {
self.write_buf()?;
}
if end_index == data_len {
break Ok(());
}
start_index = end_index;
}
}
}
impl AlsaSink {
pub const NAME: &'static str = "alsa";
fn write_buf(&mut self) -> io::Result<()> {
let pcm = self.pcm.as_mut().ok_or_else(|| {
io::Error::new(
io::ErrorKind::Other,
"Error writing from AlsaSink buffer to PCM, PCM is None",
)
})?;
let io = pcm.io_bytes();
if let Err(err) = io.writei(&self.period_buffer) {
fn write_buf(&mut self) -> SinkResult<()> {
let pcm = self.pcm.as_mut().ok_or(AlsaError::NotConnected)?;
if let Err(e) = pcm.io_bytes().writei(&self.period_buffer) {
// Capture and log the original error as a warning, and then try to recover.
// If recovery fails then forward that error back to player.
warn!(
"Error writing from AlsaSink buffer to PCM, trying to recover {}",
err
"Error writing from AlsaSink buffer to PCM, trying to recover, {}",
e
);
pcm.try_recover(err, false).map_err(|e| {
io::Error::new(
io::ErrorKind::Other,
format!(
"Error writing from AlsaSink buffer to PCM, recovery failed {}",
e
),
)
})?
pcm.try_recover(e, false).map_err(AlsaError::OnWrite)?
}
self.period_buffer.clear();
Ok(())
}
}

View file

@ -1,4 +1,4 @@
use super::{Open, Sink, SinkAsBytes};
use super::{Open, Sink, SinkAsBytes, SinkResult};
use crate::config::AudioFormat;
use crate::convert::Converter;
use crate::decoder::AudioPacket;
@ -11,7 +11,7 @@ use gst::prelude::*;
use zerocopy::AsBytes;
use std::sync::mpsc::{sync_channel, SyncSender};
use std::{io, thread};
use std::thread;
#[allow(dead_code)]
pub struct GstreamerSink {
@ -131,7 +131,7 @@ impl Sink for GstreamerSink {
}
impl SinkAsBytes for GstreamerSink {
fn write_bytes(&mut self, data: &[u8]) -> io::Result<()> {
fn write_bytes(&mut self, data: &[u8]) -> SinkResult<()> {
// Copy expensively (in to_vec()) to avoid thread synchronization
self.tx
.send(data.to_vec())

View file

@ -1,4 +1,4 @@
use super::{Open, Sink};
use super::{Open, Sink, SinkError, SinkResult};
use crate::config::AudioFormat;
use crate::convert::Converter;
use crate::decoder::AudioPacket;
@ -6,7 +6,6 @@ use crate::NUM_CHANNELS;
use jack::{
AsyncClient, AudioOut, Client, ClientOptions, Control, Port, ProcessHandler, ProcessScope,
};
use std::io;
use std::sync::mpsc::{sync_channel, Receiver, SyncSender};
pub struct JackSink {
@ -25,15 +24,12 @@ pub struct JackData {
impl ProcessHandler for JackData {
fn process(&mut self, _: &Client, ps: &ProcessScope) -> Control {
// get output port buffers
let mut out_r = self.port_r.as_mut_slice(ps);
let mut out_l = self.port_l.as_mut_slice(ps);
let buf_r: &mut [f32] = &mut out_r;
let buf_l: &mut [f32] = &mut out_l;
let buf_r: &mut [f32] = self.port_r.as_mut_slice(ps);
let buf_l: &mut [f32] = self.port_l.as_mut_slice(ps);
// get queue iterator
let mut queue_iter = self.rec.try_iter();
let buf_size = buf_r.len();
for i in 0..buf_size {
for i in 0..buf_r.len() {
buf_r[i] = queue_iter.next().unwrap_or(0.0);
buf_l[i] = queue_iter.next().unwrap_or(0.0);
}
@ -70,8 +66,12 @@ impl Open for JackSink {
}
impl Sink for JackSink {
fn write(&mut self, packet: &AudioPacket, converter: &mut Converter) -> io::Result<()> {
let samples_f32: &[f32] = &converter.f64_to_f32(packet.samples());
fn write(&mut self, packet: &AudioPacket, converter: &mut Converter) -> SinkResult<()> {
let samples = packet
.samples()
.map_err(|e| SinkError::OnWrite(e.to_string()))?;
let samples_f32: &[f32] = &converter.f64_to_f32(samples);
for sample in samples_f32.iter() {
let res = self.send.send(*sample);
if res.is_err() {

View file

@ -1,26 +1,40 @@
use crate::config::AudioFormat;
use crate::convert::Converter;
use crate::decoder::AudioPacket;
use std::io;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum SinkError {
#[error("Audio Sink Error Not Connected: {0}")]
NotConnected(String),
#[error("Audio Sink Error Connection Refused: {0}")]
ConnectionRefused(String),
#[error("Audio Sink Error On Write: {0}")]
OnWrite(String),
#[error("Audio Sink Error Invalid Parameters: {0}")]
InvalidParams(String),
}
pub type SinkResult<T> = Result<T, SinkError>;
pub trait Open {
fn open(_: Option<String>, format: AudioFormat) -> Self;
}
pub trait Sink {
fn start(&mut self) -> io::Result<()> {
fn start(&mut self) -> SinkResult<()> {
Ok(())
}
fn stop(&mut self) -> io::Result<()> {
fn stop(&mut self) -> SinkResult<()> {
Ok(())
}
fn write(&mut self, packet: &AudioPacket, converter: &mut Converter) -> io::Result<()>;
fn write(&mut self, packet: &AudioPacket, converter: &mut Converter) -> SinkResult<()>;
}
pub type SinkBuilder = fn(Option<String>, AudioFormat) -> Box<dyn Sink>;
pub trait SinkAsBytes {
fn write_bytes(&mut self, data: &[u8]) -> io::Result<()>;
fn write_bytes(&mut self, data: &[u8]) -> SinkResult<()>;
}
fn mk_sink<S: Sink + Open + 'static>(device: Option<String>, format: AudioFormat) -> Box<dyn Sink> {
@ -30,7 +44,7 @@ fn mk_sink<S: Sink + Open + 'static>(device: Option<String>, format: AudioFormat
// reuse code for various backends
macro_rules! sink_as_bytes {
() => {
fn write(&mut self, packet: &AudioPacket, converter: &mut Converter) -> io::Result<()> {
fn write(&mut self, packet: &AudioPacket, converter: &mut Converter) -> SinkResult<()> {
use crate::convert::i24;
use zerocopy::AsBytes;
match packet {
@ -90,7 +104,7 @@ use self::gstreamer::GstreamerSink;
#[cfg(any(feature = "rodio-backend", feature = "rodiojack-backend"))]
mod rodio;
#[cfg(any(feature = "rodio-backend", feature = "rodiojack-backend"))]
#[cfg(feature = "rodio-backend")]
use self::rodio::RodioSink;
#[cfg(feature = "sdl-backend")]
@ -132,11 +146,6 @@ pub fn find(name: Option<String>) -> Option<SinkBuilder> {
.find(|backend| name == backend.0)
.map(|backend| backend.1)
} else {
Some(
BACKENDS
.first()
.expect("No backends were enabled at build time")
.1,
)
BACKENDS.first().map(|backend| backend.1)
}
}

View file

@ -1,4 +1,4 @@
use super::{Open, Sink, SinkAsBytes};
use super::{Open, Sink, SinkAsBytes, SinkError, SinkResult};
use crate::config::AudioFormat;
use crate::convert::Converter;
use crate::decoder::AudioPacket;
@ -23,14 +23,14 @@ impl Open for StdoutSink {
}
impl Sink for StdoutSink {
fn start(&mut self) -> io::Result<()> {
fn start(&mut self) -> SinkResult<()> {
if self.output.is_none() {
let output: Box<dyn Write> = match self.path.as_deref() {
Some(path) => {
let open_op = OpenOptions::new()
.write(true)
.open(path)
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
.map_err(|e| SinkError::ConnectionRefused(e.to_string()))?;
Box::new(open_op)
}
None => Box::new(io::stdout()),
@ -46,14 +46,18 @@ impl Sink for StdoutSink {
}
impl SinkAsBytes for StdoutSink {
fn write_bytes(&mut self, data: &[u8]) -> io::Result<()> {
fn write_bytes(&mut self, data: &[u8]) -> SinkResult<()> {
match self.output.as_deref_mut() {
Some(output) => {
output.write_all(data)?;
output.flush()?;
output
.write_all(data)
.map_err(|e| SinkError::OnWrite(e.to_string()))?;
output
.flush()
.map_err(|e| SinkError::OnWrite(e.to_string()))?;
}
None => {
return Err(io::Error::new(io::ErrorKind::Other, "Output is None"));
return Err(SinkError::NotConnected("Output is None".to_string()));
}
}

View file

@ -1,11 +1,10 @@
use super::{Open, Sink};
use super::{Open, Sink, SinkError, SinkResult};
use crate::config::AudioFormat;
use crate::convert::Converter;
use crate::decoder::AudioPacket;
use crate::{NUM_CHANNELS, SAMPLE_RATE};
use portaudio_rs::device::{get_default_output_index, DeviceIndex, DeviceInfo};
use portaudio_rs::stream::*;
use std::io;
use std::process::exit;
use std::time::Duration;
@ -96,7 +95,7 @@ impl<'a> Open for PortAudioSink<'a> {
}
impl<'a> Sink for PortAudioSink<'a> {
fn start(&mut self) -> io::Result<()> {
fn start(&mut self) -> SinkResult<()> {
macro_rules! start_sink {
(ref mut $stream: ident, ref $parameters: ident) => {{
if $stream.is_none() {
@ -125,7 +124,7 @@ impl<'a> Sink for PortAudioSink<'a> {
Ok(())
}
fn stop(&mut self) -> io::Result<()> {
fn stop(&mut self) -> SinkResult<()> {
macro_rules! stop_sink {
(ref mut $stream: ident) => {{
$stream.as_mut().unwrap().stop().unwrap();
@ -141,14 +140,17 @@ impl<'a> Sink for PortAudioSink<'a> {
Ok(())
}
fn write(&mut self, packet: &AudioPacket, converter: &mut Converter) -> io::Result<()> {
fn write(&mut self, packet: &AudioPacket, converter: &mut Converter) -> SinkResult<()> {
macro_rules! write_sink {
(ref mut $stream: expr, $samples: expr) => {
$stream.as_mut().unwrap().write($samples)
};
}
let samples = packet.samples();
let samples = packet
.samples()
.map_err(|e| SinkError::OnWrite(e.to_string()))?;
let result = match self {
Self::F32(stream, _parameters) => {
let samples_f32: &[f32] = &converter.f64_to_f32(samples);

View file

@ -1,84 +1,129 @@
use super::{Open, Sink, SinkAsBytes};
use super::{Open, Sink, SinkAsBytes, SinkError, SinkResult};
use crate::config::AudioFormat;
use crate::convert::Converter;
use crate::decoder::AudioPacket;
use crate::{NUM_CHANNELS, SAMPLE_RATE};
use libpulse_binding::{self as pulse, stream::Direction};
use libpulse_binding::{self as pulse, error::PAErr, stream::Direction};
use libpulse_simple_binding::Simple;
use std::io;
use thiserror::Error;
const APP_NAME: &str = "librespot";
const STREAM_NAME: &str = "Spotify endpoint";
#[derive(Debug, Error)]
enum PulseError {
#[error("<PulseAudioSink> Unsupported Pulseaudio Sample Spec, Format {pulse_format:?} ({format:?}), Channels {channels}, Rate {rate}")]
InvalidSampleSpec {
pulse_format: pulse::sample::Format,
format: AudioFormat,
channels: u8,
rate: u32,
},
#[error("<PulseAudioSink> {0}")]
ConnectionRefused(PAErr),
#[error("<PulseAudioSink> Failed to Drain Pulseaudio Buffer, {0}")]
DrainFailure(PAErr),
#[error("<PulseAudioSink>")]
NotConnected,
#[error("<PulseAudioSink> {0}")]
OnWrite(PAErr),
}
impl From<PulseError> for SinkError {
fn from(e: PulseError) -> SinkError {
use PulseError::*;
let es = e.to_string();
match e {
DrainFailure(_) | OnWrite(_) => SinkError::OnWrite(es),
ConnectionRefused(_) => SinkError::ConnectionRefused(es),
NotConnected => SinkError::NotConnected(es),
InvalidSampleSpec { .. } => SinkError::InvalidParams(es),
}
}
}
pub struct PulseAudioSink {
s: Option<Simple>,
ss: pulse::sample::Spec,
device: Option<String>,
format: AudioFormat,
}
impl Open for PulseAudioSink {
fn open(device: Option<String>, format: AudioFormat) -> Self {
info!("Using PulseAudio sink with format: {:?}", format);
let mut actual_format = format;
// PulseAudio calls S24 and S24_3 different from the rest of the world
let pulse_format = match format {
AudioFormat::F32 => pulse::sample::Format::FLOAT32NE,
AudioFormat::S32 => pulse::sample::Format::S32NE,
AudioFormat::S24 => pulse::sample::Format::S24_32NE,
AudioFormat::S24_3 => pulse::sample::Format::S24NE,
AudioFormat::S16 => pulse::sample::Format::S16NE,
_ => {
unimplemented!("PulseAudio currently does not support {:?} output", format)
}
};
if actual_format == AudioFormat::F64 {
warn!("PulseAudio currently does not support F64 output");
actual_format = AudioFormat::F32;
}
let ss = pulse::sample::Spec {
format: pulse_format,
channels: NUM_CHANNELS,
rate: SAMPLE_RATE,
};
debug_assert!(ss.is_valid());
info!("Using PulseAudioSink with format: {:?}", actual_format);
Self {
s: None,
ss,
device,
format,
format: actual_format,
}
}
}
impl Sink for PulseAudioSink {
fn start(&mut self) -> io::Result<()> {
if self.s.is_some() {
return Ok(());
fn start(&mut self) -> SinkResult<()> {
if self.s.is_none() {
// PulseAudio calls S24 and S24_3 different from the rest of the world
let pulse_format = match self.format {
AudioFormat::F32 => pulse::sample::Format::FLOAT32NE,
AudioFormat::S32 => pulse::sample::Format::S32NE,
AudioFormat::S24 => pulse::sample::Format::S24_32NE,
AudioFormat::S24_3 => pulse::sample::Format::S24NE,
AudioFormat::S16 => pulse::sample::Format::S16NE,
_ => unreachable!(),
};
let ss = pulse::sample::Spec {
format: pulse_format,
channels: NUM_CHANNELS,
rate: SAMPLE_RATE,
};
if !ss.is_valid() {
let pulse_error = PulseError::InvalidSampleSpec {
pulse_format,
format: self.format,
channels: NUM_CHANNELS,
rate: SAMPLE_RATE,
};
return Err(SinkError::from(pulse_error));
}
let s = Simple::new(
None, // Use the default server.
APP_NAME, // Our application's name.
Direction::Playback, // Direction.
self.device.as_deref(), // Our device (sink) name.
STREAM_NAME, // Description of our stream.
&ss, // Our sample format.
None, // Use default channel map.
None, // Use default buffering attributes.
)
.map_err(PulseError::ConnectionRefused)?;
self.s = Some(s);
}
let device = self.device.as_deref();
let result = Simple::new(
None, // Use the default server.
APP_NAME, // Our application's name.
Direction::Playback, // Direction.
device, // Our device (sink) name.
STREAM_NAME, // Description of our stream.
&self.ss, // Our sample format.
None, // Use default channel map.
None, // Use default buffering attributes.
);
match result {
Ok(s) => {
self.s = Some(s);
Ok(())
}
Err(e) => Err(io::Error::new(
io::ErrorKind::ConnectionRefused,
e.to_string().unwrap(),
)),
}
Ok(())
}
fn stop(&mut self) -> io::Result<()> {
fn stop(&mut self) -> SinkResult<()> {
let s = self.s.as_mut().ok_or(PulseError::NotConnected)?;
s.drain().map_err(PulseError::DrainFailure)?;
self.s = None;
Ok(())
}
@ -87,21 +132,12 @@ impl Sink for PulseAudioSink {
}
impl SinkAsBytes for PulseAudioSink {
fn write_bytes(&mut self, data: &[u8]) -> io::Result<()> {
if let Some(s) = &self.s {
match s.write(data) {
Ok(_) => Ok(()),
Err(e) => Err(io::Error::new(
io::ErrorKind::BrokenPipe,
e.to_string().unwrap(),
)),
}
} else {
Err(io::Error::new(
io::ErrorKind::NotConnected,
"Not connected to PulseAudio",
))
}
fn write_bytes(&mut self, data: &[u8]) -> SinkResult<()> {
let s = self.s.as_mut().ok_or(PulseError::NotConnected)?;
s.write(data).map_err(PulseError::OnWrite)?;
Ok(())
}
}

View file

@ -1,11 +1,11 @@
use std::process::exit;
use std::thread;
use std::time::Duration;
use std::{io, thread};
use cpal::traits::{DeviceTrait, HostTrait};
use thiserror::Error;
use super::Sink;
use super::{Sink, SinkError, SinkResult};
use crate::config::AudioFormat;
use crate::convert::Converter;
use crate::decoder::AudioPacket;
@ -33,16 +33,30 @@ pub fn mk_rodiojack(device: Option<String>, format: AudioFormat) -> Box<dyn Sink
#[derive(Debug, Error)]
pub enum RodioError {
#[error("Rodio: no device available")]
#[error("<RodioSink> No Device Available")]
NoDeviceAvailable,
#[error("Rodio: device \"{0}\" is not available")]
#[error("<RodioSink> device \"{0}\" is Not Available")]
DeviceNotAvailable(String),
#[error("Rodio play error: {0}")]
#[error("<RodioSink> Play Error: {0}")]
PlayError(#[from] rodio::PlayError),
#[error("Rodio stream error: {0}")]
#[error("<RodioSink> Stream Error: {0}")]
StreamError(#[from] rodio::StreamError),
#[error("Cannot get audio devices: {0}")]
#[error("<RodioSink> Cannot Get Audio Devices: {0}")]
DevicesError(#[from] cpal::DevicesError),
#[error("<RodioSink> {0}")]
Samples(String),
}
impl From<RodioError> for SinkError {
fn from(e: RodioError) -> SinkError {
use RodioError::*;
let es = e.to_string();
match e {
StreamError(_) | PlayError(_) | Samples(_) => SinkError::OnWrite(es),
NoDeviceAvailable | DeviceNotAvailable(_) => SinkError::ConnectionRefused(es),
DevicesError(_) => SinkError::InvalidParams(es),
}
}
}
pub struct RodioSink {
@ -175,8 +189,10 @@ pub fn open(host: cpal::Host, device: Option<String>, format: AudioFormat) -> Ro
}
impl Sink for RodioSink {
fn write(&mut self, packet: &AudioPacket, converter: &mut Converter) -> io::Result<()> {
let samples = packet.samples();
fn write(&mut self, packet: &AudioPacket, converter: &mut Converter) -> SinkResult<()> {
let samples = packet
.samples()
.map_err(|e| RodioError::Samples(e.to_string()))?;
match self.format {
AudioFormat::F32 => {
let samples_f32: &[f32] = &converter.f64_to_f32(samples);
@ -211,5 +227,6 @@ impl Sink for RodioSink {
}
impl RodioSink {
#[allow(dead_code)]
pub const NAME: &'static str = "rodio";
}

View file

@ -1,11 +1,11 @@
use super::{Open, Sink};
use super::{Open, Sink, SinkError, SinkResult};
use crate::config::AudioFormat;
use crate::convert::Converter;
use crate::decoder::AudioPacket;
use crate::{NUM_CHANNELS, SAMPLE_RATE};
use sdl2::audio::{AudioQueue, AudioSpecDesired};
use std::thread;
use std::time::Duration;
use std::{io, thread};
pub enum SdlSink {
F32(AudioQueue<f32>),
@ -52,7 +52,7 @@ impl Open for SdlSink {
}
impl Sink for SdlSink {
fn start(&mut self) -> io::Result<()> {
fn start(&mut self) -> SinkResult<()> {
macro_rules! start_sink {
($queue: expr) => {{
$queue.clear();
@ -67,7 +67,7 @@ impl Sink for SdlSink {
Ok(())
}
fn stop(&mut self) -> io::Result<()> {
fn stop(&mut self) -> SinkResult<()> {
macro_rules! stop_sink {
($queue: expr) => {{
$queue.pause();
@ -82,7 +82,7 @@ impl Sink for SdlSink {
Ok(())
}
fn write(&mut self, packet: &AudioPacket, converter: &mut Converter) -> io::Result<()> {
fn write(&mut self, packet: &AudioPacket, converter: &mut Converter) -> SinkResult<()> {
macro_rules! drain_sink {
($queue: expr, $size: expr) => {{
// sleep and wait for sdl thread to drain the queue a bit
@ -92,7 +92,9 @@ impl Sink for SdlSink {
}};
}
let samples = packet.samples();
let samples = packet
.samples()
.map_err(|e| SinkError::OnWrite(e.to_string()))?;
match self {
Self::F32(queue) => {
let samples_f32: &[f32] = &converter.f64_to_f32(samples);

View file

@ -1,10 +1,10 @@
use super::{Open, Sink, SinkAsBytes};
use super::{Open, Sink, SinkAsBytes, SinkError, SinkResult};
use crate::config::AudioFormat;
use crate::convert::Converter;
use crate::decoder::AudioPacket;
use shell_words::split;
use std::io::{self, Write};
use std::io::Write;
use std::process::{Child, Command, Stdio};
pub struct SubprocessSink {
@ -30,21 +30,25 @@ impl Open for SubprocessSink {
}
impl Sink for SubprocessSink {
fn start(&mut self) -> io::Result<()> {
fn start(&mut self) -> SinkResult<()> {
let args = split(&self.shell_command).unwrap();
self.child = Some(
Command::new(&args[0])
.args(&args[1..])
.stdin(Stdio::piped())
.spawn()?,
);
let child = Command::new(&args[0])
.args(&args[1..])
.stdin(Stdio::piped())
.spawn()
.map_err(|e| SinkError::ConnectionRefused(e.to_string()))?;
self.child = Some(child);
Ok(())
}
fn stop(&mut self) -> io::Result<()> {
fn stop(&mut self) -> SinkResult<()> {
if let Some(child) = &mut self.child.take() {
child.kill()?;
child.wait()?;
child
.kill()
.map_err(|e| SinkError::OnWrite(e.to_string()))?;
child
.wait()
.map_err(|e| SinkError::OnWrite(e.to_string()))?;
}
Ok(())
}
@ -53,11 +57,18 @@ impl Sink for SubprocessSink {
}
impl SinkAsBytes for SubprocessSink {
fn write_bytes(&mut self, data: &[u8]) -> io::Result<()> {
fn write_bytes(&mut self, data: &[u8]) -> SinkResult<()> {
if let Some(child) = &mut self.child {
let child_stdin = child.stdin.as_mut().unwrap();
child_stdin.write_all(data)?;
child_stdin.flush()?;
let child_stdin = child
.stdin
.as_mut()
.ok_or_else(|| SinkError::NotConnected("Child is None".to_string()))?;
child_stdin
.write_all(data)
.map_err(|e| SinkError::OnWrite(e.to_string()))?;
child_stdin
.flush()
.map_err(|e| SinkError::OnWrite(e.to_string()))?;
}
Ok(())
}

View file

@ -76,10 +76,11 @@ impl AudioFormat {
}
}
#[derive(Clone, Debug)]
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum NormalisationType {
Album,
Track,
Auto,
}
impl FromStr for NormalisationType {
@ -88,6 +89,7 @@ impl FromStr for NormalisationType {
match s.to_lowercase().as_ref() {
"album" => Ok(Self::Album),
"track" => Ok(Self::Track),
"auto" => Ok(Self::Auto),
_ => Err(()),
}
}
@ -95,11 +97,11 @@ impl FromStr for NormalisationType {
impl Default for NormalisationType {
fn default() -> Self {
Self::Album
Self::Auto
}
}
#[derive(Clone, Debug, PartialEq)]
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum NormalisationMethod {
Basic,
Dynamic,
@ -151,7 +153,7 @@ impl Default for PlayerConfig {
normalisation_type: NormalisationType::default(),
normalisation_method: NormalisationMethod::default(),
normalisation_pregain: 0.0,
normalisation_threshold: db_to_ratio(-1.0),
normalisation_threshold: db_to_ratio(-2.0),
normalisation_attack: Duration::from_millis(5),
normalisation_release: Duration::from_millis(100),
normalisation_knee: 1.0,

View file

@ -1,22 +1,23 @@
use super::{AudioDecoder, AudioError, AudioPacket};
use super::{AudioDecoder, AudioPacket, DecoderError, DecoderResult};
use lewton::audio::AudioReadError::AudioIsHeader;
use lewton::inside_ogg::OggStreamReader;
use lewton::samples::InterleavedSamples;
use lewton::OggReadError::NoCapturePatternFound;
use lewton::VorbisError::{BadAudio, OggError};
use std::error;
use std::fmt;
use std::io::{Read, Seek};
use std::time::Duration;
pub struct VorbisDecoder<R: Read + Seek>(OggStreamReader<R>);
pub struct VorbisError(lewton::VorbisError);
impl<R> VorbisDecoder<R>
where
R: Read + Seek,
{
pub fn new(input: R) -> Result<VorbisDecoder<R>, VorbisError> {
Ok(VorbisDecoder(OggStreamReader::new(input)?))
pub fn new(input: R) -> DecoderResult<VorbisDecoder<R>> {
let reader =
OggStreamReader::new(input).map_err(|e| DecoderError::LewtonDecoder(e.to_string()))?;
Ok(VorbisDecoder(reader))
}
}
@ -24,51 +25,22 @@ impl<R> AudioDecoder for VorbisDecoder<R>
where
R: Read + Seek,
{
fn seek(&mut self, ms: i64) -> Result<(), AudioError> {
let absgp = Duration::from_millis(ms as u64 * crate::SAMPLE_RATE as u64).as_secs();
match self.0.seek_absgp_pg(absgp as u64) {
Ok(_) => Ok(()),
Err(err) => Err(AudioError::VorbisError(err.into())),
}
fn seek(&mut self, absgp: u64) -> DecoderResult<()> {
self.0
.seek_absgp_pg(absgp)
.map_err(|e| DecoderError::LewtonDecoder(e.to_string()))?;
Ok(())
}
fn next_packet(&mut self) -> Result<Option<AudioPacket>, AudioError> {
use lewton::audio::AudioReadError::AudioIsHeader;
use lewton::OggReadError::NoCapturePatternFound;
use lewton::VorbisError::{BadAudio, OggError};
fn next_packet(&mut self) -> DecoderResult<Option<AudioPacket>> {
loop {
match self.0.read_dec_packet_generic::<InterleavedSamples<f32>>() {
Ok(Some(packet)) => return Ok(Some(AudioPacket::samples_from_f32(packet.samples))),
Ok(None) => return Ok(None),
Err(BadAudio(AudioIsHeader)) => (),
Err(OggError(NoCapturePatternFound)) => (),
Err(err) => return Err(AudioError::VorbisError(err.into())),
Err(e) => return Err(DecoderError::LewtonDecoder(e.to_string())),
}
}
}
}
impl From<lewton::VorbisError> for VorbisError {
fn from(err: lewton::VorbisError) -> VorbisError {
VorbisError(err)
}
}
impl fmt::Debug for VorbisError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
fmt::Debug::fmt(&self.0, f)
}
}
impl fmt::Display for VorbisError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
fmt::Display::fmt(&self.0, f)
}
}
impl error::Error for VorbisError {
fn source(&self) -> Option<&(dyn error::Error + 'static)> {
error::Error::source(&self.0)
}
}

Some files were not shown because too many files have changed in this diff Show more