diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 825fc936..5f7a74c5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -99,8 +99,8 @@ jobs: - run: cargo hack --workspace --remove-dev-deps - run: cargo build -p librespot-core --no-default-features - run: cargo build -p librespot-core - - run: cargo build -p librespot-connect - - run: cargo build -p librespot-connect --no-default-features --features with-dns-sd + - run: cargo hack build --each-feature -p librespot-discovery + - run: cargo hack build --each-feature -p librespot-playback - run: cargo hack build --each-feature test-windows: diff --git a/CHANGELOG.md b/CHANGELOG.md index d549c71f..57d50727 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,32 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- [discovery] The crate `librespot-discovery` for discovery in LAN was created. Its functionality was previously part of `librespot-connect`. - [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 ### 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) -- [connect, playback] Moved volume controls from `connect` to `playback` crate -* [connect] Synchronize player volume with mixer volume on playback +- [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) +- [connect, playback] Moved volume controls from `librespot-connect` to `librespot-playback` crate +- [connect] Synchronize player volume with mixer volume on playback - [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`: use `--device` name for `--mixer-card` unless specified otherwise -### Fixed -- [connect] Fix step size on volume up/down events -- [playback] Fix `log` and `cubic` volume controls to be mute at zero volume -- [playback] `alsamixer`: make `cubic` consistent between cards that report minimum volume as mute, and cards that report some dB value -- [playback] `alsamixer`: make `--volume-ctrl {linear|log}` work as expected +### Deprecated +- [connect] The `discovery` module ### Removed -- [connect] Removed no-op mixer started/stopped logic +- [connect] Removed no-op mixer started/stopped logic (breaking) - [playback] `alsamixer`: removed `--mixer-linear-volume` option; use `--volume-ctrl linear` instead ### Fixed - -* [librespot-playback] Incorrect `PlayerConfig::default().normalisation_threshold` caused distortion when using dynamic volume normalisation downstream +- [connect] Fix step size on volume up/down events +- [playback] Incorrect `PlayerConfig::default().normalisation_threshold` caused distortion when using dynamic volume normalisation downstream in librespot-connect was deprecated in favor of the `librespot-discovery` crate. +- [playback] Fix `log` and `cubic` volume controls to be mute at zero volume +- [playback] `alsamixer`: make `cubic` consistent between cards that report minimum volume as mute, and cards that report some dB value +- [playback] `alsamixer`: make `--volume-ctrl {linear|log}` work as expected ## [0.2.0] - 2021-05-04 diff --git a/Cargo.lock b/Cargo.lock index 54ba474e..5dc36ce2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -237,6 +237,17 @@ dependencies = [ "libloading 0.7.0", ] +[[package]] +name = "colored" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4ffc801dacf156c5854b9df4f425a626539c3a6ef7893cc0c5084a23f0b6c59" +dependencies = [ + "atty", + "lazy_static", + "winapi", +] + [[package]] name = "combine" version = "4.5.2" @@ -956,9 +967,9 @@ dependencies = [ [[package]] name = "jack" -version = "0.7.0" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "370ed0ab943f1484a94d6328bf612907f5de2748531b873994f513d8806e9d9d" +checksum = "49e720259b4a3e1f33cba335ca524a99a5f2411d405b05f6405fadd69269e2db" dependencies = [ "bitflags", "jack-sys", @@ -1143,6 +1154,7 @@ dependencies = [ "librespot-audio", "librespot-connect", "librespot-core", + "librespot-discovery", "librespot-metadata", "librespot-playback", "librespot-protocol", @@ -1172,16 +1184,10 @@ dependencies = [ name = "librespot-connect" version = "0.2.0" dependencies = [ - "aes-ctr", - "base64", - "dns-sd", "form_urlencoded", - "futures-core", "futures-util", - "hmac", - "hyper", - "libmdns", "librespot-core", + "librespot-discovery", "librespot-playback", "librespot-protocol", "log", @@ -1189,10 +1195,8 @@ dependencies = [ "rand", "serde", "serde_json", - "sha-1", "tokio", "tokio-stream", - "url", ] [[package]] @@ -1235,6 +1239,31 @@ dependencies = [ "vergen", ] +[[package]] +name = "librespot-discovery" +version = "0.2.0" +dependencies = [ + "aes-ctr", + "base64", + "cfg-if 1.0.0", + "dns-sd", + "form_urlencoded", + "futures", + "futures-core", + "hex", + "hmac", + "hyper", + "libmdns", + "librespot-core", + "log", + "rand", + "serde_json", + "sha-1", + "simple_logger", + "thiserror", + "tokio", +] + [[package]] name = "librespot-metadata" version = "0.2.0" @@ -1260,7 +1289,7 @@ dependencies = [ "glib", "gstreamer", "gstreamer-app", - "jack 0.7.0", + "jack 0.7.1", "lewton", "libpulse-binding", "libpulse-simple-binding", @@ -1913,9 +1942,9 @@ dependencies = [ [[package]] name = "rodio" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b65c2eda643191f6d1bb12ea323a9db8d9ba95374e9be3780b5a9fb5cfb8520f" +checksum = "4d98f5e557b61525057e2bc142c8cd7f0e70d75dc32852309bec440e6e046bf9" dependencies = [ "cpal", ] @@ -2081,6 +2110,19 @@ dependencies = [ "libc", ] +[[package]] +name = "simple_logger" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd57f17c093ead1d4a1499dc9acaafdd71240908d64775465543b8d9a9f1d198" +dependencies = [ + "atty", + "chrono", + "colored", + "log", + "winapi", +] + [[package]] name = "slab" version = "0.4.3" diff --git a/Cargo.toml b/Cargo.toml index 081cacae..80903698 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,6 +33,10 @@ path = "core" version = "0.2.0" features = ["apresolve"] +[dependencies.librespot-discovery] +path = "discovery" +version = "0.2.0" + [dependencies.librespot-metadata] path = "metadata" version = "0.2.0" @@ -72,7 +76,7 @@ gstreamer-backend = ["librespot-playback/gstreamer-backend"] with-tremor = ["librespot-playback/with-tremor"] with-vorbis = ["librespot-playback/with-vorbis"] -with-dns-sd = ["librespot-connect/with-dns-sd"] +with-dns-sd = ["librespot-discovery/with-dns-sd"] default = ["rodio-backend"] diff --git a/connect/Cargo.toml b/connect/Cargo.toml index 8e9589fc..89d185ab 100644 --- a/connect/Cargo.toml +++ b/connect/Cargo.toml @@ -8,25 +8,15 @@ repository = "https://github.com/librespot-org/librespot" edition = "2018" [dependencies] -aes-ctr = "0.6" -base64 = "0.13" form_urlencoded = "1.0" -futures-core = "0.3" futures-util = { version = "0.3.5", default_features = false } -hmac = "0.11" -hyper = { version = "0.14", features = ["server", "http1", "tcp"] } -libmdns = "0.6" log = "0.4" protobuf = "2.14.0" rand = "0.8" serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0.25" -sha-1 = "0.9" -tokio = { version = "1.0", features = ["macros", "rt", "sync"] } +serde_json = "1.0" +tokio = { version = "1.0", features = ["macros", "sync"] } tokio-stream = "0.1.1" -url = "2.1" - -dns-sd = { version = "0.1.3", optional = true } [dependencies.librespot-core] path = "../core" @@ -40,6 +30,9 @@ version = "0.2.0" path = "../protocol" version = "0.2.0" -[features] -with-dns-sd = ["dns-sd"] +[dependencies.librespot-discovery] +path = "../discovery" +version = "0.2.0" +[features] +with-dns-sd = ["librespot-discovery/with-dns-sd"] diff --git a/connect/src/discovery.rs b/connect/src/discovery.rs index 7d559f0a..8ce3f4f0 100644 --- a/connect/src/discovery.rs +++ b/connect/src/discovery.rs @@ -1,203 +1,19 @@ -use aes_ctr::cipher::generic_array::GenericArray; -use aes_ctr::cipher::{NewStreamCipher, SyncStreamCipher}; -use aes_ctr::Aes128Ctr; -use futures_core::Stream; -use hmac::{Hmac, Mac, NewMac}; -use hyper::service::{make_service_fn, service_fn}; -use hyper::{Body, Method, Request, Response, StatusCode}; -use serde_json::json; -use sha1::{Digest, Sha1}; -use tokio::sync::{mpsc, oneshot}; - -#[cfg(feature = "with-dns-sd")] -use dns_sd::DNSService; - -use librespot_core::authentication::Credentials; -use librespot_core::config::ConnectConfig; -use librespot_core::diffie_hellman::DhLocalKeys; - -use std::borrow::Cow; -use std::collections::BTreeMap; -use std::convert::Infallible; use std::io; -use std::net::{Ipv4Addr, SocketAddr}; use std::pin::Pin; -use std::sync::Arc; use std::task::{Context, Poll}; -type HmacSha1 = Hmac; +use futures_util::Stream; +use librespot_core::authentication::Credentials; +use librespot_core::config::ConnectConfig; -#[derive(Clone)] -struct Discovery(Arc); -struct DiscoveryInner { - config: ConnectConfig, - device_id: String, - keys: DhLocalKeys, - tx: mpsc::UnboundedSender, -} +pub struct DiscoveryStream(librespot_discovery::Discovery); -impl Discovery { - fn new( - config: ConnectConfig, - device_id: String, - ) -> (Discovery, mpsc::UnboundedReceiver) { - let (tx, rx) = mpsc::unbounded_channel(); +impl Stream for DiscoveryStream { + type Item = Credentials; - let discovery = Discovery(Arc::new(DiscoveryInner { - config, - device_id, - keys: DhLocalKeys::random(&mut rand::thread_rng()), - tx, - })); - - (discovery, rx) + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.0).poll_next(cx) } - - fn handle_get_info(&self, _: BTreeMap, Cow<'_, str>>) -> Response { - let public_key = base64::encode(&self.0.keys.public_key()); - - let result = json!({ - "status": 101, - "statusString": "ERROR-OK", - "spotifyError": 0, - "version": "2.7.1", - "deviceID": (self.0.device_id), - "remoteName": (self.0.config.name), - "activeUser": "", - "publicKey": (public_key), - "deviceType": (self.0.config.device_type.to_string().to_uppercase()), - "libraryVersion": "0.1.0", - "accountReq": "PREMIUM", - "brandDisplayName": "librespot", - "modelDisplayName": "librespot", - "resolverVersion": "0", - "groupStatus": "NONE", - "voiceSupport": "NO", - }); - - let body = result.to_string(); - Response::new(Body::from(body)) - } - - fn handle_add_user( - &self, - params: BTreeMap, Cow<'_, str>>, - ) -> Response { - let username = params.get("userName").unwrap().as_ref(); - let encrypted_blob = params.get("blob").unwrap(); - let client_key = params.get("clientKey").unwrap(); - - let encrypted_blob = base64::decode(encrypted_blob.as_bytes()).unwrap(); - - let shared_key = self - .0 - .keys - .shared_secret(&base64::decode(client_key.as_bytes()).unwrap()); - - let iv = &encrypted_blob[0..16]; - let encrypted = &encrypted_blob[16..encrypted_blob.len() - 20]; - let cksum = &encrypted_blob[encrypted_blob.len() - 20..encrypted_blob.len()]; - - let base_key = Sha1::digest(&shared_key); - let base_key = &base_key[..16]; - - let checksum_key = { - let mut h = HmacSha1::new_from_slice(base_key).expect("HMAC can take key of any size"); - h.update(b"checksum"); - h.finalize().into_bytes() - }; - - let encryption_key = { - let mut h = HmacSha1::new_from_slice(&base_key).expect("HMAC can take key of any size"); - h.update(b"encryption"); - h.finalize().into_bytes() - }; - - let mut h = HmacSha1::new_from_slice(&checksum_key).expect("HMAC can take key of any size"); - h.update(encrypted); - if h.verify(cksum).is_err() { - warn!("Login error for user {:?}: MAC mismatch", username); - let result = json!({ - "status": 102, - "spotifyError": 1, - "statusString": "ERROR-MAC" - }); - - let body = result.to_string(); - return Response::new(Body::from(body)); - } - - let decrypted = { - let mut data = encrypted.to_vec(); - let mut cipher = Aes128Ctr::new( - &GenericArray::from_slice(&encryption_key[0..16]), - &GenericArray::from_slice(iv), - ); - cipher.apply_keystream(&mut data); - String::from_utf8(data).unwrap() - }; - - let credentials = - Credentials::with_blob(username.to_string(), &decrypted, &self.0.device_id); - - self.0.tx.send(credentials).unwrap(); - - let result = json!({ - "status": 101, - "spotifyError": 0, - "statusString": "ERROR-OK" - }); - - let body = result.to_string(); - Response::new(Body::from(body)) - } - - fn not_found(&self) -> Response { - let mut res = Response::default(); - *res.status_mut() = StatusCode::NOT_FOUND; - res - } - - async fn call(self, request: Request) -> hyper::Result> { - let mut params = BTreeMap::new(); - - let (parts, body) = request.into_parts(); - - if let Some(query) = parts.uri.query() { - let query_params = url::form_urlencoded::parse(query.as_bytes()); - params.extend(query_params); - } - - if parts.method != Method::GET { - debug!("{:?} {:?} {:?}", parts.method, parts.uri.path(), params); - } - - let body = hyper::body::to_bytes(body).await?; - - params.extend(url::form_urlencoded::parse(&body)); - - Ok( - match (parts.method, params.get("action").map(AsRef::as_ref)) { - (Method::GET, Some("getInfo")) => self.handle_get_info(params), - (Method::POST, Some("addUser")) => self.handle_add_user(params), - _ => self.not_found(), - }, - ) - } -} - -#[cfg(feature = "with-dns-sd")] -pub struct DiscoveryStream { - credentials: mpsc::UnboundedReceiver, - _svc: DNSService, - _close_tx: oneshot::Sender, -} - -#[cfg(not(feature = "with-dns-sd"))] -pub struct DiscoveryStream { - credentials: mpsc::UnboundedReceiver, - _svc: libmdns::Service, - _close_tx: oneshot::Sender, } pub fn discovery( @@ -205,59 +21,11 @@ pub fn discovery( device_id: String, port: u16, ) -> io::Result { - let (discovery, creds_rx) = Discovery::new(config.clone(), device_id); - let (close_tx, close_rx) = oneshot::channel(); - - let address = SocketAddr::new(Ipv4Addr::UNSPECIFIED.into(), port); - - let make_service = make_service_fn(move |_| { - let discovery = discovery.clone(); - async move { Ok::<_, hyper::Error>(service_fn(move |request| discovery.clone().call(request))) } - }); - - let server = hyper::Server::bind(&address).serve(make_service); - - let s_port = server.local_addr().port(); - debug!("Zeroconf server listening on 0.0.0.0:{}", s_port); - - tokio::spawn(server.with_graceful_shutdown(async { - close_rx.await.unwrap_err(); - debug!("Shutting down discovery server"); - })); - - #[cfg(feature = "with-dns-sd")] - let svc = DNSService::register( - Some(&*config.name), - "_spotify-connect._tcp", - None, - None, - s_port, - &["VERSION=1.0", "CPath=/"], - ) - .unwrap(); - - #[cfg(not(feature = "with-dns-sd"))] - let responder = libmdns::Responder::spawn(&tokio::runtime::Handle::current())?; - - #[cfg(not(feature = "with-dns-sd"))] - let svc = responder.register( - "_spotify-connect._tcp".to_owned(), - config.name, - s_port, - &["VERSION=1.0", "CPath=/"], - ); - - Ok(DiscoveryStream { - credentials: creds_rx, - _svc: svc, - _close_tx: close_tx, - }) -} - -impl Stream for DiscoveryStream { - type Item = Credentials; - - fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - self.credentials.poll_recv(cx) - } + librespot_discovery::Discovery::builder(device_id) + .device_type(config.device_type) + .port(port) + .name(config.name) + .launch() + .map(DiscoveryStream) + .map_err(|e| io::Error::new(io::ErrorKind::Other, e)) } diff --git a/connect/src/lib.rs b/connect/src/lib.rs index 600dd033..267bf1b8 100644 --- a/connect/src/lib.rs +++ b/connect/src/lib.rs @@ -6,5 +6,9 @@ use librespot_playback as playback; use librespot_protocol as protocol; pub mod context; +#[deprecated( + since = "0.2.1", + note = "Please use the crate `librespot_discovery` instead." +)] pub mod discovery; pub mod spirc; diff --git a/core/src/config.rs b/core/src/config.rs index db990419..0e3eaf4a 100644 --- a/core/src/config.rs +++ b/core/src/config.rs @@ -71,30 +71,43 @@ impl FromStr for DeviceType { } } +impl From<&DeviceType> for &str { + fn from(d: &DeviceType) -> &'static str { + use self::DeviceType::*; + match d { + Unknown => "Unknown", + Computer => "Computer", + Tablet => "Tablet", + Smartphone => "Smartphone", + Speaker => "Speaker", + Tv => "TV", + Avr => "AVR", + Stb => "STB", + AudioDongle => "AudioDongle", + GameConsole => "GameConsole", + CastAudio => "CastAudio", + CastVideo => "CastVideo", + Automobile => "Automobile", + Smartwatch => "Smartwatch", + Chromebook => "Chromebook", + UnknownSpotify => "UnknownSpotify", + CarThing => "CarThing", + Observer => "Observer", + HomeThing => "HomeThing", + } + } +} + +impl From for &str { + fn from(d: DeviceType) -> &'static str { + (&d).into() + } +} + impl fmt::Display for DeviceType { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - use self::DeviceType::*; - match *self { - Unknown => f.write_str("Unknown"), - Computer => f.write_str("Computer"), - Tablet => f.write_str("Tablet"), - Smartphone => f.write_str("Smartphone"), - Speaker => f.write_str("Speaker"), - Tv => f.write_str("TV"), - Avr => f.write_str("AVR"), - Stb => f.write_str("STB"), - AudioDongle => f.write_str("AudioDongle"), - GameConsole => f.write_str("GameConsole"), - CastAudio => f.write_str("CastAudio"), - CastVideo => f.write_str("CastVideo"), - Automobile => f.write_str("Automobile"), - Smartwatch => f.write_str("Smartwatch"), - Chromebook => f.write_str("Chromebook"), - UnknownSpotify => f.write_str("UnknownSpotify"), - CarThing => f.write_str("CarThing"), - Observer => f.write_str("Observer"), - HomeThing => f.write_str("HomeThing"), - } + let str: &str = self.into(); + f.write_str(str) } } diff --git a/core/src/mercury/sender.rs b/core/src/mercury/sender.rs index 383d449d..268554d9 100644 --- a/core/src/mercury/sender.rs +++ b/core/src/mercury/sender.rs @@ -6,20 +6,21 @@ pub struct MercurySender { mercury: MercuryManager, uri: String, pending: VecDeque>, + buffered_future: Option>, } impl MercurySender { - // TODO: pub(super) when stable pub(crate) fn new(mercury: MercuryManager, uri: String) -> MercurySender { MercurySender { mercury, uri, pending: VecDeque::new(), + buffered_future: None, } } pub fn is_flushed(&self) -> bool { - self.pending.is_empty() + self.buffered_future.is_none() && self.pending.is_empty() } pub fn send(&mut self, item: Vec) { @@ -28,8 +29,13 @@ impl MercurySender { } pub async fn flush(&mut self) -> Result<(), MercuryError> { - for fut in self.pending.drain(..) { + if self.buffered_future.is_none() { + self.buffered_future = self.pending.pop_front(); + } + + while let Some(fut) = self.buffered_future.as_mut() { fut.await?; + self.buffered_future = self.pending.pop_front(); } Ok(()) } @@ -41,6 +47,7 @@ impl Clone for MercurySender { mercury: self.mercury.clone(), uri: self.uri.clone(), pending: VecDeque::new(), + buffered_future: None, } } } diff --git a/discovery/Cargo.toml b/discovery/Cargo.toml new file mode 100644 index 00000000..9ea9df48 --- /dev/null +++ b/discovery/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "librespot-discovery" +version = "0.2.0" +authors = ["Paul Lietar "] +description = "The discovery logic for librespot" +license = "MIT" +repository = "https://github.com/librespot-org/librespot" +edition = "2018" + +[dependencies] +aes-ctr = "0.6" +base64 = "0.13" +cfg-if = "1.0" +form_urlencoded = "1.0" +futures-core = "0.3" +hmac = "0.11" +hyper = { version = "0.14", features = ["server", "http1", "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 } + +[dependencies.librespot-core] +path = "../core" +default_features = false +version = "0.2.0" + +[dev-dependencies] +futures = "0.3" +hex = "0.4" +simple_logger = "1.11" +tokio = { version = "1.0", features = ["macros", "rt"] } + +[features] +with-dns-sd = ["dns-sd"] diff --git a/discovery/examples/discovery.rs b/discovery/examples/discovery.rs new file mode 100644 index 00000000..cd913fd2 --- /dev/null +++ b/discovery/examples/discovery.rs @@ -0,0 +1,25 @@ +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())); + + let mut server = librespot_discovery::Discovery::builder(device_id) + .name(name) + .device_type(DeviceType::Computer) + .launch() + .unwrap(); + + while let Some(x) = server.next().await { + println!("Received {:?}", x); + } +} diff --git a/discovery/src/lib.rs b/discovery/src/lib.rs new file mode 100644 index 00000000..b1249a0d --- /dev/null +++ b/discovery/src/lib.rs @@ -0,0 +1,150 @@ +//! Advertises this device to Spotify clients in the local network. +//! +//! This device will show up in the list of "available devices". +//! Once it is selected from the list, [`Credentials`] are received. +//! Those can be used to establish a new Session with [`librespot_core`]. +//! +//! 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; +use std::io; +use std::pin::Pin; +use std::task::{Context, Poll}; + +use cfg_if::cfg_if; +use futures_core::Stream; +use librespot_core as core; +use thiserror::Error; + +use self::server::DiscoveryServer; + +/// Credentials to be used in [`librespot`](`librespot_core`). +pub use crate::core::authentication::Credentials; + +/// Determining the icon in the list of available devices. +pub use crate::core::config::DeviceType; + +/// Makes this device visible to Spotify clients in the local network. +/// +/// `Discovery` implements the [`Stream`] trait. Every time this device +/// is selected in the list of available devices, it yields [`Credentials`]. +pub struct Discovery { + server: DiscoveryServer, + + #[cfg(not(feature = "with-dns-sd"))] + _svc: libmdns::Service, + #[cfg(feature = "with-dns-sd")] + _svc: dns_sd::DNSService, +} + +/// A builder for [`Discovery`]. +pub struct Builder { + server_config: server::Config, + port: u16, +} + +/// Errors that can occur while setting up a [`Discovery`] instance. +#[derive(Debug, Error)] +pub enum Error { + /// 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("Setting up the http server failed: {0}")] + HttpServerError(#[from] hyper::Error), +} + +impl Builder { + /// Starts a new builder using the provided device id. + pub fn new(device_id: impl Into) -> Self { + Self { + server_config: server::Config { + name: "Librespot".into(), + device_type: DeviceType::default(), + device_id: device_id.into(), + }, + port: 0, + } + } + + /// Sets the name to be displayed. Default is `"Librespot"`. + pub fn name(mut self, name: impl Into>) -> Self { + self.server_config.name = name.into(); + self + } + + /// Sets the device type which is visible as icon in other Spotify clients. Default is `Speaker`. + pub fn device_type(mut self, device_type: DeviceType) -> Self { + self.server_config.device_type = device_type; + self + } + + /// Sets the port on which it should listen to incoming connections. + /// The default value `0` means any port. + pub fn port(mut self, port: u16) -> Self { + self.port = port; + self + } + + /// Sets up the [`Discovery`] instance. + /// + /// # Errors + /// If setting up the mdns service or creating the server fails, this function returns an error. + pub fn launch(self) -> Result { + let mut port = self.port; + let name = self.server_config.name.clone().into_owned(); + let server = DiscoveryServer::new(self.server_config, &mut port)?; + + let svc; + + cfg_if! { + if #[cfg(feature = "with-dns-sd")] { + svc = dns_sd::DNSService::register( + Some(name.as_ref()), + "_spotify-connect._tcp", + None, + None, + port, + &["VERSION=1.0", "CPath=/"], + ) + .unwrap(); + + } else { + let responder = libmdns::Responder::spawn(&tokio::runtime::Handle::current())?; + svc = responder.register( + "_spotify-connect._tcp".to_owned(), + name, + port, + &["VERSION=1.0", "CPath=/"], + ) + } + }; + + Ok(Discovery { server, _svc: svc }) + } +} + +impl Discovery { + /// Starts a [`Builder`] with the provided device id. + pub fn builder(device_id: impl Into) -> Builder { + Builder::new(device_id) + } + + /// Create a new instance with the specified device id and default paramaters. + pub fn new(device_id: impl Into) -> Result { + Self::builder(device_id).launch() + } +} + +impl Stream for Discovery { + type Item = Credentials; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.server).poll_next(cx) + } +} diff --git a/discovery/src/server.rs b/discovery/src/server.rs new file mode 100644 index 00000000..53b849f7 --- /dev/null +++ b/discovery/src/server.rs @@ -0,0 +1,236 @@ +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 aes_ctr::cipher::generic_array::GenericArray; +use aes_ctr::cipher::{NewStreamCipher, SyncStreamCipher}; +use aes_ctr::Aes128Ctr; +use futures_core::Stream; +use hmac::{Hmac, Mac, NewMac}; +use hyper::service::{make_service_fn, service_fn}; +use hyper::{Body, Method, Request, Response, StatusCode}; +use log::{debug, warn}; +use 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; + +type Params<'a> = BTreeMap, Cow<'a, str>>; + +pub struct Config { + pub name: Cow<'static, str>, + pub device_type: DeviceType, + pub device_id: String, +} + +struct RequestHandler { + config: Config, + keys: DhLocalKeys, + tx: mpsc::UnboundedSender, +} + +impl RequestHandler { + fn new(config: Config) -> (Self, mpsc::UnboundedReceiver) { + let (tx, rx) = mpsc::unbounded_channel(); + + let discovery = Self { + config, + keys: DhLocalKeys::random(&mut rand::thread_rng()), + tx, + }; + + (discovery, rx) + } + + fn handle_get_info(&self) -> Response { + let public_key = base64::encode(&self.keys.public_key()); + let device_type: &str = self.config.device_type.into(); + + let body = json!({ + "status": 101, + "statusString": "ERROR-OK", + "spotifyError": 0, + "version": "2.7.1", + "deviceID": (self.config.device_id), + "remoteName": (self.config.name), + "activeUser": "", + "publicKey": (public_key), + "deviceType": (device_type), + "libraryVersion": crate::core::version::SEMVER, + "accountReq": "PREMIUM", + "brandDisplayName": "librespot", + "modelDisplayName": "librespot", + "resolverVersion": "0", + "groupStatus": "NONE", + "voiceSupport": "NO", + }) + .to_string(); + + Response::new(Body::from(body)) + } + + fn handle_add_user(&self, params: &Params<'_>) -> Response { + let username = params.get("userName").unwrap().as_ref(); + let encrypted_blob = params.get("blob").unwrap(); + let client_key = params.get("clientKey").unwrap(); + + let encrypted_blob = base64::decode(encrypted_blob.as_bytes()).unwrap(); + + let client_key = base64::decode(client_key.as_bytes()).unwrap(); + let shared_key = self.keys.shared_secret(&client_key); + + let iv = &encrypted_blob[0..16]; + let encrypted = &encrypted_blob[16..encrypted_blob.len() - 20]; + let cksum = &encrypted_blob[encrypted_blob.len() - 20..encrypted_blob.len()]; + + let base_key = Sha1::digest(&shared_key); + let base_key = &base_key[..16]; + + let checksum_key = { + let mut h = + Hmac::::new_from_slice(base_key).expect("HMAC can take key of any size"); + h.update(b"checksum"); + h.finalize().into_bytes() + }; + + let encryption_key = { + let mut h = + Hmac::::new_from_slice(base_key).expect("HMAC can take key of any size"); + h.update(b"encryption"); + h.finalize().into_bytes() + }; + + let mut h = + Hmac::::new_from_slice(&checksum_key).expect("HMAC can take key of any size"); + h.update(encrypted); + if h.verify(cksum).is_err() { + warn!("Login error for user {:?}: MAC mismatch", username); + let result = json!({ + "status": 102, + "spotifyError": 1, + "statusString": "ERROR-MAC" + }); + + let body = result.to_string(); + return Response::new(Body::from(body)); + } + + let decrypted = { + let mut data = encrypted.to_vec(); + let mut cipher = Aes128Ctr::new( + GenericArray::from_slice(&encryption_key[0..16]), + GenericArray::from_slice(iv), + ); + cipher.apply_keystream(&mut data); + String::from_utf8(data).unwrap() + }; + + let credentials = + Credentials::with_blob(username.to_string(), &decrypted, &self.config.device_id); + + self.tx.send(credentials).unwrap(); + + let result = json!({ + "status": 101, + "spotifyError": 0, + "statusString": "ERROR-OK" + }); + + let body = result.to_string(); + Response::new(Body::from(body)) + } + + fn not_found(&self) -> Response { + let mut res = Response::default(); + *res.status_mut() = StatusCode::NOT_FOUND; + res + } + + async fn handle(self: Arc, request: Request) -> hyper::Result> { + let mut params = Params::new(); + + let (parts, body) = request.into_parts(); + + if let Some(query) = parts.uri.query() { + let query_params = form_urlencoded::parse(query.as_bytes()); + params.extend(query_params); + } + + if parts.method != Method::GET { + debug!("{:?} {:?} {:?}", parts.method, parts.uri.path(), params); + } + + let body = hyper::body::to_bytes(body).await?; + + params.extend(form_urlencoded::parse(&body)); + + let action = params.get("action").map(Cow::as_ref); + + Ok(match (parts.method, action) { + (Method::GET, Some("getInfo")) => self.handle_get_info(), + (Method::POST, Some("addUser")) => self.handle_add_user(¶ms), + _ => self.not_found(), + }) + } +} + +pub struct DiscoveryServer { + cred_rx: mpsc::UnboundedReceiver, + _close_tx: oneshot::Sender, +} + +impl DiscoveryServer { + pub fn new(config: Config, port: &mut u16) -> hyper::Result { + let (discovery, cred_rx) = RequestHandler::new(config); + let discovery = Arc::new(discovery); + + let (close_tx, close_rx) = oneshot::channel(); + + let address = SocketAddr::new(Ipv4Addr::UNSPECIFIED.into(), *port); + + let make_service = make_service_fn(move |_| { + let discovery = discovery.clone(); + async move { + Ok::<_, hyper::Error>(service_fn(move |request| discovery.clone().handle(request))) + } + }); + + let server = hyper::Server::try_bind(&address)?.serve(make_service); + + *port = server.local_addr().port(); + debug!("Zeroconf server listening on 0.0.0.0:{}", *port); + + tokio::spawn(async { + let result = server + .with_graceful_shutdown(async { + close_rx.await.unwrap_err(); + debug!("Shutting down discovery server"); + }) + .await; + + if let Err(e) = result { + warn!("Discovery server failed: {}", e); + } + }); + + Ok(Self { + cred_rx, + _close_tx: close_tx, + }) + } +} + +impl Stream for DiscoveryServer { + type Item = Credentials; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + self.cred_rx.poll_recv(cx) + } +} diff --git a/playback/Cargo.toml b/playback/Cargo.toml index 0f90d80f..37806062 100644 --- a/playback/Cargo.toml +++ b/playback/Cargo.toml @@ -38,7 +38,7 @@ gstreamer-app = { version = "0.16", optional = true } glib = { version = "0.10", optional = true } # Rodio dependencies -rodio = { version = "0.13", optional = true, default-features = false } +rodio = { version = "0.14", optional = true, default-features = false } cpal = { version = "0.13", optional = true } thiserror = { version = "1", optional = true } diff --git a/src/lib.rs b/src/lib.rs index 7722e93e..75211282 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,6 +3,7 @@ pub use librespot_audio as audio; pub use librespot_connect as connect; pub use librespot_core as core; +pub use librespot_discovery as discovery; pub use librespot_metadata as metadata; pub use librespot_playback as playback; pub use librespot_protocol as protocol; diff --git a/src/main.rs b/src/main.rs index 5b8d1d1a..739336bb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -687,11 +687,14 @@ async fn main() { let mut connecting: Pin>> = Box::pin(future::pending()); if setup.enable_discovery { - let config = setup.connect_config.clone(); let device_id = setup.session_config.device_id.clone(); discovery = Some( - librespot_connect::discovery::discovery(config, device_id, setup.zeroconf_port) + librespot::discovery::Discovery::builder(device_id) + .name(setup.connect_config.name.clone()) + .device_type(setup.connect_config.device_type) + .port(setup.zeroconf_port) + .launch() .unwrap(), ); }