Skip to content

Commit

Permalink
handling client_id scoping for Spotify Connect feature with `libres…
Browse files Browse the repository at this point in the history
…pot 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
  • Loading branch information
aome510 authored Oct 28, 2024
1 parent 68b205b commit cfde29f
Show file tree
Hide file tree
Showing 6 changed files with 108 additions and 70 deletions.
8 changes: 6 additions & 2 deletions spotify_player/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,18 @@ use librespot_oauth::get_access_token;
use crate::config;

pub const SPOTIFY_CLIENT_ID: &str = "65b708073fc0480ea92a077233ca87bd";
pub const CLIENT_REDIRECT_URI: &str = "http://127.0.0.1:8989/login";
pub const OAUTH_SCOPES: &[&str] = &[
const CLIENT_REDIRECT_URI: &str = "http://127.0.0.1:8989/login";
// based on https://github.com/librespot-org/librespot/blob/f96f36c064795011f9fee912291eecb1aa46fff6/src/main.rs#L173
const OAUTH_SCOPES: &[&str] = &[
"app-remote-control",
"playlist-modify",
"playlist-modify-private",
"playlist-modify-public",
"playlist-read",
"playlist-read-collaborative",
"playlist-read-private",
"streaming",
"ugc-image-upload",
"user-follow-modify",
"user-follow-read",
"user-library-modify",
Expand All @@ -22,6 +25,7 @@ pub const OAUTH_SCOPES: &[&str] = &[
"user-modify-playback-state",
"user-modify-private",
"user-personalized",
"user-read-birthdate",
"user-read-currently-playing",
"user-read-email",
"user-read-play-history",
Expand Down
102 changes: 49 additions & 53 deletions spotify_player/src/client/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,22 +60,55 @@ impl Client {
}

/// Initialize the application's playback upon creating a new session or during startup
pub async fn initialize_playback(&self, state: &SharedState) -> Result<()> {
self.retrieve_current_playback(state, false).await?;

if state.player.read().playback.is_none() {
tracing::info!("No playback found, trying to connect to an available device...");
// // handle `connect_device` task separately as we don't want to block here
tokio::task::spawn({
let client = self.clone();
let state = state.clone();
async move {
client.connect_device(&state).await;
}
});
}
pub fn initialize_playback(&self, state: &SharedState) {
tokio::task::spawn({
let client = self.clone();
let state = state.clone();
async move {
// The main playback initialization logic is simple:
// if there is no playback, connect to an available device
//
// However, because it takes time for Spotify server to show up new changes,
// a retry logic is implemented to ensure the application's state is properly initialized
let delay = std::time::Duration::from_secs(1);

for _ in 0..5 {
tokio::time::sleep(delay).await;

if let Err(err) = client.retrieve_current_playback(&state, false).await {
tracing::error!("Failed to retrieve current playback: {err:#}");
return;
}

Ok(())
// if playback exists, don't connect to a new device
if state.player.read().playback.is_some() {
continue;
}

let id = match client.find_available_device().await {
Ok(Some(id)) => Some(Cow::Owned(id)),
Ok(None) => None,
Err(err) => {
tracing::error!("Failed to find an available device: {err:#}");
None
}
};

if let Some(id) = id {
tracing::info!("Trying to connect to device (id={id})");
if let Err(err) = client.transfer_playback(&id, Some(false)).await {
tracing::warn!("Connection failed (device_id={id}): {err:#}");
} else {
tracing::info!("Connection succeeded (device_id={id})!");
// upon new connection, reset the buffered playback
state.player.write().buffered_playback = None;
client.update_playback(&state);
break;
}
}
}
}
});
}

/// Create a new client session
Expand Down Expand Up @@ -114,10 +147,7 @@ impl Client {
if let Some(state) = state {
// reset the application's caches
state.data.write().caches = MemoryCaches::new();

self.initialize_playback(state)
.await
.context("initialize playback")?;
self.initialize_playback(state);
}

Ok(())
Expand Down Expand Up @@ -554,40 +584,6 @@ impl Client {
Ok(())
}

/// Connect to a Spotify device
async fn connect_device(&self, state: &SharedState) {
// Device connection can fail when the specified device hasn't shown up
// in the Spotify's server, resulting in a failed `TransferPlayback` API request.
// This is why a retry mechanism is needed to ensure a successful connection.
let delay = std::time::Duration::from_secs(1);

for _ in 0..10 {
tokio::time::sleep(delay).await;

let id = match self.find_available_device().await {
Ok(Some(id)) => Some(Cow::Owned(id)),
Ok(None) => None,
Err(err) => {
tracing::error!("Failed to find an available device: {err:#}");
None
}
};

if let Some(id) = id {
tracing::info!("Trying to connect to device (id={id})");
if let Err(err) = self.transfer_playback(&id, Some(false)).await {
tracing::warn!("Connection failed (device_id={id}): {err:#}");
} else {
tracing::info!("Connection succeeded (device_id={id})!");
// upon new connection, reset the buffered playback
state.player.write().buffered_playback = None;
self.update_playback(state);
break;
}
}
}
}

pub fn update_playback(&self, state: &SharedState) {
// After handling a request changing the player's playback,
// update the playback state by making multiple get-playback requests.
Expand Down
11 changes: 9 additions & 2 deletions spotify_player/src/client/spotify.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use rspotify::{
};
use std::{fmt, sync::Arc};

use crate::token;
use crate::{config, token};

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

Expand All @@ -45,6 +46,12 @@ impl Spotify {
},
token: Arc::new(Mutex::new(None)),
http: HttpClient::default(),
// Spotify client uses different `client_id` from Spotify session (`auth::SPOTIFY_CLIENT_ID`)
// to support user-provided `client_id`, which is required for Spotify Connect feature
client_id: config::get_config()
.app_config
.get_client_id()
.expect("get client_id"),
session: Arc::new(tokio::sync::Mutex::new(None)),
}
}
Expand Down Expand Up @@ -108,7 +115,7 @@ impl BaseClient for Spotify {
return Ok(old_token);
}

match token::get_token(&session).await {
match token::get_token(&session, &self.client_id).await {
Ok(token) => Ok(Some(token)),
Err(err) => {
tracing::error!("Failed to get a new token: {err:#}");
Expand Down
2 changes: 0 additions & 2 deletions spotify_player/src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -413,8 +413,6 @@ impl AppConfig {
}

/// Returns stdout of `client_id_command` if set, otherwise it returns the the value of `client_id`
// TODO: figure out how to use user-provided client_id for Spotify Connect integration
#[allow(dead_code)]
pub fn get_client_id(&self) -> Result<String> {
match self.client_id_command {
Some(ref cmd) => cmd.execute(None),
Expand Down
5 changes: 1 addition & 4 deletions spotify_player/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,7 @@ async fn init_spotify(
client: &client::Client,
state: &state::SharedState,
) -> Result<()> {
client
.initialize_playback(state)
.await
.context("initialize playback")?;
client.initialize_playback(state);

// request user data
client_pub.send(client::ClientRequest::GetCurrentUser)?;
Expand Down
50 changes: 43 additions & 7 deletions spotify_player/src/token.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,53 @@ use std::collections::HashSet;
use anyhow::Result;
use chrono::{Duration, Utc};
use librespot_core::session::Session;
use rspotify::Token;

use crate::auth::OAUTH_SCOPES;

const TIMEOUT_IN_SECS: u64 = 5;

pub async fn get_token(session: &Session) -> Result<Token> {
/// The application authentication token's permission scopes
const SCOPES: [&str; 15] = [
"user-read-recently-played",
"user-top-read",
"user-read-playback-position",
"user-read-playback-state",
"user-modify-playback-state",
"user-read-currently-playing",
"streaming",
"playlist-read-private",
"playlist-modify-private",
"playlist-modify-public",
"playlist-read-collaborative",
"user-follow-read",
"user-follow-modify",
"user-library-read",
"user-library-modify",
];

async fn retrieve_token(
session: &Session,
client_id: &str,
) -> Result<librespot_core::token::Token> {
let query_uri = format!(
"hm://keymaster/token/authenticated?scope={}&client_id={}&device_id={}",
SCOPES.join(","),
client_id,
session.device_id(),
);
let request = session.mercury().get(query_uri)?;
let response = request.await?;
let data = response
.payload
.first()
.ok_or(librespot_core::token::TokenError::Empty)?
.to_vec();
let token = librespot_core::token::Token::from_json(String::from_utf8(data)?)?;
Ok(token)
}

pub async fn get_token(session: &Session, client_id: &str) -> Result<rspotify::Token> {
tracing::info!("Getting a new authentication token...");

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

let token = Token {
let token = rspotify::Token {
access_token: token.access_token,
expires_in,
expires_at: Some(expires_at),
Expand Down

0 comments on commit cfde29f

Please sign in to comment.