Merge branch 'dev' into new-api and update crates

This commit is contained in:
Roderick van Domburg 2022-07-27 23:31:11 +02:00
commit 05b9b13cf8
No known key found for this signature in database
GPG key ID: 87F5FDE8A56219F4
41 changed files with 975 additions and 804 deletions

View file

@ -191,7 +191,7 @@ jobs:
- name: Install cross - name: Install cross
run: cargo install cross || true run: cargo install cross || true
- name: Build - name: Build
run: cross build --locked --target ${{ matrix.target }} --no-default-features run: cross build --target ${{ matrix.target }} --no-default-features
clippy: clippy:
needs: [test-cross-arm, test-windows] needs: [test-cross-arm, test-windows]

3
.gitignore vendored
View file

@ -5,5 +5,4 @@ spotify_appkey.key
.project .project
.history .history
*.save *.save
*.*~

View file

@ -8,38 +8,64 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Changed ### Changed
- [main] Enforce reasonable ranges for option values (breaking). - [playback] `subprocess`: Better error handling
- [main] Don't evaluate options that would otherwise have no effect. - [playback] `pipe`: Better error handling
- [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).
- [playback] `Sink`: `write()` now receives ownership of the packet (breaking).
- [playback] `pipe`: create file if it doesn't already exist
- [playback] More robust dynamic limiter for very wide dynamic range (breaking)
### Added ### Added
- [cache] Add `disable-credential-cache` flag (breaking). - [playback] `pipe`: Implement stop
- [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.
- [playback] `pulseaudio`: set the PulseAudio name to match librespot's device name via `PULSE_PROP_application.name` environment variable (user set env var value takes precedence). (breaking)
- [playback] `pulseaudio`: set icon to `audio-x-generic` so we get an icon instead of a placeholder via `PULSE_PROP_application.icon_name` environment variable (user set env var value takes precedence). (breaking)
- [playback] `pulseaudio`: set values to: `PULSE_PROP_application.version`, `PULSE_PROP_application.process.binary`, `PULSE_PROP_stream.description`, `PULSE_PROP_media.software` and `PULSE_PROP_media.role` environment variables (user set env var values take precedence). (breaking)
### Fixed ### Fixed
- [main] Prevent hang when discovery is disabled and there are no credentials or when bad credentials are given. - [playback] `alsamixer`: make `--volume-ctrl fixed` work as expected when combined with `--mixer alsa`
- [main] Don't panic when parsing options. Instead list valid values and exit. - [main] fix `--opt=value` line argument logging
- [main] `--alsa-mixer-device` and `--alsa-mixer-index` now fallback to the card and index specified in `--device`.
- [core] Removed unsafe code (breaking)
- [playback] Adhere to ReplayGain spec when calculating gain normalisation factor.
- [playback] `alsa`: Use `--volume-range` overrides for softvol controls
- [connect] Don't panic when activating shuffle without previous interaction.
### Removed ### Removed
- [playback] `alsamixer`: previously deprecated option `mixer-card` has been removed.
- [playback] `alsamixer`: previously deprecated option `mixer-name` has been removed. ## [0.4.1] - 2022-05-23
- [playback] `alsamixer`: previously deprecated option `mixer-index` has been removed.
### Changed
- [chore] The MSRV is now 1.56
### Fixed
- [playback] Fixed dependency issues when installing from crate
## [0.4.0] - 2022-05-21
### Changed
- [chore] The MSRV is now 1.53
- [contrib] Hardened security of the `systemd` service units
- [core] `Session`: `connect()` now returns the long-term credentials
- [core] `Session`: `connect()` now accepts a flag if the credentails should be stored via the cache
- [main] Different option descriptions and error messages based on what backends are enabled at build time
- [playback] More robust dynamic limiter for very wide dynamic range (breaking)
- [playback] `alsa`: improve `--device ?` output for the Alsa backend
- [playback] `gstreamer`: create own context, set correct states and use sync handler
- [playback] `pipe`: create file if it doesn't already exist
- [playback] `Sink`: `write()` now receives ownership of the packet (breaking)
### Added
- [main] Enforce reasonable ranges for option values (breaking)
- [main] Add the ability to parse environment variables
- [main] Log now emits warning when trying to use options that would otherwise have no effect
- [main] Verbose logging now logs all parsed environment variables and command line arguments (credentials are redacted)
- [main] Add a `-q`, `--quiet` option that changes the logging level to WARN
- [main] Add `disable-credential-cache` flag (breaking)
- [main] Add a short name for every flag and option
- [playback] `pulseaudio`: set the PulseAudio name to match librespot's device name via `PULSE_PROP_application.name` environment variable (user set env var value takes precedence) (breaking)
- [playback] `pulseaudio`: set icon to `audio-x-generic` so we get an icon instead of a placeholder via `PULSE_PROP_application.icon_name` environment variable (user set env var value takes precedence) (breaking)
- [playback] `pulseaudio`: set values to: `PULSE_PROP_application.version`, `PULSE_PROP_application.process.binary`, `PULSE_PROP_stream.description`, `PULSE_PROP_media.software` and `PULSE_PROP_media.role` environment variables (user set env var values take precedence) (breaking)
### Fixed
- [connect] Don't panic when activating shuffle without previous interaction
- [core] Removed unsafe code (breaking)
- [main] Fix crash when built with Avahi support but Avahi is locally unavailable
- [main] Prevent hang when discovery is disabled and there are no credentials or when bad credentials are given
- [main] Don't panic when parsing options, instead list valid values and exit
- [main] `--alsa-mixer-device` and `--alsa-mixer-index` now fallback to the card and index specified in `--device`.
- [playback] Adhere to ReplayGain spec when calculating gain normalisation factor
- [playback] `alsa`: make `--volume-range` overrides apply to Alsa softvol controls
### Removed
- [playback] `alsamixer`: previously deprecated options `mixer-card`, `mixer-name` and `mixer-index` have been removed
## [0.3.1] - 2021-10-24 ## [0.3.1] - 2021-10-24
@ -114,7 +140,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [0.1.0] - 2019-11-06 ## [0.1.0] - 2019-11-06
[unreleased]: https://github.com/librespot-org/librespot/compare/v0.3.1..HEAD [unreleased]: https://github.com/librespot-org/librespot/compare/v0.4.1..HEAD
[0.4.1]: https://github.com/librespot-org/librespot/compare/v0.4.0..v0.4.1
[0.4.0]: https://github.com/librespot-org/librespot/compare/v0.3.1..v0.4.0
[0.3.1]: https://github.com/librespot-org/librespot/compare/v0.3.0..v0.3.1 [0.3.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.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.2.0]: https://github.com/librespot-org/librespot/compare/v0.1.6..v0.2.0

View file

@ -7,7 +7,11 @@ In order to compile librespot, you will first need to set up a suitable Rust bui
### Install Rust ### Install Rust
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. 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.
<<<<<<< HEAD
*Note: The current minimum required Rust version at the time of writing is 1.56.* *Note: The current minimum required Rust version at the time of writing is 1.56.*
=======
*Note: The current minimum required Rust version at the time of writing is 1.56, you can find the current minimum version specified in the `.github/workflow/test.yml` file.*
>>>>>>> dev
#### Additional Rust tools - `rustfmt` #### Additional Rust tools - `rustfmt`
To ensure a consistent codebase, we utilise [`rustfmt`](https://github.com/rust-lang/rustfmt) and [`clippy`](https://github.com/rust-lang/rust-clippy), which are installed by default with `rustup` these days, else they can be installed manually with: 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:

815
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
[package] [package]
name = "librespot" name = "librespot"
version = "0.3.1" version = "0.4.1"
authors = ["Librespot Org"] authors = ["Librespot Org"]
license = "MIT" license = "MIT"
description = "An open source client library for Spotify, with support for Spotify Connect" description = "An open source client library for Spotify, with support for Spotify Connect"
@ -22,31 +22,31 @@ doc = false
[dependencies.librespot-audio] [dependencies.librespot-audio]
path = "audio" path = "audio"
version = "0.3.1" version = "0.4.1"
[dependencies.librespot-connect] [dependencies.librespot-connect]
path = "connect" path = "connect"
version = "0.3.1" version = "0.4.1"
[dependencies.librespot-core] [dependencies.librespot-core]
path = "core" path = "core"
version = "0.3.1" version = "0.4.1"
[dependencies.librespot-discovery] [dependencies.librespot-discovery]
path = "discovery" path = "discovery"
version = "0.3.1" version = "0.4.1"
[dependencies.librespot-metadata] [dependencies.librespot-metadata]
path = "metadata" path = "metadata"
version = "0.3.1" version = "0.4.1"
[dependencies.librespot-playback] [dependencies.librespot-playback]
path = "playback" path = "playback"
version = "0.3.1" version = "0.4.1"
[dependencies.librespot-protocol] [dependencies.librespot-protocol]
path = "protocol" path = "protocol"
version = "0.3.1" version = "0.4.1"
[dependencies] [dependencies]
env_logger = { version = "0.9", default-features = false, features = ["termcolor", "humantime", "atty"] } env_logger = { version = "0.9", default-features = false, features = ["termcolor", "humantime", "atty"] }
@ -54,8 +54,8 @@ futures-util = { version = "0.3", default_features = false }
getopts = "0.2" getopts = "0.2"
hex = "0.4" hex = "0.4"
log = "0.4" log = "0.4"
rpassword = "5.0" rpassword = "7.0"
sha-1 = "0.10" sha1 = "0.10"
thiserror = "1.0" thiserror = "1.0"
tokio = { version = "1", features = ["rt", "macros", "signal", "sync", "parking_lot", "process"] } tokio = { version = "1", features = ["rt", "macros", "signal", "sync", "parking_lot", "process"] }
url = "2.2" url = "2.2"

View file

@ -2,7 +2,17 @@
## How To ## How To
The bash script in the root of the project, named `publish.sh` can be used to publish a new version of librespot and it's corresponding crates. the command should be used as follows: `./publish 0.1.0` from the project root, substituting the new version number that you wish to publish. *Note the lack of a v prefix on the version number. This is important, do not add one.* The v prefix is added where appropriate by the script. Read through this paragraph in its entirety before running anything.
The Bash script in the root of the project, named `publish.sh` can be used to publish a new version of librespot and it's corresponding crates. the command should be used as follows from the project root: `./publish 0.1.0` from the project root, substituting the new version number that you wish to publish. *Note the lack of a v prefix on the version number. This is important, do not add one.* The v prefix is added where appropriate by the script.
Make sure that you are are starting from a clean working directory for both `dev` and `master`, completely up to date with remote and all local changes either committed and pushed or stashed.
You will want to perform a dry run first: `./publish --dry-run 0.1.0`. Please make note of any errors or warnings. In particular, you may need to explicitly inform Git which remote you want to track for the `master` branch like so: `git --track origin/master` (or whatever you have called the `librespot-org` remote `master` branch).
Depending on your system the script may fail to publish the main `librespot` crate after having published all the `librespot-xyz` sub-crates. If so then make sure the working directory is committed and pushed (watch `Cargo.toml`) and then run `cargo publish` manually after `publish.sh` finished.
To publish the crates your GitHub account needs to be authorized on `crates.io` by `librespot-org`.
## What the script does ## What the script does

View file

@ -1,24 +1,26 @@
[package] [package]
name = "librespot-audio" name = "librespot-audio"
version = "0.3.1" version = "0.4.1"
authors = ["Paul Lietar <paul@lietar.net>"] authors = ["Paul Lietar <paul@lietar.net>"]
description="The audio fetching and processing logic for librespot" description = "The audio fetching logic for librespot"
license = "MIT" license = "MIT"
repository = "https://github.com/librespot-org/librespot"
edition = "2018" edition = "2018"
[dependencies.librespot-core] [dependencies.librespot-core]
path = "../core" path = "../core"
version = "0.3.1" version = "0.4.1"
[dependencies] [dependencies]
aes = { version = "0.7", features = ["ctr"] } aes = "0.8"
byteorder = "1.4" byteorder = "1.4"
bytes = "1" bytes = "1"
ctr = "0.9"
futures-core = "0.3" futures-core = "0.3"
futures-util = "0.3" futures-util = "0.3"
hyper = { version = "0.14", features = ["client"] } hyper = { version = "0.14", features = ["client"] }
log = "0.4" log = "0.4"
parking_lot = { version = "0.11", features = ["deadlock_detection"] } parking_lot = { version = "0.12", features = ["deadlock_detection"] }
tempfile = "3" tempfile = "3"
thiserror = "1.0" thiserror = "1.0"
tokio = { version = "1", features = ["macros", "parking_lot", "sync"] } tokio = { version = "1", features = ["macros", "parking_lot", "sync"] }

View file

@ -1,9 +1,8 @@
use std::io; use std::io;
use aes::{ use aes::cipher::{KeyIvInit, StreamCipher, StreamCipherSeek};
cipher::{generic_array::GenericArray, NewCipher, StreamCipher, StreamCipherSeek},
Aes128Ctr, type Aes128Ctr = ctr::Ctr128BE<aes::Aes128>;
};
use librespot_core::audio_key::AudioKey; use librespot_core::audio_key::AudioKey;
@ -19,12 +18,12 @@ pub struct AudioDecrypt<T: io::Read> {
impl<T: io::Read> AudioDecrypt<T> { impl<T: io::Read> AudioDecrypt<T> {
pub fn new(key: Option<AudioKey>, reader: T) -> AudioDecrypt<T> { pub fn new(key: Option<AudioKey>, reader: T) -> AudioDecrypt<T> {
let cipher = key.map(|key| { let cipher = if let Some(key) = key {
Aes128Ctr::new( Aes128Ctr::new_from_slices(&key.0, &AUDIO_AESIV).ok()
GenericArray::from_slice(&key.0), } else {
GenericArray::from_slice(&AUDIO_AESIV), // some files are unencrypted
) None
}); };
AudioDecrypt { cipher, reader } AudioDecrypt { cipher, reader }
} }

View file

@ -1,6 +1,6 @@
[package] [package]
name = "librespot-connect" name = "librespot-connect"
version = "0.3.1" version = "0.4.1"
authors = ["Paul Lietar <paul@lietar.net>"] authors = ["Paul Lietar <paul@lietar.net>"]
description = "The discovery and Spotify Connect logic for librespot" description = "The discovery and Spotify Connect logic for librespot"
license = "MIT" license = "MIT"
@ -21,12 +21,12 @@ tokio-stream = "0.1"
[dependencies.librespot-core] [dependencies.librespot-core]
path = "../core" path = "../core"
version = "0.3.1" version = "0.4.1"
[dependencies.librespot-playback] [dependencies.librespot-playback]
path = "../playback" path = "../playback"
version = "0.3.1" version = "0.4.1"
[dependencies.librespot-protocol] [dependencies.librespot-protocol]
path = "../protocol" path = "../protocol"
version = "0.3.1" version = "0.4.1"

View file

@ -331,7 +331,7 @@ impl Spirc {
); );
// Connect *after* all message listeners are registered // Connect *after* all message listeners are registered
session.connect(credentials).await?; session.connect(credentials, true).await?;
let canonical_username = &session.username(); let canonical_username = &session.username();
debug!("canonical_username: {}", canonical_username); debug!("canonical_username: {}", canonical_username);
@ -612,6 +612,7 @@ impl SpircTask {
} }
SpircCommand::Shutdown => { SpircCommand::Shutdown => {
CommandSender::new(self, MessageType::kMessageTypeGoodbye).send()?; CommandSender::new(self, MessageType::kMessageTypeGoodbye).send()?;
self.player.stop();
self.shutdown = true; self.shutdown = true;
if let Some(rx) = self.commands.as_mut() { if let Some(rx) = self.commands.as_mut() {
rx.close() rx.close()

View file

@ -1,6 +1,6 @@
[package] [package]
name = "librespot-core" name = "librespot-core"
version = "0.3.1" version = "0.4.1"
authors = ["Paul Lietar <paul@lietar.net>"] authors = ["Paul Lietar <paul@lietar.net>"]
build = "build.rs" build = "build.rs"
description = "The core functionality provided by librespot" description = "The core functionality provided by librespot"
@ -10,10 +10,10 @@ edition = "2018"
[dependencies.librespot-protocol] [dependencies.librespot-protocol]
path = "../protocol" path = "../protocol"
version = "0.3.1" version = "0.4.1"
[dependencies] [dependencies]
aes = "0.7" aes = "0.8"
base64 = "0.13" base64 = "0.13"
byteorder = "1.4" byteorder = "1.4"
bytes = "1" bytes = "1"
@ -22,7 +22,7 @@ form_urlencoded = "1.0"
futures-core = "0.3" futures-core = "0.3"
futures-util = { version = "0.3", features = ["alloc", "bilock", "sink", "unstable"] } futures-util = { version = "0.3", features = ["alloc", "bilock", "sink", "unstable"] }
hmac = "0.12" hmac = "0.12"
httparse = "1.5" httparse = "1.7"
http = "0.2" http = "0.2"
hyper = { version = "0.14", features = ["client", "http1", "http2", "tcp"] } hyper = { version = "0.14", features = ["client", "http1", "http2", "tcp"] }
hyper-proxy = { version = "0.9", default-features = false, features = ["rustls"] } hyper-proxy = { version = "0.9", default-features = false, features = ["rustls"] }
@ -33,30 +33,30 @@ num-bigint = { version = "0.4", features = ["rand"] }
num-derive = "0.3" num-derive = "0.3"
num-integer = "0.1" num-integer = "0.1"
num-traits = "0.2" num-traits = "0.2"
once_cell = "1.9" once_cell = "1"
parking_lot = { version = "0.11", features = ["deadlock_detection"] } parking_lot = { version = "0.12", features = ["deadlock_detection"] }
pbkdf2 = { version = "0.10", default-features = false, features = ["hmac"] } pbkdf2 = { version = "0.11", default-features = false, features = ["hmac"] }
priority-queue = "1.2.1" priority-queue = "1.2"
protobuf = "2" protobuf = "2"
quick-xml = { version = "0.22", features = ["serialize"] } quick-xml = { version = "0.23", features = ["serialize"] }
rand = "0.8" rand = "0.8"
rsa = { version = "0.5", default-features = false, features = ["alloc"] } rsa = "0.6"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
sha-1 = "0.10" sha1 = "0.10"
shannon = "0.2" shannon = "0.2"
thiserror = "1.0" thiserror = "1.0"
time = "0.3" time = "0.3"
tokio = { version = "1", features = ["io-util", "macros", "net", "parking_lot", "rt", "sync", "time"] } tokio = { version = "1", features = ["io-util", "macros", "net", "parking_lot", "rt", "sync", "time"] }
tokio-stream = "0.1" tokio-stream = "0.1"
tokio-tungstenite = { version = "*", default-features = false, features = ["rustls-tls-native-roots"] } tokio-tungstenite = { version = "*", default-features = false, features = ["rustls-tls-native-roots"] }
tokio-util = { version = "0.6", features = ["codec"] } tokio-util = { version = "0.7", features = ["codec"] }
url = "2" url = "2"
uuid = { version = "0.8", default-features = false, features = ["v4"] } uuid = { version = "1", default-features = false, features = ["v4"] }
[build-dependencies] [build-dependencies]
rand = "0.8" rand = "0.8"
vergen = { version = "6", default-features = false, features = ["build", "git"] } vergen = { version = "7", default-features = false, features = ["build", "git"] }
[dev-dependencies] [dev-dependencies]
env_logger = "0.9" env_logger = "0.9"

View file

@ -13,11 +13,14 @@ fn main() {
vergen(config).expect("Unable to generate the cargo keys!"); vergen(config).expect("Unable to generate the cargo keys!");
let build_id: String = rand::thread_rng() let build_id = match std::env::var("SOURCE_DATE_EPOCH") {
Ok(val) => val,
Err(_) => rand::thread_rng()
.sample_iter(Alphanumeric) .sample_iter(Alphanumeric)
.take(8) .take(8)
.map(char::from) .map(char::from)
.collect(); .collect(),
};
println!("cargo:rustc-env=LIBRESPOT_BUILD_ID={}", build_id); println!("cargo:rustc-env=LIBRESPOT_BUILD_ID={}", build_id);
} }

View file

@ -1,6 +1,6 @@
use std::io::{self, Read}; use std::io::{self, Read};
use aes::{Aes192, BlockDecrypt}; use aes::Aes192;
use byteorder::{BigEndian, ByteOrder}; use byteorder::{BigEndian, ByteOrder};
use hmac::Hmac; use hmac::Hmac;
use pbkdf2::pbkdf2; use pbkdf2::pbkdf2;
@ -106,13 +106,12 @@ impl Credentials {
// decrypt data using ECB mode without padding // decrypt data using ECB mode without padding
let blob = { let blob = {
use aes::cipher::generic_array::typenum::Unsigned;
use aes::cipher::generic_array::GenericArray; use aes::cipher::generic_array::GenericArray;
use aes::cipher::{BlockCipher, NewBlockCipher}; use aes::cipher::{BlockDecrypt, BlockSizeUser, KeyInit};
let mut data = base64::decode(encrypted_blob)?; let mut data = base64::decode(encrypted_blob)?;
let cipher = Aes192::new(GenericArray::from_slice(&key)); let cipher = Aes192::new(GenericArray::from_slice(&key));
let block_size = <Aes192 as BlockCipher>::BlockSize::to_usize(); let block_size = Aes192::block_size();
for chunk in data.chunks_exact_mut(block_size) { for chunk in data.chunks_exact_mut(block_size) {
cipher.decrypt_block(GenericArray::from_mut_slice(chunk)); cipher.decrypt_block(GenericArray::from_mut_slice(chunk));

View file

@ -375,7 +375,7 @@ impl Cache {
path path
}), }),
Err(e) => { Err(e) => {
warn!("{}", e); warn!("Invalid FileId: {}", e);
None None
} }
} }

View file

@ -15,7 +15,7 @@ pub struct SessionConfig {
impl Default for SessionConfig { impl Default for SessionConfig {
fn default() -> SessionConfig { fn default() -> SessionConfig {
let device_id = uuid::Uuid::new_v4().to_hyphenated().to_string(); let device_id = uuid::Uuid::new_v4().as_hyphenated().to_string();
SessionConfig { SessionConfig {
client_id: KEYMASTER_CLIENT_ID.to_owned(), client_id: KEYMASTER_CLIENT_ID.to_owned(),
device_id, device_id,

View file

@ -126,7 +126,11 @@ impl Session {
})) }))
} }
pub async fn connect(&self, credentials: Credentials) -> Result<(), Error> { pub async fn connect(
&self,
credentials: Credentials,
store_credentials: bool,
) -> Result<(), Error> {
let ap = self.apresolver().resolve("accesspoint").await?; let ap = self.apresolver().resolve("accesspoint").await?;
info!("Connecting to AP \"{}:{}\"", ap.0, ap.1); info!("Connecting to AP \"{}:{}\"", ap.0, ap.1);
let mut transport = connection::connect(&ap.0, ap.1, self.config().proxy.as_ref()).await?; let mut transport = connection::connect(&ap.0, ap.1, self.config().proxy.as_ref()).await?;
@ -136,8 +140,10 @@ impl Session {
info!("Authenticated as \"{}\" !", reusable_credentials.username); info!("Authenticated as \"{}\" !", reusable_credentials.username);
self.set_username(&reusable_credentials.username); self.set_username(&reusable_credentials.username);
if let Some(cache) = self.cache() { if let Some(cache) = self.cache() {
if store_credentials {
cache.save_credentials(&reusable_credentials); cache.save_credentials(&reusable_credentials);
} }
}
let (tx_connection, rx_connection) = mpsc::unbounded_channel(); let (tx_connection, rx_connection) = mpsc::unbounded_channel();
self.0 self.0

View file

@ -1,5 +1,6 @@
use std::{ use std::{
convert::TryInto, convert::TryInto,
fmt::Write,
time::{Duration, Instant}, time::{Duration, Instant},
}; };
@ -273,7 +274,7 @@ impl SpClient {
Some(_) => "&", Some(_) => "&",
None => "?", None => "?",
}; };
url.push_str(&format!("{}product=0", separator)); let _ = write!(url, "{}product=0", separator);
let mut request = Request::builder() let mut request = Request::builder()
.method(method) .method(method)
@ -464,7 +465,7 @@ impl SpClient {
Some(_) => "&", Some(_) => "&",
None => "?", None => "?",
}; };
url.push_str(&format!("{}cid={}", separator, self.session().client_id())); let _ = write!(url, "{}cid={}", separator, self.session().client_id());
self.request_url(url).await self.request_url(url).await
} }

View file

@ -200,7 +200,6 @@ impl SpotifyId {
/// character long `String`. /// character long `String`.
/// ///
/// [canonically]: https://developer.spotify.com/documentation/web-api/#spotify-uris-and-ids /// [canonically]: https://developer.spotify.com/documentation/web-api/#spotify-uris-and-ids
#[allow(clippy::wrong_self_convention)] #[allow(clippy::wrong_self_convention)]
pub fn to_base62(&self) -> Result<String, Error> { pub fn to_base62(&self) -> Result<String, Error> {
let mut dst = [0u8; 22]; let mut dst = [0u8; 22];
@ -258,7 +257,6 @@ impl SpotifyId {
/// be encoded as `unknown`. /// be encoded as `unknown`.
/// ///
/// [Spotify URI]: https://developer.spotify.com/documentation/web-api/#spotify-uris-and-ids /// [Spotify URI]: https://developer.spotify.com/documentation/web-api/#spotify-uris-and-ids
#[allow(clippy::wrong_self_convention)] #[allow(clippy::wrong_self_convention)]
pub fn to_uri(&self) -> Result<String, Error> { pub fn to_uri(&self) -> Result<String, Error> {
// 8 chars for the "spotify:" prefix + 1 colon + 22 chars base62 encoded ID = 31 // 8 chars for the "spotify:" prefix + 1 colon + 22 chars base62 encoded ID = 31

View file

@ -7,7 +7,7 @@ use librespot_core::{authentication::Credentials, config::SessionConfig, session
#[tokio::test] #[tokio::test]
async fn test_connection() { async fn test_connection() {
timeout(Duration::from_secs(30), async { timeout(Duration::from_secs(30), async {
let result = Session::new(SessionConfig::default(), None) let result = Session::new(SessionConfig::default(), None, false)
.connect(Credentials::with_password("test", "test")) .connect(Credentials::with_password("test", "test"))
.await; .await;

View file

@ -1,6 +1,6 @@
[package] [package]
name = "librespot-discovery" name = "librespot-discovery"
version = "0.3.1" version = "0.4.1"
authors = ["Paul Lietar <paul@lietar.net>"] authors = ["Paul Lietar <paul@lietar.net>"]
description = "The discovery logic for librespot" description = "The discovery logic for librespot"
license = "MIT" license = "MIT"
@ -8,26 +8,27 @@ repository = "https://github.com/librespot-org/librespot"
edition = "2018" edition = "2018"
[dependencies] [dependencies]
aes = { version = "0.7", features = ["ctr"] } aes = "0.8"
base64 = "0.13" base64 = "0.13"
cfg-if = "1.0" cfg-if = "1.0"
ctr = "0.9"
dns-sd = { version = "0.1.3", optional = true } dns-sd = { version = "0.1.3", optional = true }
form_urlencoded = "1.0" form_urlencoded = "1.0"
futures-core = "0.3" futures-core = "0.3"
futures-util = "0.3" futures-util = "0.3"
hmac = "0.12" hmac = "0.12"
hyper = { version = "0.14", features = ["http1", "server", "tcp"] } hyper = { version = "0.14", features = ["http1", "server", "tcp"] }
libmdns = "0.6" libmdns = "0.7"
log = "0.4" log = "0.4"
rand = "0.8" rand = "0.8"
serde_json = "1.0" serde_json = "1.0"
sha-1 = "0.10" sha1 = "0.10"
thiserror = "1.0" thiserror = "1.0"
tokio = { version = "1", features = ["parking_lot", "sync", "rt"] } tokio = { version = "1", features = ["parking_lot", "sync", "rt"] }
[dependencies.librespot-core] [dependencies.librespot-core]
path = "../core" path = "../core"
version = "0.3.1" version = "0.4.1"
[dev-dependencies] [dev-dependencies]
futures = "0.3" futures = "0.3"

View file

@ -52,13 +52,13 @@ pub struct Builder {
/// Errors that can occur while setting up a [`Discovery`] instance. /// Errors that can occur while setting up a [`Discovery`] instance.
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum DiscoveryError { pub enum DiscoveryError {
/// Setting up service discovery via DNS-SD failed. #[error("Creating SHA1 block cipher failed")]
AesError(#[from] aes::cipher::InvalidLength),
#[error("Setting up dns-sd failed: {0}")] #[error("Setting up dns-sd failed: {0}")]
DnsSdError(#[from] io::Error), DnsSdError(#[from] io::Error),
/// Setting up the http server failed.
#[error("Creating SHA1 HMAC failed for base key {0:?}")] #[error("Creating SHA1 HMAC failed for base key {0:?}")]
HmacError(Vec<u8>), HmacError(Vec<u8>),
#[error("Setting up the http server failed: {0}")] #[error("Setting up the HTTP server failed: {0}")]
HttpServerError(#[from] hyper::Error), HttpServerError(#[from] hyper::Error),
#[error("Missing params for key {0}")] #[error("Missing params for key {0}")]
ParamsError(&'static str), ParamsError(&'static str),
@ -67,6 +67,7 @@ pub enum DiscoveryError {
impl From<DiscoveryError> for Error { impl From<DiscoveryError> for Error {
fn from(err: DiscoveryError) -> Self { fn from(err: DiscoveryError) -> Self {
match err { match err {
DiscoveryError::AesError(_) => Error::unavailable(err),
DiscoveryError::DnsSdError(_) => Error::unavailable(err), DiscoveryError::DnsSdError(_) => Error::unavailable(err),
DiscoveryError::HmacError(_) => Error::invalid_argument(err), DiscoveryError::HmacError(_) => Error::invalid_argument(err),
DiscoveryError::HttpServerError(_) => Error::unavailable(err), DiscoveryError::HttpServerError(_) => Error::unavailable(err),

View file

@ -8,11 +8,7 @@ use std::{
task::{Context, Poll}, task::{Context, Poll},
}; };
use aes::{ use aes::cipher::{KeyIvInit, StreamCipher};
cipher::generic_array::GenericArray,
cipher::{NewCipher, StreamCipher},
Aes128Ctr,
};
use futures_core::Stream; use futures_core::Stream;
use futures_util::{FutureExt, TryFutureExt}; use futures_util::{FutureExt, TryFutureExt};
use hmac::{Hmac, Mac}; use hmac::{Hmac, Mac};
@ -32,6 +28,8 @@ use crate::{
core::{authentication::Credentials, diffie_hellman::DhLocalKeys, Error}, core::{authentication::Credentials, diffie_hellman::DhLocalKeys, Error},
}; };
type Aes128Ctr = ctr::Ctr128BE<aes::Aes128>;
type Params<'a> = BTreeMap<Cow<'a, str>, Cow<'a, str>>; type Params<'a> = BTreeMap<Cow<'a, str>, Cow<'a, str>>;
pub struct Config { pub struct Config {
@ -151,10 +149,8 @@ impl RequestHandler {
let decrypted = { let decrypted = {
let mut data = encrypted.to_vec(); let mut data = encrypted.to_vec();
let mut cipher = Aes128Ctr::new( let mut cipher = Aes128Ctr::new_from_slices(&encryption_key[0..16], iv)
GenericArray::from_slice(&encryption_key[0..16]), .map_err(DiscoveryError::AesError)?;
GenericArray::from_slice(iv),
);
cipher.apply_keystream(&mut data); cipher.apply_keystream(&mut data);
data data
}; };

View file

@ -19,7 +19,7 @@ async fn main() {
let credentials = Credentials::with_password(&args[1], &args[2]); let credentials = Credentials::with_password(&args[1], &args[2]);
let session = Session::new(session_config, None); let session = Session::new(session_config, None);
match session.connect(credentials).await { match session.connect(credentials, false).await {
Ok(()) => println!( Ok(()) => println!(
"Token: {:#?}", "Token: {:#?}",
session.token_provider().get_token(SCOPES).await.unwrap() session.token_provider().get_token(SCOPES).await.unwrap()

View file

@ -7,6 +7,7 @@ use librespot::{
playback::{ playback::{
audio_backend, audio_backend,
config::{AudioFormat, PlayerConfig}, config::{AudioFormat, PlayerConfig},
mixer::NoOpVolume,
player::Player, player::Player,
}, },
}; };
@ -30,12 +31,12 @@ async fn main() {
println!("Connecting..."); println!("Connecting...");
let session = Session::new(session_config, None); let session = Session::new(session_config, None);
if let Err(e) = session.connect(credentials).await { if let Err(e) = session.connect(credentials, false).await {
println!("Error connecting: {}", e); println!("Error connecting: {}", e);
exit(1); exit(1);
} }
let (mut player, _) = Player::new(player_config, session, None, move || { let (mut player, _) = Player::new(player_config, session, Box::new(NoOpVolume), move || {
backend(None, audio_format) backend(None, audio_format)
}); });

View file

@ -28,7 +28,7 @@ async fn main() {
}); });
let session = Session::new(session_config, None); let session = Session::new(session_config, None);
if let Err(e) = session.connect(credentials).await { if let Err(e) = session.connect(credentials, false).await {
println!("Error connecting: {}", e); println!("Error connecting: {}", e);
exit(1); exit(1);
} }

View file

@ -1,6 +1,6 @@
[package] [package]
name = "librespot-metadata" name = "librespot-metadata"
version = "0.3.1" version = "0.4.1"
authors = ["Paul Lietar <paul@lietar.net>"] authors = ["Paul Lietar <paul@lietar.net>"]
description = "The metadata logic for librespot" description = "The metadata logic for librespot"
license = "MIT" license = "MIT"
@ -14,12 +14,12 @@ bytes = "1"
log = "0.4" log = "0.4"
protobuf = "2" protobuf = "2"
thiserror = "1" thiserror = "1"
uuid = { version = "0.8", default-features = false } uuid = { version = "1", default-features = false }
[dependencies.librespot-core] [dependencies.librespot-core]
path = "../core" path = "../core"
version = "0.3.1" version = "0.4.1"
[dependencies.librespot-protocol] [dependencies.librespot-protocol]
path = "../protocol" path = "../protocol"
version = "0.3.1" version = "0.4.1"

View file

@ -1,3 +1,5 @@
use std::fmt::Write;
use crate::MetadataError; use crate::MetadataError;
use librespot_core::{Error, Session}; use librespot_core::{Error, Session};
@ -13,10 +15,10 @@ pub trait MercuryRequest {
Some(_) => "&", Some(_) => "&",
None => "?", None => "?",
}; };
metrics_uri.push_str(&format!("{}country={}", separator, session.country())); let _ = write!(metrics_uri, "{}country={}", separator, session.country());
if let Some(product) = session.get_user_attribute("type") { if let Some(product) = session.get_user_attribute("type") {
metrics_uri.push_str(&format!("&product={}", product)); let _ = write!(metrics_uri, "&product={}", product);
} }
trace!("Requesting {}", metrics_uri); trace!("Requesting {}", metrics_uri);

View file

@ -1,6 +1,6 @@
[package] [package]
name = "librespot-playback" name = "librespot-playback"
version = "0.3.1" version = "0.4.1"
authors = ["Sasha Hilton <sashahilton00@gmail.com>"] authors = ["Sasha Hilton <sashahilton00@gmail.com>"]
description = "The audio playback logic for librespot" description = "The audio playback logic for librespot"
license = "MIT" license = "MIT"
@ -9,21 +9,21 @@ edition = "2018"
[dependencies.librespot-audio] [dependencies.librespot-audio]
path = "../audio" path = "../audio"
version = "0.3.1" version = "0.4.1"
[dependencies.librespot-core] [dependencies.librespot-core]
path = "../core" path = "../core"
version = "0.3.1" version = "0.4.1"
[dependencies.librespot-metadata] [dependencies.librespot-metadata]
path = "../metadata" path = "../metadata"
version = "0.3.1" version = "0.4.1"
[dependencies] [dependencies]
byteorder = "1" byteorder = "1"
futures-util = "0.3" futures-util = "0.3"
log = "0.4" log = "0.4"
parking_lot = { version = "0.11", features = ["deadlock_detection"] } parking_lot = { version = "0.12", features = ["deadlock_detection"] }
shell-words = "1.0.0" shell-words = "1.1"
thiserror = "1.0" thiserror = "1"
tokio = { version = "1", features = ["parking_lot", "rt", "rt-multi-thread", "sync"] } tokio = { version = "1", features = ["parking_lot", "rt", "rt-multi-thread", "sync"] }
zerocopy = "0.6" zerocopy = "0.6"
@ -32,11 +32,11 @@ alsa = { version = "0.6", optional = true }
portaudio-rs = { version = "0.3", optional = true } portaudio-rs = { version = "0.3", optional = true }
libpulse-binding = { version = "2", optional = true, default-features = false } libpulse-binding = { version = "2", optional = true, default-features = false }
libpulse-simple-binding = { version = "2", optional = true, default-features = false } libpulse-simple-binding = { version = "2", optional = true, default-features = false }
jack = { version = "0.8", optional = true } jack = { version = "0.10", optional = true }
sdl2 = { version = "0.35", optional = true } sdl2 = { version = "0.35", optional = true }
gst = { package = "gstreamer", version = "0.18", optional = true } gstreamer = { version = "0.18", optional = true }
gst-app = { package = "gstreamer-app", version = "0.18", optional = true } gstreamer-app = { version = "0.18", optional = true }
gst-audio = { package = "gstreamer-audio", version = "0.18", optional = true } gstreamer-audio = { version = "0.18", optional = true }
glib = { version = "0.15", optional = true } glib = { version = "0.15", optional = true }
# Rodio dependencies # Rodio dependencies
@ -61,6 +61,6 @@ jackaudio-backend = ["jack"]
rodio-backend = ["rodio", "cpal"] rodio-backend = ["rodio", "cpal"]
rodiojack-backend = ["rodio", "cpal/jack"] rodiojack-backend = ["rodio", "cpal/jack"]
sdl-backend = ["sdl2"] sdl-backend = ["sdl2"]
gstreamer-backend = ["gst", "gst-app", "gst-audio", "glib"] gstreamer-backend = ["gstreamer", "gstreamer-app", "gstreamer-audio", "glib"]
passthrough-decoder = ["ogg"] passthrough-decoder = ["ogg"]

View file

@ -6,7 +6,6 @@ use crate::{NUM_CHANNELS, SAMPLE_RATE};
use alsa::device_name::HintIter; use alsa::device_name::HintIter;
use alsa::pcm::{Access, Format, Frames, HwParams, PCM}; use alsa::pcm::{Access, Format, Frames, HwParams, PCM};
use alsa::{Direction, ValueOr}; use alsa::{Direction, ValueOr};
use std::cmp::min;
use std::process::exit; use std::process::exit;
use thiserror::Error; use thiserror::Error;
@ -467,7 +466,7 @@ impl SinkAsBytes for AlsaSink {
loop { loop {
let data_left = data_len - start_index; let data_left = data_len - start_index;
let space_left = capacity - self.period_buffer.len(); let space_left = capacity - self.period_buffer.len();
let data_to_buffer = min(data_left, space_left); let data_to_buffer = data_left.min(space_left);
let end_index = start_index + data_to_buffer; let end_index = start_index + data_to_buffer;
self.period_buffer self.period_buffer

View file

@ -1,9 +1,13 @@
use gst::{ use gstreamer::{
event::{FlushStart, FlushStop}, event::{FlushStart, FlushStop},
prelude::*, prelude::*,
State, State,
}; };
use gstreamer as gst;
use gstreamer_app as gst_app;
use gstreamer_audio as gst_audio;
use parking_lot::Mutex; use parking_lot::Mutex;
use std::sync::Arc; use std::sync::Arc;
@ -41,7 +45,7 @@ impl Open for GstreamerSink {
let gst_caps = gst_info.to_caps().expect("Failed to create GStreamer caps"); let gst_caps = gst_info.to_caps().expect("Failed to create GStreamer caps");
let sample_size = format.size(); let sample_size = format.size();
let gst_bytes = NUM_CHANNELS as usize * 1024 * sample_size; let gst_bytes = NUM_CHANNELS as usize * 2048 * sample_size;
let pipeline = gst::Pipeline::new(None); let pipeline = gst::Pipeline::new(None);
let appsrc = gst::ElementFactory::make("appsrc", None) let appsrc = gst::ElementFactory::make("appsrc", None)

View file

@ -2,9 +2,38 @@ use super::{Open, Sink, SinkAsBytes, SinkError, SinkResult};
use crate::config::AudioFormat; use crate::config::AudioFormat;
use crate::convert::Converter; use crate::convert::Converter;
use crate::decoder::AudioPacket; use crate::decoder::AudioPacket;
use std::fs::OpenOptions; use std::fs::OpenOptions;
use std::io::{self, Write}; use std::io::{self, Write};
use std::process::exit; use std::process::exit;
use thiserror::Error;
#[derive(Debug, Error)]
enum StdoutError {
#[error("<StdoutSink> {0}")]
OnWrite(std::io::Error),
#[error("<StdoutSink> File Path {file} Can Not be Opened and/or Created, {e}")]
OpenFailure { file: String, e: std::io::Error },
#[error("<StdoutSink> Failed to Flush the Output Stream, {0}")]
FlushFailure(std::io::Error),
#[error("<StdoutSink> The Output Stream is None")]
NoOutput,
}
impl From<StdoutError> for SinkError {
fn from(e: StdoutError) -> SinkError {
use StdoutError::*;
let es = e.to_string();
match e {
FlushFailure(_) | OnWrite(_) => SinkError::OnWrite(es),
OpenFailure { .. } => SinkError::ConnectionRefused(es),
NoOutput => SinkError::NotConnected(es),
}
}
}
pub struct StdoutSink { pub struct StdoutSink {
output: Option<Box<dyn Write>>, output: Option<Box<dyn Write>>,
@ -15,13 +44,12 @@ pub struct StdoutSink {
impl Open for StdoutSink { impl Open for StdoutSink {
fn open(file: Option<String>, format: AudioFormat) -> Self { fn open(file: Option<String>, format: AudioFormat) -> Self {
if let Some("?") = file.as_deref() { if let Some("?") = file.as_deref() {
info!("Usage:"); println!("\nUsage:\n\nOutput to stdout:\n\n\t--backend pipe\n\nOutput to file:\n\n\t--backend pipe --device {{filename}}\n");
println!(" Output to stdout: --backend pipe");
println!(" Output to file: --backend pipe --device {{filename}}");
exit(0); exit(0);
} }
info!("Using pipe sink with format: {:?}", format); info!("Using StdoutSink (pipe) with format: {:?}", format);
Self { Self {
output: None, output: None,
file, file,
@ -32,21 +60,31 @@ impl Open for StdoutSink {
impl Sink for StdoutSink { impl Sink for StdoutSink {
fn start(&mut self) -> SinkResult<()> { fn start(&mut self) -> SinkResult<()> {
if self.output.is_none() { self.output.get_or_insert({
let output: Box<dyn Write> = match self.file.as_deref() { match self.file.as_deref() {
Some(file) => { Some(file) => Box::new(
let open_op = OpenOptions::new() OpenOptions::new()
.write(true) .write(true)
.create(true) .create(true)
.open(file) .open(file)
.map_err(|e| SinkError::ConnectionRefused(e.to_string()))?; .map_err(|e| StdoutError::OpenFailure {
Box::new(open_op) file: file.to_string(),
} e,
})?,
),
None => Box::new(io::stdout()), None => Box::new(io::stdout()),
};
self.output = Some(output);
} }
});
Ok(())
}
fn stop(&mut self) -> SinkResult<()> {
self.output
.take()
.ok_or(StdoutError::NoOutput)?
.flush()
.map_err(StdoutError::FlushFailure)?;
Ok(()) Ok(())
} }
@ -56,19 +94,11 @@ impl Sink for StdoutSink {
impl SinkAsBytes for StdoutSink { impl SinkAsBytes for StdoutSink {
fn write_bytes(&mut self, data: &[u8]) -> SinkResult<()> { fn write_bytes(&mut self, data: &[u8]) -> SinkResult<()> {
match self.output.as_deref_mut() { self.output
Some(output) => { .as_deref_mut()
output .ok_or(StdoutError::NoOutput)?
.write_all(data) .write_all(data)
.map_err(|e| SinkError::OnWrite(e.to_string()))?; .map_err(StdoutError::OnWrite)?;
output
.flush()
.map_err(|e| SinkError::OnWrite(e.to_string()))?;
}
None => {
return Err(SinkError::NotConnected("Output is None".to_string()));
}
}
Ok(()) Ok(())
} }

View file

@ -4,30 +4,75 @@ use crate::convert::Converter;
use crate::decoder::AudioPacket; use crate::decoder::AudioPacket;
use shell_words::split; use shell_words::split;
use std::io::Write; use std::io::{ErrorKind, Write};
use std::process::{exit, Child, Command, Stdio}; use std::process::{exit, Child, Command, Stdio};
use thiserror::Error;
#[derive(Debug, Error)]
enum SubprocessError {
#[error("<SubprocessSink> {0}")]
OnWrite(std::io::Error),
#[error("<SubprocessSink> Command {command} Can Not be Executed, {e}")]
SpawnFailure { command: String, e: std::io::Error },
#[error("<SubprocessSink> Failed to Parse Command args for {command}, {e}")]
InvalidArgs {
command: String,
e: shell_words::ParseError,
},
#[error("<SubprocessSink> Failed to Flush the Subprocess, {0}")]
FlushFailure(std::io::Error),
#[error("<SubprocessSink> Failed to Kill the Subprocess, {0}")]
KillFailure(std::io::Error),
#[error("<SubprocessSink> Failed to Wait for the Subprocess to Exit, {0}")]
WaitFailure(std::io::Error),
#[error("<SubprocessSink> The Subprocess is no longer able to accept Bytes")]
WriteZero,
#[error("<SubprocessSink> Missing Required Shell Command")]
MissingCommand,
#[error("<SubprocessSink> The Subprocess is None")]
NoChild,
#[error("<SubprocessSink> The Subprocess's stdin is None")]
NoStdin,
}
impl From<SubprocessError> for SinkError {
fn from(e: SubprocessError) -> SinkError {
use SubprocessError::*;
let es = e.to_string();
match e {
FlushFailure(_) | KillFailure(_) | WaitFailure(_) | OnWrite(_) | WriteZero => {
SinkError::OnWrite(es)
}
SpawnFailure { .. } => SinkError::ConnectionRefused(es),
MissingCommand | InvalidArgs { .. } => SinkError::InvalidParams(es),
NoChild | NoStdin => SinkError::NotConnected(es),
}
}
}
pub struct SubprocessSink { pub struct SubprocessSink {
shell_command: String, shell_command: Option<String>,
child: Option<Child>, child: Option<Child>,
format: AudioFormat, format: AudioFormat,
} }
impl Open for SubprocessSink { impl Open for SubprocessSink {
fn open(shell_command: Option<String>, format: AudioFormat) -> Self { fn open(shell_command: Option<String>, format: AudioFormat) -> Self {
let shell_command = match shell_command.as_deref() { if let Some("?") = shell_command.as_deref() {
Some("?") => { println!("\nUsage:\n\nOutput to a Subprocess:\n\n\t--backend subprocess --device {{shell_command}}\n");
info!("Usage: --backend subprocess --device {{shell_command}}");
exit(0); exit(0);
} }
Some(cmd) => cmd.to_owned(),
None => {
error!("subprocess sink requires specifying a shell command");
exit(1);
}
};
info!("Using subprocess sink with format: {:?}", format); info!("Using SubprocessSink with format: {:?}", format);
Self { Self {
shell_command, shell_command,
@ -39,49 +84,121 @@ impl Open for SubprocessSink {
impl Sink for SubprocessSink { impl Sink for SubprocessSink {
fn start(&mut self) -> SinkResult<()> { fn start(&mut self) -> SinkResult<()> {
let args = split(&self.shell_command).unwrap(); self.child.get_or_insert({
let child = Command::new(&args[0]) match self.shell_command.as_deref() {
Some(command) => {
let args = split(command).map_err(|e| SubprocessError::InvalidArgs {
command: command.to_string(),
e,
})?;
Command::new(&args[0])
.args(&args[1..]) .args(&args[1..])
.stdin(Stdio::piped()) .stdin(Stdio::piped())
.spawn() .spawn()
.map_err(|e| SinkError::ConnectionRefused(e.to_string()))?; .map_err(|e| SubprocessError::SpawnFailure {
self.child = Some(child); command: command.to_string(),
e,
})?
}
None => return Err(SubprocessError::MissingCommand.into()),
}
});
Ok(()) Ok(())
} }
fn stop(&mut self) -> SinkResult<()> { fn stop(&mut self) -> SinkResult<()> {
if let Some(child) = &mut self.child.take() { let child = &mut self.child.take().ok_or(SubprocessError::NoChild)?;
match child.try_wait() {
// The process has already exited
// nothing to do.
Ok(Some(_)) => Ok(()),
Ok(_) => {
// The process Must DIE!!!
child child
.kill() .stdin
.map_err(|e| SinkError::OnWrite(e.to_string()))?; .take()
child .ok_or(SubprocessError::NoStdin)?
.wait() .flush()
.map_err(|e| SinkError::OnWrite(e.to_string()))?; .map_err(SubprocessError::FlushFailure)?;
}
child.kill().map_err(SubprocessError::KillFailure)?;
child.wait().map_err(SubprocessError::WaitFailure)?;
Ok(()) Ok(())
} }
Err(e) => Err(SubprocessError::WaitFailure(e).into()),
}
}
sink_as_bytes!(); sink_as_bytes!();
} }
impl SinkAsBytes for SubprocessSink { impl SinkAsBytes for SubprocessSink {
fn write_bytes(&mut self, data: &[u8]) -> SinkResult<()> { fn write_bytes(&mut self, data: &[u8]) -> SinkResult<()> {
if let Some(child) = &mut self.child { // We get one attempted restart per write.
let child_stdin = child // We don't want to get stuck in a restart loop.
let mut restarted = false;
let mut start_index = 0;
let data_len = data.len();
let mut end_index = data_len;
loop {
match self
.child
.as_ref()
.ok_or(SubprocessError::NoChild)?
.stdin .stdin
.as_mut() .as_ref()
.ok_or_else(|| SinkError::NotConnected("Child is None".to_string()))?; .ok_or(SubprocessError::NoStdin)?
child_stdin .write(&data[start_index..end_index])
.write_all(data) {
.map_err(|e| SinkError::OnWrite(e.to_string()))?; Ok(0) => {
child_stdin // Potentially fatal.
.flush() // As per the docs a return value of 0
.map_err(|e| SinkError::OnWrite(e.to_string()))?; // means we shouldn't try to write to the
// process anymore so let's try a restart
// if we haven't already.
self.try_restart(SubprocessError::WriteZero, &mut restarted)?;
continue;
}
Ok(bytes_written) => {
// What we want, a successful write.
start_index = data_len.min(start_index + bytes_written);
end_index = data_len.min(start_index + bytes_written);
if end_index == data_len {
break Ok(());
}
}
// Non-fatal, retry the write.
Err(ref e) if e.kind() == ErrorKind::Interrupted => continue,
Err(e) => {
// Very possibly fatal,
// but let's try a restart anyway if we haven't already.
self.try_restart(SubprocessError::OnWrite(e), &mut restarted)?;
continue;
}
}
} }
Ok(())
} }
} }
impl SubprocessSink { impl SubprocessSink {
pub const NAME: &'static str = "subprocess"; pub const NAME: &'static str = "subprocess";
fn try_restart(&mut self, e: SubprocessError, restarted: &mut bool) -> SinkResult<()> {
// If the restart fails throw the original error back.
if !*restarted && self.stop().is_ok() && self.start().is_ok() {
*restarted = true;
Ok(())
} else {
Err(e.into())
}
}
} }

View file

@ -104,23 +104,33 @@ impl Mixer for AlsaMixer {
let min_db = min_millibel.to_db() as f64; let min_db = min_millibel.to_db() as f64;
let max_db = max_millibel.to_db() as f64; let max_db = max_millibel.to_db() as f64;
let mut db_range = f64::abs(max_db - min_db); let reported_db_range = f64::abs(max_db - min_db);
// Synchronize the volume control dB range with the mixer control, // Synchronize the volume control dB range with the mixer control,
// unless it was already set with a command line option. // unless it was already set with a command line option.
if !config.volume_ctrl.range_ok() { let db_range = if config.volume_ctrl.range_ok() {
if db_range > 100.0 {
debug!("Alsa mixer reported dB range > 100, which is suspect");
warn!("Please manually set `--volume-range` if this is incorrect");
}
config.volume_ctrl.set_db_range(db_range);
} else {
let db_range_override = config.volume_ctrl.db_range(); let db_range_override = config.volume_ctrl.db_range();
if db_range_override.is_normal() {
db_range_override
} else {
reported_db_range
}
} else {
config.volume_ctrl.set_db_range(reported_db_range);
reported_db_range
};
if reported_db_range == db_range {
debug!("Alsa dB volume range was reported as {}", reported_db_range);
if reported_db_range > 100.0 {
debug!("Alsa mixer reported dB range > 100, which is suspect");
debug!("Please manually set `--volume-range` if this is incorrect");
}
} else {
debug!( debug!(
"Alsa dB volume range was detected as {} but overridden as {}", "Alsa dB volume range was reported as {} but overridden to {}",
db_range, db_range_override reported_db_range, db_range
); );
db_range = db_range_override;
} }
// For hardware controls with a small range (24 dB or less), // For hardware controls with a small range (24 dB or less),

View file

@ -3,6 +3,8 @@ use crate::config::VolumeCtrl;
pub mod mappings; pub mod mappings;
use self::mappings::MappedCtrl; use self::mappings::MappedCtrl;
pub struct NoOpVolume;
pub trait Mixer: Send { pub trait Mixer: Send {
fn open(config: MixerConfig) -> Self fn open(config: MixerConfig) -> Self
where where
@ -11,13 +13,19 @@ pub trait Mixer: Send {
fn set_volume(&self, volume: u16); fn set_volume(&self, volume: u16);
fn volume(&self) -> u16; fn volume(&self) -> u16;
fn get_audio_filter(&self) -> Option<Box<dyn AudioFilter + Send>> { fn get_soft_volume(&self) -> Box<dyn VolumeGetter + Send> {
None Box::new(NoOpVolume)
} }
} }
pub trait AudioFilter { pub trait VolumeGetter {
fn modify_stream(&self, data: &mut [f64]); fn attenuation_factor(&self) -> f64;
}
impl VolumeGetter for NoOpVolume {
fn attenuation_factor(&self) -> f64 {
1.0
}
} }
pub mod softmixer; pub mod softmixer;

View file

@ -1,7 +1,7 @@
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc; use std::sync::Arc;
use super::AudioFilter; use super::VolumeGetter;
use super::{MappedCtrl, VolumeCtrl}; use super::{MappedCtrl, VolumeCtrl};
use super::{Mixer, MixerConfig}; use super::{Mixer, MixerConfig};
@ -35,10 +35,8 @@ impl Mixer for SoftMixer {
.store(mapped_volume.to_bits(), Ordering::Relaxed) .store(mapped_volume.to_bits(), Ordering::Relaxed)
} }
fn get_audio_filter(&self) -> Option<Box<dyn AudioFilter + Send>> { fn get_soft_volume(&self) -> Box<dyn VolumeGetter + Send> {
Some(Box::new(SoftVolumeApplier { Box::new(SoftVolume(self.volume.clone()))
volume: self.volume.clone(),
}))
} }
} }
@ -46,17 +44,10 @@ impl SoftMixer {
pub const NAME: &'static str = "softvol"; pub const NAME: &'static str = "softvol";
} }
struct SoftVolumeApplier { struct SoftVolume(Arc<AtomicU64>);
volume: Arc<AtomicU64>,
}
impl AudioFilter for SoftVolumeApplier { impl VolumeGetter for SoftVolume {
fn modify_stream(&self, data: &mut [f64]) { fn attenuation_factor(&self) -> f64 {
let volume = f64::from_bits(self.volume.load(Ordering::Relaxed)); f64::from_bits(self.0.load(Ordering::Relaxed))
if volume < 1.0 {
for x in data.iter_mut() {
*x *= volume;
}
}
} }
} }

View file

@ -37,7 +37,7 @@ use crate::{
core::{util::SeqGenerator, Error, Session, SpotifyId}, core::{util::SeqGenerator, Error, Session, SpotifyId},
decoder::{AudioDecoder, AudioPacket, AudioPacketPosition, SymphoniaDecoder}, decoder::{AudioDecoder, AudioPacket, AudioPacketPosition, SymphoniaDecoder},
metadata::audio::{AudioFileFormat, AudioFiles, AudioItem}, metadata::audio::{AudioFileFormat, AudioFiles, AudioItem},
mixer::AudioFilter, mixer::VolumeGetter,
}; };
#[cfg(feature = "passthrough-decoder")] #[cfg(feature = "passthrough-decoder")]
@ -81,7 +81,7 @@ struct PlayerInternal {
sink: Box<dyn Sink>, sink: Box<dyn Sink>,
sink_status: SinkStatus, sink_status: SinkStatus,
sink_event_callback: Option<SinkEventCallback>, sink_event_callback: Option<SinkEventCallback>,
audio_filter: Option<Box<dyn AudioFilter + Send>>, volume_getter: Box<dyn VolumeGetter + Send>,
event_senders: Vec<mpsc::UnboundedSender<PlayerEvent>>, event_senders: Vec<mpsc::UnboundedSender<PlayerEvent>>,
converter: Converter, converter: Converter,
@ -368,7 +368,7 @@ impl Player {
pub fn new<F>( pub fn new<F>(
config: PlayerConfig, config: PlayerConfig,
session: Session, session: Session,
audio_filter: Option<Box<dyn AudioFilter + Send>>, volume_getter: Box<dyn VolumeGetter + Send>,
sink_builder: F, sink_builder: F,
) -> (Player, PlayerEventChannel) ) -> (Player, PlayerEventChannel)
where where
@ -420,7 +420,7 @@ impl Player {
sink: sink_builder(), sink: sink_builder(),
sink_status: SinkStatus::Closed, sink_status: SinkStatus::Closed,
sink_event_callback: None, sink_event_callback: None,
audio_filter, volume_getter,
event_senders: [event_sender].to_vec(), event_senders: [event_sender].to_vec(),
converter, converter,
@ -1445,19 +1445,27 @@ impl PlayerInternal {
Some((_, mut packet)) => { Some((_, mut packet)) => {
if !packet.is_empty() { if !packet.is_empty() {
if let AudioPacket::Samples(ref mut data) = packet { if let AudioPacket::Samples(ref mut data) = packet {
// Get the volume for the packet.
// In the case of hardware volume control this will
// always be 1.0 (no change).
let volume = self.volume_getter.attenuation_factor();
// For the basic normalisation method, a normalisation factor of 1.0 indicates that // For the basic normalisation method, a normalisation factor of 1.0 indicates that
// there is nothing to normalise (all samples should pass unaltered). For the // there is nothing to normalise (all samples should pass unaltered). For the
// dynamic method, there may still be peaks that we want to shave off. // dynamic method, there may still be peaks that we want to shave off.
if self.config.normalisation {
if self.config.normalisation_method == NormalisationMethod::Basic // No matter the case we apply volume attenuation last if there is any.
&& normalisation_factor < 1.0 if !self.config.normalisation && volume < 1.0 {
for sample in data.iter_mut() {
*sample *= volume;
}
} else if self.config.normalisation_method == NormalisationMethod::Basic
&& (normalisation_factor < 1.0 || volume < 1.0)
{ {
for sample in data.iter_mut() { for sample in data.iter_mut() {
*sample *= normalisation_factor; *sample *= normalisation_factor * volume;
} }
} else if self.config.normalisation_method } else if self.config.normalisation_method == NormalisationMethod::Dynamic {
== NormalisationMethod::Dynamic
{
// zero-cost shorthands // zero-cost shorthands
let threshold_db = self.config.normalisation_threshold_dbfs; let threshold_db = self.config.normalisation_threshold_dbfs;
let knee_db = self.config.normalisation_knee_db; let knee_db = self.config.normalisation_knee_db;
@ -1527,8 +1535,7 @@ impl PlayerInternal {
// attack_cf * self.normalisation_peak + (1.0 - attack_cf) * self.normalisation_integrator // attack_cf * self.normalisation_peak + (1.0 - attack_cf) * self.normalisation_integrator
// Simplifies to: // Simplifies to:
// attack_cf * self.normalisation_peak - attack_cf * self.normalisation_integrator + self.normalisation_integrator // attack_cf * self.normalisation_peak - attack_cf * self.normalisation_integrator + self.normalisation_integrator
self.normalisation_peak = attack_cf self.normalisation_peak = attack_cf * self.normalisation_peak
* self.normalisation_peak
- attack_cf * self.normalisation_integrator - attack_cf * self.normalisation_integrator
+ self.normalisation_integrator; + self.normalisation_integrator;
@ -1540,14 +1547,9 @@ impl PlayerInternal {
// steps 7-8: conversion into level and multiplication into gain stage // steps 7-8: conversion into level and multiplication into gain stage
*sample *= db_to_ratio(-self.normalisation_peak); *sample *= db_to_ratio(-self.normalisation_peak);
} }
}
}
}
// Apply volume attenuation last. TODO: make this so we can chain *sample *= volume;
// the normaliser and mixer as a processing pipeline. }
if let Some(ref editor) = self.audio_filter {
editor.modify_stream(data)
} }
} }
@ -1979,7 +1981,7 @@ impl PlayerInternal {
fn handle_command(&mut self, cmd: PlayerCommand) -> PlayerResult { fn handle_command(&mut self, cmd: PlayerCommand) -> PlayerResult {
debug!("command={:?}", cmd); debug!("command={:?}", cmd);
let result = match cmd { match cmd {
PlayerCommand::Load { PlayerCommand::Load {
track_id, track_id,
play_request_id, play_request_id,
@ -2034,7 +2036,7 @@ impl PlayerInternal {
} }
}; };
Ok(result) Ok(())
} }
fn send_event(&mut self, event: PlayerEvent) { fn send_event(&mut self, event: PlayerEvent) {

View file

@ -1,6 +1,6 @@
[package] [package]
name = "librespot-protocol" name = "librespot-protocol"
version = "0.3.1" version = "0.4.1"
authors = ["Paul Liétar <paul@lietar.net>"] authors = ["Paul Liétar <paul@lietar.net>"]
build = "build.rs" build = "build.rs"
description = "The protobuf logic for communicating with Spotify servers" description = "The protobuf logic for communicating with Spotify servers"

View file

@ -27,14 +27,17 @@ function updateVersion {
do do
if [ "$CRATE" = "librespot" ] if [ "$CRATE" = "librespot" ]
then then
CRATE='' CRATE_DIR=''
else
CRATE_DIR=$CRATE
fi fi
crate_path="$WORKINGDIR/$CRATE/Cargo.toml" crate_path="$WORKINGDIR/$CRATE_DIR/Cargo.toml"
crate_path=${crate_path//\/\///} crate_path=${crate_path//\/\///}
sed -i '' "s/^version.*/version = \"$1\"/g" "$crate_path" sed -i '' "s/^version.*/version = \"$1\"/g" "$crate_path"
echo "Path is $crate_path" echo "Path is $crate_path"
if [ "$CRATE" = "librespot" ] if [ "$CRATE" = "librespot" ]
then then
echo "Updating lockfile"
if [ "$DRY_RUN" = 'true' ] ; then if [ "$DRY_RUN" = 'true' ] ; then
cargo update --dry-run cargo update --dry-run
git add . && git commit --dry-run -a -m "Update Cargo.lock" git add . && git commit --dry-run -a -m "Update Cargo.lock"

View file

@ -668,7 +668,15 @@ fn get_setup() -> Setup {
trace!("Command line argument(s):"); trace!("Command line argument(s):");
for (index, key) in args.iter().enumerate() { for (index, key) in args.iter().enumerate() {
let opt = key.trim_start_matches('-'); let opt = {
let key = key.trim_start_matches('-');
if let Some((s, _)) = key.split_once('=') {
s
} else {
key
}
};
if index > 0 if index > 0
&& key.starts_with('-') && key.starts_with('-')
@ -678,13 +686,13 @@ fn get_setup() -> Setup {
{ {
if matches!(opt, PASSWORD | PASSWORD_SHORT | USERNAME | USERNAME_SHORT) { if matches!(opt, PASSWORD | PASSWORD_SHORT | USERNAME | USERNAME_SHORT) {
// Don't log creds. // Don't log creds.
trace!("\t\t{} \"XXXXXXXX\"", key); trace!("\t\t{} \"XXXXXXXX\"", opt);
} else { } else {
let value = matches.opt_str(opt).unwrap_or_else(|| "".to_string()); let value = matches.opt_str(opt).unwrap_or_else(|| "".to_string());
if value.is_empty() { if value.is_empty() {
trace!("\t\t{}", key); trace!("\t\t{}", opt);
} else { } else {
trace!("\t\t{} \"{}\"", key, value); trace!("\t\t{} \"{}\"", opt, value);
} }
} }
} }
@ -1072,7 +1080,7 @@ fn get_setup() -> Setup {
Some(creds) if username == creds.username => Some(creds), Some(creds) if username == creds.username => Some(creds),
_ => { _ => {
let prompt = &format!("Password for {}: ", username); let prompt = &format!("Password for {}: ", username);
match rpassword::prompt_password_stderr(prompt) { match rpassword::prompt_password(prompt) {
Ok(password) => { Ok(password) => {
if !password.is_empty() { if !password.is_empty() {
Some(Credentials::with_password(username, password)) Some(Credentials::with_password(username, password))
@ -1599,24 +1607,25 @@ async fn main() {
if setup.enable_discovery { if setup.enable_discovery {
let device_id = setup.session_config.device_id.clone(); let device_id = setup.session_config.device_id.clone();
match librespot::discovery::Discovery::builder(device_id)
discovery = match librespot::discovery::Discovery::builder(device_id)
.name(setup.connect_config.name.clone()) .name(setup.connect_config.name.clone())
.device_type(setup.connect_config.device_type) .device_type(setup.connect_config.device_type)
.port(setup.zeroconf_port) .port(setup.zeroconf_port)
.launch() .launch()
{ {
Ok(d) => Some(d), Ok(d) => discovery = Some(d),
Err(e) => { Err(err) => warn!("Could not initialise discovery: {}.", err),
error!("Discovery Error: {}", e); };
exit(1);
}
}
} }
if let Some(credentials) = setup.credentials { if let Some(credentials) = setup.credentials {
last_credentials = Some(credentials); last_credentials = Some(credentials);
connecting = true; connecting = true;
} else if discovery.is_none() {
error!(
"Discovery is unavailable and no credentials provided. Authentication is not possible."
);
exit(1);
} }
loop { loop {
@ -1656,12 +1665,12 @@ async fn main() {
let player_config = setup.player_config.clone(); let player_config = setup.player_config.clone();
let connect_config = setup.connect_config.clone(); let connect_config = setup.connect_config.clone();
let audio_filter = mixer.get_audio_filter(); let soft_volume = mixer.get_soft_volume();
let format = setup.format; let format = setup.format;
let backend = setup.backend; let backend = setup.backend;
let device = setup.device.clone(); let device = setup.device.clone();
let (player, event_channel) = let (player, event_channel) =
Player::new(player_config, session.clone(), audio_filter, move || { Player::new(player_config, session.clone(), soft_volume, move || {
(backend)(device, format) (backend)(device, format)
}); });

View file

@ -17,17 +17,17 @@ pub fn run_program_on_events(event: PlayerEvent, onevent: &str) -> Option<Result
old_track_id, old_track_id,
new_track_id, new_track_id,
} => match old_track_id.to_base62() { } => match old_track_id.to_base62() {
Err(_) => { Err(e) => {
return Some(Err(Error::new( return Some(Err(Error::new(
ErrorKind::InvalidData, ErrorKind::InvalidData,
"PlayerEvent::Changed: Invalid old track id", format!("PlayerEvent::Changed: Invalid old track id: {}", e),
))) )))
} }
Ok(old_id) => match new_track_id.to_base62() { Ok(old_id) => match new_track_id.to_base62() {
Err(_) => { Err(e) => {
return Some(Err(Error::new( return Some(Err(Error::new(
ErrorKind::InvalidData, ErrorKind::InvalidData,
"PlayerEvent::Changed: Invalid new track id", format!("PlayerEvent::Changed: Invalid old track id: {}", e),
))) )))
} }
Ok(new_id) => { Ok(new_id) => {
@ -38,10 +38,10 @@ pub fn run_program_on_events(event: PlayerEvent, onevent: &str) -> Option<Result
}, },
}, },
PlayerEvent::Started { track_id, .. } => match track_id.to_base62() { PlayerEvent::Started { track_id, .. } => match track_id.to_base62() {
Err(_) => { Err(e) => {
return Some(Err(Error::new( return Some(Err(Error::new(
ErrorKind::InvalidData, ErrorKind::InvalidData,
"PlayerEvent::Started: Invalid track id", format!("PlayerEvent::Started: Invalid track id: {}", e),
))) )))
} }
Ok(id) => { Ok(id) => {
@ -50,10 +50,10 @@ pub fn run_program_on_events(event: PlayerEvent, onevent: &str) -> Option<Result
} }
}, },
PlayerEvent::Stopped { track_id, .. } => match track_id.to_base62() { PlayerEvent::Stopped { track_id, .. } => match track_id.to_base62() {
Err(_) => { Err(e) => {
return Some(Err(Error::new( return Some(Err(Error::new(
ErrorKind::InvalidData, ErrorKind::InvalidData,
"PlayerEvent::Stopped: Invalid track id", format!("PlayerEvent::Stopped: Invalid track id: {}", e),
))) )))
} }
Ok(id) => { Ok(id) => {
@ -67,10 +67,10 @@ pub fn run_program_on_events(event: PlayerEvent, onevent: &str) -> Option<Result
position_ms, position_ms,
.. ..
} => match track_id.to_base62() { } => match track_id.to_base62() {
Err(_) => { Err(e) => {
return Some(Err(Error::new( return Some(Err(Error::new(
ErrorKind::InvalidData, ErrorKind::InvalidData,
"PlayerEvent::Playing: Invalid track id", format!("PlayerEvent::Playing: Invalid track id: {}", e),
))) )))
} }
Ok(id) => { Ok(id) => {
@ -86,10 +86,10 @@ pub fn run_program_on_events(event: PlayerEvent, onevent: &str) -> Option<Result
position_ms, position_ms,
.. ..
} => match track_id.to_base62() { } => match track_id.to_base62() {
Err(_) => { Err(e) => {
return Some(Err(Error::new( return Some(Err(Error::new(
ErrorKind::InvalidData, ErrorKind::InvalidData,
"PlayerEvent::Paused: Invalid track id", format!("PlayerEvent::Paused: Invalid track id: {}", e),
))) )))
} }
Ok(id) => { Ok(id) => {
@ -100,10 +100,10 @@ pub fn run_program_on_events(event: PlayerEvent, onevent: &str) -> Option<Result
} }
}, },
PlayerEvent::Preloading { track_id, .. } => match track_id.to_base62() { PlayerEvent::Preloading { track_id, .. } => match track_id.to_base62() {
Err(_) => { Err(e) => {
return Some(Err(Error::new( return Some(Err(Error::new(
ErrorKind::InvalidData, ErrorKind::InvalidData,
"PlayerEvent::Preloading: Invalid track id", format!("PlayerEvent::Preloading: Invalid track id: {}", e),
))) )))
} }
Ok(id) => { Ok(id) => {