mirror of
https://github.com/librespot-org/librespot.git
synced 2024-12-18 17:11:53 +00:00
Merge branch 'dev' into log-volume-ctrl-optimisations
This commit is contained in:
commit
3a2455d686
16 changed files with 604 additions and 317 deletions
4
.github/workflows/test.yml
vendored
4
.github/workflows/test.yml
vendored
|
@ -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:
|
||||
|
|
23
CHANGELOG.md
23
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
|
||||
|
||||
|
|
70
Cargo.lock
generated
70
Cargo.lock
generated
|
@ -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"
|
||||
|
|
|
@ -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"]
|
||||
|
||||
|
|
|
@ -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"]
|
||||
|
|
|
@ -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<Sha1>;
|
||||
use futures_util::Stream;
|
||||
use librespot_core::authentication::Credentials;
|
||||
use librespot_core::config::ConnectConfig;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Discovery(Arc<DiscoveryInner>);
|
||||
struct DiscoveryInner {
|
||||
config: ConnectConfig,
|
||||
device_id: String,
|
||||
keys: DhLocalKeys,
|
||||
tx: mpsc::UnboundedSender<Credentials>,
|
||||
pub struct DiscoveryStream(librespot_discovery::Discovery);
|
||||
|
||||
impl Stream for DiscoveryStream {
|
||||
type Item = Credentials;
|
||||
|
||||
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
|
||||
Pin::new(&mut self.0).poll_next(cx)
|
||||
}
|
||||
|
||||
impl Discovery {
|
||||
fn new(
|
||||
config: ConnectConfig,
|
||||
device_id: String,
|
||||
) -> (Discovery, mpsc::UnboundedReceiver<Credentials>) {
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
|
||||
let discovery = Discovery(Arc::new(DiscoveryInner {
|
||||
config,
|
||||
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(
|
||||
|
@ -205,59 +21,11 @@ pub fn discovery(
|
|||
device_id: String,
|
||||
port: u16,
|
||||
) -> io::Result<DiscoveryStream> {
|
||||
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<Option<Self::Item>> {
|
||||
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))
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -6,20 +6,21 @@ pub struct MercurySender {
|
|||
mercury: MercuryManager,
|
||||
uri: String,
|
||||
pending: VecDeque<MercuryFuture<MercuryResponse>>,
|
||||
buffered_future: Option<MercuryFuture<MercuryResponse>>,
|
||||
}
|
||||
|
||||
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<u8>) {
|
||||
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
40
discovery/Cargo.toml
Normal file
40
discovery/Cargo.toml
Normal 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"]
|
25
discovery/examples/discovery.rs
Normal file
25
discovery/examples/discovery.rs
Normal 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
150
discovery/src/lib.rs
Normal 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
236
discovery/src/server.rs
Normal 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(¶ms),
|
||||
_ => 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)
|
||||
}
|
||||
}
|
|
@ -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 }
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -687,11 +687,14 @@ async fn main() {
|
|||
let mut connecting: Pin<Box<dyn future::FusedFuture<Output = _>>> = 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(),
|
||||
);
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue