Skip to content

Commit cfde29f

Browse files
authored
handling client_id scoping for Spotify Connect feature with librespot 0.5.0 (#584)
- update `token::get_token` to support user-provided `client_id` (required for Spotify Connect feature) - refactor playback initialization logic upon startup or new session
1 parent 68b205b commit cfde29f

File tree

6 files changed

+108
-70
lines changed

6 files changed

+108
-70
lines changed

spotify_player/src/auth.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,18 @@ use librespot_oauth::get_access_token;
55
use crate::config;
66

77
pub const SPOTIFY_CLIENT_ID: &str = "65b708073fc0480ea92a077233ca87bd";
8-
pub const CLIENT_REDIRECT_URI: &str = "http://127.0.0.1:8989/login";
9-
pub const OAUTH_SCOPES: &[&str] = &[
8+
const CLIENT_REDIRECT_URI: &str = "http://127.0.0.1:8989/login";
9+
// based on https://github.com/librespot-org/librespot/blob/f96f36c064795011f9fee912291eecb1aa46fff6/src/main.rs#L173
10+
const OAUTH_SCOPES: &[&str] = &[
11+
"app-remote-control",
1012
"playlist-modify",
1113
"playlist-modify-private",
1214
"playlist-modify-public",
1315
"playlist-read",
1416
"playlist-read-collaborative",
1517
"playlist-read-private",
1618
"streaming",
19+
"ugc-image-upload",
1720
"user-follow-modify",
1821
"user-follow-read",
1922
"user-library-modify",
@@ -22,6 +25,7 @@ pub const OAUTH_SCOPES: &[&str] = &[
2225
"user-modify-playback-state",
2326
"user-modify-private",
2427
"user-personalized",
28+
"user-read-birthdate",
2529
"user-read-currently-playing",
2630
"user-read-email",
2731
"user-read-play-history",

spotify_player/src/client/mod.rs

Lines changed: 49 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -60,22 +60,55 @@ impl Client {
6060
}
6161

6262
/// Initialize the application's playback upon creating a new session or during startup
63-
pub async fn initialize_playback(&self, state: &SharedState) -> Result<()> {
64-
self.retrieve_current_playback(state, false).await?;
65-
66-
if state.player.read().playback.is_none() {
67-
tracing::info!("No playback found, trying to connect to an available device...");
68-
// // handle `connect_device` task separately as we don't want to block here
69-
tokio::task::spawn({
70-
let client = self.clone();
71-
let state = state.clone();
72-
async move {
73-
client.connect_device(&state).await;
74-
}
75-
});
76-
}
63+
pub fn initialize_playback(&self, state: &SharedState) {
64+
tokio::task::spawn({
65+
let client = self.clone();
66+
let state = state.clone();
67+
async move {
68+
// The main playback initialization logic is simple:
69+
// if there is no playback, connect to an available device
70+
//
71+
// However, because it takes time for Spotify server to show up new changes,
72+
// a retry logic is implemented to ensure the application's state is properly initialized
73+
let delay = std::time::Duration::from_secs(1);
74+
75+
for _ in 0..5 {
76+
tokio::time::sleep(delay).await;
77+
78+
if let Err(err) = client.retrieve_current_playback(&state, false).await {
79+
tracing::error!("Failed to retrieve current playback: {err:#}");
80+
return;
81+
}
7782

78-
Ok(())
83+
// if playback exists, don't connect to a new device
84+
if state.player.read().playback.is_some() {
85+
continue;
86+
}
87+
88+
let id = match client.find_available_device().await {
89+
Ok(Some(id)) => Some(Cow::Owned(id)),
90+
Ok(None) => None,
91+
Err(err) => {
92+
tracing::error!("Failed to find an available device: {err:#}");
93+
None
94+
}
95+
};
96+
97+
if let Some(id) = id {
98+
tracing::info!("Trying to connect to device (id={id})");
99+
if let Err(err) = client.transfer_playback(&id, Some(false)).await {
100+
tracing::warn!("Connection failed (device_id={id}): {err:#}");
101+
} else {
102+
tracing::info!("Connection succeeded (device_id={id})!");
103+
// upon new connection, reset the buffered playback
104+
state.player.write().buffered_playback = None;
105+
client.update_playback(&state);
106+
break;
107+
}
108+
}
109+
}
110+
}
111+
});
79112
}
80113

81114
/// Create a new client session
@@ -114,10 +147,7 @@ impl Client {
114147
if let Some(state) = state {
115148
// reset the application's caches
116149
state.data.write().caches = MemoryCaches::new();
117-
118-
self.initialize_playback(state)
119-
.await
120-
.context("initialize playback")?;
150+
self.initialize_playback(state);
121151
}
122152

123153
Ok(())
@@ -554,40 +584,6 @@ impl Client {
554584
Ok(())
555585
}
556586

557-
/// Connect to a Spotify device
558-
async fn connect_device(&self, state: &SharedState) {
559-
// Device connection can fail when the specified device hasn't shown up
560-
// in the Spotify's server, resulting in a failed `TransferPlayback` API request.
561-
// This is why a retry mechanism is needed to ensure a successful connection.
562-
let delay = std::time::Duration::from_secs(1);
563-
564-
for _ in 0..10 {
565-
tokio::time::sleep(delay).await;
566-
567-
let id = match self.find_available_device().await {
568-
Ok(Some(id)) => Some(Cow::Owned(id)),
569-
Ok(None) => None,
570-
Err(err) => {
571-
tracing::error!("Failed to find an available device: {err:#}");
572-
None
573-
}
574-
};
575-
576-
if let Some(id) = id {
577-
tracing::info!("Trying to connect to device (id={id})");
578-
if let Err(err) = self.transfer_playback(&id, Some(false)).await {
579-
tracing::warn!("Connection failed (device_id={id}): {err:#}");
580-
} else {
581-
tracing::info!("Connection succeeded (device_id={id})!");
582-
// upon new connection, reset the buffered playback
583-
state.player.write().buffered_playback = None;
584-
self.update_playback(state);
585-
break;
586-
}
587-
}
588-
}
589-
}
590-
591587
pub fn update_playback(&self, state: &SharedState) {
592588
// After handling a request changing the player's playback,
593589
// update the playback state by making multiple get-playback requests.

spotify_player/src/client/spotify.rs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ use rspotify::{
99
};
1010
use std::{fmt, sync::Arc};
1111

12-
use crate::token;
12+
use crate::{config, token};
1313

1414
#[derive(Clone, Default)]
1515
/// A Spotify client to interact with Spotify API server
@@ -19,6 +19,7 @@ pub struct Spotify {
1919
config: Config,
2020
token: Arc<Mutex<Option<Token>>>,
2121
http: HttpClient,
22+
client_id: String,
2223
pub(crate) session: Arc<tokio::sync::Mutex<Option<Session>>>,
2324
}
2425

@@ -45,6 +46,12 @@ impl Spotify {
4546
},
4647
token: Arc::new(Mutex::new(None)),
4748
http: HttpClient::default(),
49+
// Spotify client uses different `client_id` from Spotify session (`auth::SPOTIFY_CLIENT_ID`)
50+
// to support user-provided `client_id`, which is required for Spotify Connect feature
51+
client_id: config::get_config()
52+
.app_config
53+
.get_client_id()
54+
.expect("get client_id"),
4855
session: Arc::new(tokio::sync::Mutex::new(None)),
4956
}
5057
}
@@ -108,7 +115,7 @@ impl BaseClient for Spotify {
108115
return Ok(old_token);
109116
}
110117

111-
match token::get_token(&session).await {
118+
match token::get_token(&session, &self.client_id).await {
112119
Ok(token) => Ok(Some(token)),
113120
Err(err) => {
114121
tracing::error!("Failed to get a new token: {err:#}");

spotify_player/src/config/mod.rs

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -413,8 +413,6 @@ impl AppConfig {
413413
}
414414

415415
/// Returns stdout of `client_id_command` if set, otherwise it returns the the value of `client_id`
416-
// TODO: figure out how to use user-provided client_id for Spotify Connect integration
417-
#[allow(dead_code)]
418416
pub fn get_client_id(&self) -> Result<String> {
419417
match self.client_id_command {
420418
Some(ref cmd) => cmd.execute(None),

spotify_player/src/main.rs

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,7 @@ async fn init_spotify(
2323
client: &client::Client,
2424
state: &state::SharedState,
2525
) -> Result<()> {
26-
client
27-
.initialize_playback(state)
28-
.await
29-
.context("initialize playback")?;
26+
client.initialize_playback(state);
3027

3128
// request user data
3229
client_pub.send(client::ClientRequest::GetCurrentUser)?;

spotify_player/src/token.rs

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,53 @@ use std::collections::HashSet;
33
use anyhow::Result;
44
use chrono::{Duration, Utc};
55
use librespot_core::session::Session;
6-
use rspotify::Token;
7-
8-
use crate::auth::OAUTH_SCOPES;
96

107
const TIMEOUT_IN_SECS: u64 = 5;
118

12-
pub async fn get_token(session: &Session) -> Result<Token> {
9+
/// The application authentication token's permission scopes
10+
const SCOPES: [&str; 15] = [
11+
"user-read-recently-played",
12+
"user-top-read",
13+
"user-read-playback-position",
14+
"user-read-playback-state",
15+
"user-modify-playback-state",
16+
"user-read-currently-playing",
17+
"streaming",
18+
"playlist-read-private",
19+
"playlist-modify-private",
20+
"playlist-modify-public",
21+
"playlist-read-collaborative",
22+
"user-follow-read",
23+
"user-follow-modify",
24+
"user-library-read",
25+
"user-library-modify",
26+
];
27+
28+
async fn retrieve_token(
29+
session: &Session,
30+
client_id: &str,
31+
) -> Result<librespot_core::token::Token> {
32+
let query_uri = format!(
33+
"hm://keymaster/token/authenticated?scope={}&client_id={}&device_id={}",
34+
SCOPES.join(","),
35+
client_id,
36+
session.device_id(),
37+
);
38+
let request = session.mercury().get(query_uri)?;
39+
let response = request.await?;
40+
let data = response
41+
.payload
42+
.first()
43+
.ok_or(librespot_core::token::TokenError::Empty)?
44+
.to_vec();
45+
let token = librespot_core::token::Token::from_json(String::from_utf8(data)?)?;
46+
Ok(token)
47+
}
48+
49+
pub async fn get_token(session: &Session, client_id: &str) -> Result<rspotify::Token> {
1350
tracing::info!("Getting a new authentication token...");
1451

15-
let scopes = OAUTH_SCOPES.join(",");
16-
let fut = session.token_provider().get_token(&scopes);
52+
let fut = retrieve_token(session, client_id);
1753
let token =
1854
match tokio::time::timeout(std::time::Duration::from_secs(TIMEOUT_IN_SECS), fut).await {
1955
Ok(Ok(token)) => token,
@@ -34,7 +70,7 @@ pub async fn get_token(session: &Session) -> Result<Token> {
3470
// let expires_in = Duration::from_std(std::time::Duration::from_secs(5))?;
3571
let expires_at = Utc::now() + expires_in;
3672

37-
let token = Token {
73+
let token = rspotify::Token {
3874
access_token: token.access_token,
3975
expires_in,
4076
expires_at: Some(expires_at),

0 commit comments

Comments
 (0)