Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

handling client_id scoping for Spotify Connect feature with librespot 0.5.0 #584

Merged
merged 2 commits into from
Oct 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading