Merge branch 'dev' into log-volume-ctrl-optimisations

This commit is contained in:
Roderick van Domburg 2021-05-26 20:50:42 +02:00 committed by GitHub
commit 3a2455d686
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 604 additions and 317 deletions

View file

@ -99,8 +99,8 @@ jobs:
- run: cargo hack --workspace --remove-dev-deps - run: cargo hack --workspace --remove-dev-deps
- run: cargo build -p librespot-core --no-default-features - run: cargo build -p librespot-core --no-default-features
- run: cargo build -p librespot-core - run: cargo build -p librespot-core
- run: cargo build -p librespot-connect - run: cargo hack build --each-feature -p librespot-discovery
- run: cargo build -p librespot-connect --no-default-features --features with-dns-sd - run: cargo hack build --each-feature -p librespot-playback
- run: cargo hack build --each-feature - run: cargo hack build --each-feature
test-windows: test-windows:

View file

@ -7,32 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Added ### 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] Add `--volume-range` option to set dB range and control `log` and `cubic` volume control curves
- [playback] `alsamixer`: support for querying dB range from Alsa softvol - [playback] `alsamixer`: support for querying dB range from Alsa softvol
### Changed ### Changed
* [audio, playback] Moved `VorbisDecoder`, `VorbisError`, `AudioPacket`, `PassthroughDecoder`, `PassthroughError`, `AudioError`, `AudioDecoder` and the `convert` module from `librespot-audio` to `librespot-playback`. The underlying crates `vorbis`, `librespot-tremor`, `lewton` and `ogg` should be used directly. (breaking) - [audio, playback] Moved `VorbisDecoder`, `VorbisError`, `AudioPacket`, `PassthroughDecoder`, `PassthroughError`, `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, playback] Moved volume controls from `librespot-connect` to `librespot-playback` crate
* [connect] Synchronize player volume with mixer volume on playback - [connect] Synchronize player volume with mixer volume on playback
- [playback] Make cubic volume control available to all mixers with `--volume-ctrl cubic` - [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] Normalize volumes to `[0.0..1.0]` instead of `[0..65535]` for greater precision and performance (breaking)
- [playback] `alsamixer`: complete rewrite (breaking) - [playback] `alsamixer`: complete rewrite (breaking)
- [playback] `alsamixer`: query card dB range for the `log` volume control unless specified otherwise - [playback] `alsamixer`: query card dB range for the `log` volume control unless specified otherwise
- [playback] `alsamixer`: use `--device` name for `--mixer-card` unless specified otherwise - [playback] `alsamixer`: use `--device` name for `--mixer-card` unless specified otherwise
### Fixed ### Deprecated
- [connect] Fix step size on volume up/down events - [connect] The `discovery` module
- [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
### Removed ### 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 - [playback] `alsamixer`: removed `--mixer-linear-volume` option; use `--volume-ctrl linear` instead
### Fixed ### Fixed
- [connect] Fix step size on volume up/down events
* [librespot-playback] Incorrect `PlayerConfig::default().normalisation_threshold` caused distortion when using dynamic volume normalisation downstream - [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 ## [0.2.0] - 2021-05-04

70
Cargo.lock generated
View file

@ -237,6 +237,17 @@ dependencies = [
"libloading 0.7.0", "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]] [[package]]
name = "combine" name = "combine"
version = "4.5.2" version = "4.5.2"
@ -956,9 +967,9 @@ dependencies = [
[[package]] [[package]]
name = "jack" name = "jack"
version = "0.7.0" version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "370ed0ab943f1484a94d6328bf612907f5de2748531b873994f513d8806e9d9d" checksum = "49e720259b4a3e1f33cba335ca524a99a5f2411d405b05f6405fadd69269e2db"
dependencies = [ dependencies = [
"bitflags", "bitflags",
"jack-sys", "jack-sys",
@ -1143,6 +1154,7 @@ dependencies = [
"librespot-audio", "librespot-audio",
"librespot-connect", "librespot-connect",
"librespot-core", "librespot-core",
"librespot-discovery",
"librespot-metadata", "librespot-metadata",
"librespot-playback", "librespot-playback",
"librespot-protocol", "librespot-protocol",
@ -1172,16 +1184,10 @@ dependencies = [
name = "librespot-connect" name = "librespot-connect"
version = "0.2.0" version = "0.2.0"
dependencies = [ dependencies = [
"aes-ctr",
"base64",
"dns-sd",
"form_urlencoded", "form_urlencoded",
"futures-core",
"futures-util", "futures-util",
"hmac",
"hyper",
"libmdns",
"librespot-core", "librespot-core",
"librespot-discovery",
"librespot-playback", "librespot-playback",
"librespot-protocol", "librespot-protocol",
"log", "log",
@ -1189,10 +1195,8 @@ dependencies = [
"rand", "rand",
"serde", "serde",
"serde_json", "serde_json",
"sha-1",
"tokio", "tokio",
"tokio-stream", "tokio-stream",
"url",
] ]
[[package]] [[package]]
@ -1235,6 +1239,31 @@ dependencies = [
"vergen", "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]] [[package]]
name = "librespot-metadata" name = "librespot-metadata"
version = "0.2.0" version = "0.2.0"
@ -1260,7 +1289,7 @@ dependencies = [
"glib", "glib",
"gstreamer", "gstreamer",
"gstreamer-app", "gstreamer-app",
"jack 0.7.0", "jack 0.7.1",
"lewton", "lewton",
"libpulse-binding", "libpulse-binding",
"libpulse-simple-binding", "libpulse-simple-binding",
@ -1913,9 +1942,9 @@ dependencies = [
[[package]] [[package]]
name = "rodio" name = "rodio"
version = "0.13.1" version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b65c2eda643191f6d1bb12ea323a9db8d9ba95374e9be3780b5a9fb5cfb8520f" checksum = "4d98f5e557b61525057e2bc142c8cd7f0e70d75dc32852309bec440e6e046bf9"
dependencies = [ dependencies = [
"cpal", "cpal",
] ]
@ -2081,6 +2110,19 @@ dependencies = [
"libc", "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]] [[package]]
name = "slab" name = "slab"
version = "0.4.3" version = "0.4.3"

View file

@ -33,6 +33,10 @@ path = "core"
version = "0.2.0" version = "0.2.0"
features = ["apresolve"] features = ["apresolve"]
[dependencies.librespot-discovery]
path = "discovery"
version = "0.2.0"
[dependencies.librespot-metadata] [dependencies.librespot-metadata]
path = "metadata" path = "metadata"
version = "0.2.0" version = "0.2.0"
@ -72,7 +76,7 @@ gstreamer-backend = ["librespot-playback/gstreamer-backend"]
with-tremor = ["librespot-playback/with-tremor"] with-tremor = ["librespot-playback/with-tremor"]
with-vorbis = ["librespot-playback/with-vorbis"] 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"] default = ["rodio-backend"]

View file

@ -8,25 +8,15 @@ repository = "https://github.com/librespot-org/librespot"
edition = "2018" edition = "2018"
[dependencies] [dependencies]
aes-ctr = "0.6"
base64 = "0.13"
form_urlencoded = "1.0" form_urlencoded = "1.0"
futures-core = "0.3"
futures-util = { version = "0.3.5", default_features = false } 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" log = "0.4"
protobuf = "2.14.0" protobuf = "2.14.0"
rand = "0.8" rand = "0.8"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0.25" serde_json = "1.0"
sha-1 = "0.9" tokio = { version = "1.0", features = ["macros", "sync"] }
tokio = { version = "1.0", features = ["macros", "rt", "sync"] }
tokio-stream = "0.1.1" tokio-stream = "0.1.1"
url = "2.1"
dns-sd = { version = "0.1.3", optional = true }
[dependencies.librespot-core] [dependencies.librespot-core]
path = "../core" path = "../core"
@ -40,6 +30,9 @@ version = "0.2.0"
path = "../protocol" path = "../protocol"
version = "0.2.0" version = "0.2.0"
[features] [dependencies.librespot-discovery]
with-dns-sd = ["dns-sd"] path = "../discovery"
version = "0.2.0"
[features]
with-dns-sd = ["librespot-discovery/with-dns-sd"]

View file

@ -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::io;
use std::net::{Ipv4Addr, SocketAddr};
use std::pin::Pin; use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context, Poll}; use std::task::{Context, Poll};
type HmacSha1 = Hmac<Sha1>; use futures_util::Stream;
use librespot_core::authentication::Credentials;
use librespot_core::config::ConnectConfig;
#[derive(Clone)] pub struct DiscoveryStream(librespot_discovery::Discovery);
struct Discovery(Arc<DiscoveryInner>);
struct DiscoveryInner {
config: ConnectConfig,
device_id: String,
keys: DhLocalKeys,
tx: mpsc::UnboundedSender<Credentials>,
}
impl Discovery { impl Stream for DiscoveryStream {
fn new( type Item = Credentials;
config: ConnectConfig,
device_id: String,
) -> (Discovery, mpsc::UnboundedReceiver<Credentials>) {
let (tx, rx) = mpsc::unbounded_channel();
let discovery = Discovery(Arc::new(DiscoveryInner { fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
config, Pin::new(&mut self.0).poll_next(cx)
device_id,
keys: DhLocalKeys::random(&mut rand::thread_rng()),
tx,
}));
(discovery, rx)
} }
fn handle_get_info(&self, _: BTreeMap<Cow<'_, str>, Cow<'_, str>>) -> Response<hyper::Body> {
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>, Cow<'_, str>>,
) -> Response<hyper::Body> {
let username = params.get("userName").unwrap().as_ref();
let encrypted_blob = params.get("blob").unwrap();
let client_key = params.get("clientKey").unwrap();
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<hyper::Body> {
let mut res = Response::default();
*res.status_mut() = StatusCode::NOT_FOUND;
res
}
async fn call(self, request: Request<Body>) -> hyper::Result<Response<Body>> {
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<Credentials>,
_svc: DNSService,
_close_tx: oneshot::Sender<Infallible>,
}
#[cfg(not(feature = "with-dns-sd"))]
pub struct DiscoveryStream {
credentials: mpsc::UnboundedReceiver<Credentials>,
_svc: libmdns::Service,
_close_tx: oneshot::Sender<Infallible>,
} }
pub fn discovery( pub fn discovery(
@ -205,59 +21,11 @@ pub fn discovery(
device_id: String, device_id: String,
port: u16, port: u16,
) -> io::Result<DiscoveryStream> { ) -> io::Result<DiscoveryStream> {
let (discovery, creds_rx) = Discovery::new(config.clone(), device_id); librespot_discovery::Discovery::builder(device_id)
let (close_tx, close_rx) = oneshot::channel(); .device_type(config.device_type)
.port(port)
let address = SocketAddr::new(Ipv4Addr::UNSPECIFIED.into(), port); .name(config.name)
.launch()
let make_service = make_service_fn(move |_| { .map(DiscoveryStream)
let discovery = discovery.clone(); .map_err(|e| io::Error::new(io::ErrorKind::Other, e))
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<Option<Self::Item>> {
self.credentials.poll_recv(cx)
}
} }

View file

@ -6,5 +6,9 @@ use librespot_playback as playback;
use librespot_protocol as protocol; use librespot_protocol as protocol;
pub mod context; pub mod context;
#[deprecated(
since = "0.2.1",
note = "Please use the crate `librespot_discovery` instead."
)]
pub mod discovery; pub mod discovery;
pub mod spirc; pub mod spirc;

View file

@ -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<DeviceType> for &str {
fn from(d: DeviceType) -> &'static str {
(&d).into()
}
}
impl fmt::Display for DeviceType { impl fmt::Display for DeviceType {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
use self::DeviceType::*; let str: &str = self.into();
match *self { f.write_str(str)
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"),
}
} }
} }

View file

@ -6,20 +6,21 @@ pub struct MercurySender {
mercury: MercuryManager, mercury: MercuryManager,
uri: String, uri: String,
pending: VecDeque<MercuryFuture<MercuryResponse>>, pending: VecDeque<MercuryFuture<MercuryResponse>>,
buffered_future: Option<MercuryFuture<MercuryResponse>>,
} }
impl MercurySender { impl MercurySender {
// TODO: pub(super) when stable
pub(crate) fn new(mercury: MercuryManager, uri: String) -> MercurySender { pub(crate) fn new(mercury: MercuryManager, uri: String) -> MercurySender {
MercurySender { MercurySender {
mercury, mercury,
uri, uri,
pending: VecDeque::new(), pending: VecDeque::new(),
buffered_future: None,
} }
} }
pub fn is_flushed(&self) -> bool { 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<u8>) { pub fn send(&mut self, item: Vec<u8>) {
@ -28,8 +29,13 @@ impl MercurySender {
} }
pub async fn flush(&mut self) -> Result<(), MercuryError> { 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?; fut.await?;
self.buffered_future = self.pending.pop_front();
} }
Ok(()) Ok(())
} }
@ -41,6 +47,7 @@ impl Clone for MercurySender {
mercury: self.mercury.clone(), mercury: self.mercury.clone(),
uri: self.uri.clone(), uri: self.uri.clone(),
pending: VecDeque::new(), pending: VecDeque::new(),
buffered_future: None,
} }
} }
} }

40
discovery/Cargo.toml Normal file
View file

@ -0,0 +1,40 @@
[package]
name = "librespot-discovery"
version = "0.2.0"
authors = ["Paul Lietar <paul@lietar.net>"]
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"]

View file

@ -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);
}
}

150
discovery/src/lib.rs Normal file
View file

@ -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<String>) -> 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<Cow<'static, str>>) -> 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<Discovery, Error> {
let mut port = self.port;
let name = self.server_config.name.clone().into_owned();
let server = DiscoveryServer::new(self.server_config, &mut port)?;
let 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<String>) -> Builder {
Builder::new(device_id)
}
/// Create a new instance with the specified device id and default paramaters.
pub fn new(device_id: impl Into<String>) -> Result<Self, Error> {
Self::builder(device_id).launch()
}
}
impl Stream for Discovery {
type Item = Credentials;
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
Pin::new(&mut self.server).poll_next(cx)
}
}

236
discovery/src/server.rs Normal file
View file

@ -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>, 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<Credentials>,
}
impl RequestHandler {
fn new(config: Config) -> (Self, mpsc::UnboundedReceiver<Credentials>) {
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<hyper::Body> {
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<hyper::Body> {
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::<Sha1>::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::<Sha1>::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::<Sha1>::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<hyper::Body> {
let mut res = Response::default();
*res.status_mut() = StatusCode::NOT_FOUND;
res
}
async fn handle(self: Arc<Self>, request: Request<Body>) -> hyper::Result<Response<Body>> {
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(&params),
_ => self.not_found(),
})
}
}
pub struct DiscoveryServer {
cred_rx: mpsc::UnboundedReceiver<Credentials>,
_close_tx: oneshot::Sender<Infallible>,
}
impl DiscoveryServer {
pub fn new(config: Config, port: &mut u16) -> hyper::Result<Self> {
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<Option<Credentials>> {
self.cred_rx.poll_recv(cx)
}
}

View file

@ -38,7 +38,7 @@ gstreamer-app = { version = "0.16", optional = true }
glib = { version = "0.10", optional = true } glib = { version = "0.10", optional = true }
# Rodio dependencies # 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 } cpal = { version = "0.13", optional = true }
thiserror = { version = "1", optional = true } thiserror = { version = "1", optional = true }

View file

@ -3,6 +3,7 @@
pub use librespot_audio as audio; pub use librespot_audio as audio;
pub use librespot_connect as connect; pub use librespot_connect as connect;
pub use librespot_core as core; pub use librespot_core as core;
pub use librespot_discovery as discovery;
pub use librespot_metadata as metadata; pub use librespot_metadata as metadata;
pub use librespot_playback as playback; pub use librespot_playback as playback;
pub use librespot_protocol as protocol; pub use librespot_protocol as protocol;

View file

@ -687,11 +687,14 @@ async fn main() {
let mut connecting: Pin<Box<dyn future::FusedFuture<Output = _>>> = Box::pin(future::pending()); let mut connecting: Pin<Box<dyn future::FusedFuture<Output = _>>> = Box::pin(future::pending());
if setup.enable_discovery { if setup.enable_discovery {
let config = setup.connect_config.clone();
let device_id = setup.session_config.device_id.clone(); let device_id = setup.session_config.device_id.clone();
discovery = Some( 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(), .unwrap(),
); );
} }