mirror of
https://github.com/Felitendo/spotifast.git
synced 2026-11-01 17:33:26 +01:00
A librespot, spotifyd, or hardware receiver only enters Spotify's device list once it has an account, so a speaker plainly visible in the official client is missing from the Web API. Fastpotify now browses mDNS for _spotify-connect._tcp when the device picker opens, lists what it finds, and on request hands over the reusable credential librespot already stores: wrapped in a key derived from the receiver's device id, then encrypted to a key both sides derive from a Diffie-Hellman exchange with the key that receiver published. The receiver logs in, appears in the ordinary device list, and playback moves there. Verified against a real librespot 0.8 receiver on the LAN. The crate is now also a library so diagnostics can reach these internals; examples/connect_probe.rs discovers and connects from the terminal.
101 lines
3.5 KiB
Rust
101 lines
3.5 KiB
Rust
//! Diagnostic: discover Spotify Connect receivers on the LAN and hand one an
|
|
//! account, then check whether Spotify's device list picks it up.
|
|
//!
|
|
//! cargo run --example connect_probe # just discover
|
|
//! cargo run --example connect_probe -- "House" # discover and activate
|
|
|
|
use std::time::Duration;
|
|
|
|
fn main() -> anyhow::Result<()> {
|
|
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")).init();
|
|
let wanted = std::env::args().nth(1);
|
|
|
|
println!("browsing for _spotify-connect._tcp ...");
|
|
let receivers = fastpotify::zeroconf::discover(Duration::from_secs(4))?;
|
|
if receivers.is_empty() {
|
|
println!(" none found");
|
|
return Ok(());
|
|
}
|
|
let http = reqwest::blocking::Client::builder()
|
|
.timeout(Duration::from_secs(8))
|
|
.build()?;
|
|
for receiver in &receivers {
|
|
print!(
|
|
" {} at {}:{}",
|
|
receiver.name, receiver.address, receiver.port
|
|
);
|
|
match fastpotify::zeroconf::get_info(&http, receiver) {
|
|
Ok(info) => println!(
|
|
" [{} {} | token={} | active_user={:?}]",
|
|
info.remote_name, info.device_type, info.token_type, info.active_user
|
|
),
|
|
Err(error) => println!(" (getInfo failed: {error})"),
|
|
}
|
|
}
|
|
|
|
let Some(wanted) = wanted else { return Ok(()) };
|
|
let Some(receiver) = receivers
|
|
.iter()
|
|
.find(|r| r.name.to_lowercase().contains(&wanted.to_lowercase()))
|
|
else {
|
|
println!("no receiver matching {wanted:?}");
|
|
return Ok(());
|
|
};
|
|
|
|
let dirs = fastpotify::paths::AppDirs::discover();
|
|
let credentials = fastpotify::zeroconf::Credentials::load(&dirs.credentials_dir())?;
|
|
println!("\nhanding the account to {} ...", receiver.name);
|
|
let info = fastpotify::zeroconf::get_info(&http, receiver)?;
|
|
match fastpotify::zeroconf::add_user(&http, receiver, &info, &credentials, "Fastpotify") {
|
|
Ok(()) => println!(" accepted"),
|
|
Err(error) => {
|
|
println!(" refused: {error}");
|
|
return Ok(());
|
|
}
|
|
}
|
|
|
|
println!("\nwaiting for it to appear in Spotify's device list ...");
|
|
for attempt in 1..=10 {
|
|
std::thread::sleep(Duration::from_secs(2));
|
|
let listed = devices()?;
|
|
if !listed.is_empty() {
|
|
for name in &listed {
|
|
println!(" [{attempt}] {name}");
|
|
}
|
|
return Ok(());
|
|
}
|
|
println!(" [{attempt}] still empty");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// The account's devices as Spotify currently sees them.
|
|
fn devices() -> anyhow::Result<Vec<String>> {
|
|
let home = std::env::var("HOME")?;
|
|
let token: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(format!(
|
|
"{home}/.local/state/fastpotify/web_api_token.json"
|
|
))?)?;
|
|
let http = reqwest::blocking::Client::new();
|
|
let body: serde_json::Value = http
|
|
.get("https://api.spotify.com/v1/me/player/devices")
|
|
.bearer_auth(token["access_token"].as_str().unwrap_or_default())
|
|
.send()?
|
|
.json()?;
|
|
Ok(body["devices"]
|
|
.as_array()
|
|
.map(|devices| {
|
|
devices
|
|
.iter()
|
|
.map(|d| {
|
|
format!(
|
|
"{} ({}, active={})",
|
|
d["name"].as_str().unwrap_or("?"),
|
|
d["type"].as_str().unwrap_or("?"),
|
|
d["is_active"]
|
|
)
|
|
})
|
|
.collect()
|
|
})
|
|
.unwrap_or_default())
|
|
}
|