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

fix Spotify Connect feature support after new API changes #618

Merged
merged 2 commits into from
Nov 30, 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
54 changes: 40 additions & 14 deletions spotify_player/src/client/mod.rs
Original file line number Diff line number Diff line change
@@ -1,17 +1,14 @@
use std::ops::Deref;
use std::{borrow::Cow, collections::HashMap, sync::Arc};

#[cfg(feature = "streaming")]
use crate::state::Mutex;
use crate::state::Show;
use crate::{auth, config};
use crate::{
auth::AuthConfig,
state::{
rspotify_model, store_data_into_file_cache, Album, AlbumId, Artist, ArtistId, Category,
Context, ContextId, Device, FileCacheKey, Item, ItemId, MemoryCaches, Playback,
PlaybackMetadata, Playlist, PlaylistFolderItem, PlaylistId, SearchResults, SharedState,
ShowId, Track, TrackId, UserId, TTL_CACHE_DURATION, USER_LIKED_TRACKS_ID,
Show, ShowId, Track, TrackId, UserId, TTL_CACHE_DURATION, USER_LIKED_TRACKS_ID,
USER_RECENTLY_PLAYED_TRACKS_ID, USER_TOP_TRACKS_ID,
},
};
Expand All @@ -20,6 +17,10 @@ use std::io::Write;

use anyhow::Context as _;
use anyhow::Result;

#[cfg(feature = "streaming")]
use parking_lot::Mutex;

use reqwest::StatusCode;
use rspotify::model::{AdditionalType, CurrentPlaybackContext};
use rspotify::{
Expand Down Expand Up @@ -212,7 +213,7 @@ impl Client {
// because `TransferPlayback` doesn't require an active playback
self.transfer_playback(&device_id, Some(force_play)).await?;
tracing::info!("Transferred playback to device with id={}", device_id);
return Ok(playback);
return Ok(None);
}
PlayerRequest::StartPlayback(p, shuffle) => {
// Set the playback's shuffle state if specified in the request
Expand All @@ -226,7 +227,7 @@ impl Client {
if let Some(ref playback) = playback {
self.shuffle(playback.shuffle_state, device_id).await?;
}
return Ok(playback);
return Ok(None);
}
_ => {}
}
Expand Down Expand Up @@ -366,7 +367,7 @@ impl Client {
self.retrieve_current_playback(state, true).await?;
}
ClientRequest::GetDevices => {
let devices = self.device().await?;
let devices = self.available_devices().await?;
state.player.write().devices = devices
.into_iter()
.filter_map(Device::try_from_device)
Expand Down Expand Up @@ -610,6 +611,19 @@ impl Client {
Ok(())
}

/// Get user available devices
// This is a custom API to replace `rspotify::device` API to support Spotify Connect feature
pub async fn available_devices(&self) -> Result<Vec<rspotify::model::Device>> {
Ok(self
.http_get::<rspotify::model::DevicePayload>(
&format!("{SPOTIFY_API_ENDPOINT}/me/player/devices"),
&Query::new(),
true,
)
.await?
.devices)
}

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 Expand Up @@ -652,7 +666,8 @@ impl Client {

/// Find an available device. If found, return the device's ID.
async fn find_available_device(&self) -> Result<Option<String>> {
let devices = self.device().await?.into_iter().collect::<Vec<_>>();
let devices = self.available_devices().await?;

if devices.is_empty() {
tracing::warn!("No device found. Please make sure you already setup Spotify Connect \
support as described in https://github.com/aome510/spotify-player#spotify-connect.");
Expand Down Expand Up @@ -754,6 +769,7 @@ impl Client {
.http_get::<Page<SimplifiedPlaylist>>(
&format!("{SPOTIFY_API_ENDPOINT}/me/playlists"),
&Query::from([("limit", "50")]),
false,
)
.await?;
// let first_page = self
Expand All @@ -780,7 +796,7 @@ impl Client {
let mut maybe_next = first_page.next;
while let Some(url) = maybe_next {
let mut next_page = self
.http_get::<rspotify_model::CursorPageFullArtists>(&url, &Query::new())
.http_get::<rspotify_model::CursorPageFullArtists>(&url, &Query::new(), false)
.await?
.artists;
artists.append(&mut next_page.items);
Expand Down Expand Up @@ -1272,6 +1288,7 @@ impl Client {
.http_get::<FullPlaylist>(
&format!("{SPOTIFY_API_ENDPOINT}/playlists/{}", playlist_id.id()),
&market_query(),
false,
)
.await?;

Expand Down Expand Up @@ -1392,7 +1409,12 @@ impl Client {
}

/// Make a GET HTTP request to the Spotify server
async fn http_get<T>(&self, url: &str, payload: &Query<'_>) -> Result<T>
async fn http_get<T>(
&self,
url: &str,
payload: &Query<'_>,
use_user_client_id: bool,
) -> Result<T>
where
T: serde::de::DeserializeOwned,
{
Expand All @@ -1408,7 +1430,12 @@ impl Client {
.replace("\"name\":null", "\"name\":\"\"")
}

let access_token = self.access_token().await?;
let access_token = if use_user_client_id {
self.access_token_from_user_client_id().await
} else {
self.access_token().await
}
.context("get access token")?;

tracing::debug!("{access_token} {url}");

Expand Down Expand Up @@ -1448,7 +1475,7 @@ impl Client {

while let Some(url) = maybe_next {
let mut next_page = self
.http_get::<rspotify_model::Page<T>>(&url, payload)
.http_get::<rspotify_model::Page<T>>(&url, payload, false)
.await?;
if next_page.items.is_empty() {
break;
Expand All @@ -1471,7 +1498,7 @@ impl Client {
let mut maybe_next = first_page.next;
while let Some(url) = maybe_next {
let mut next_page = self
.http_get::<rspotify_model::CursorBasedPage<T>>(&url, &Query::new())
.http_get::<rspotify_model::CursorBasedPage<T>>(&url, &Query::new(), false)
.await?;
items.append(&mut next_page.items);
maybe_next = next_page.next;
Expand All @@ -1492,7 +1519,6 @@ impl Client {
let new_playback = {
// update the playback state
let playback = self.current_playback2().await?;
log::info!("current_playback: {playback:?}");
let mut player = state.player.write();

let prev_item = player.currently_playing();
Expand Down
23 changes: 17 additions & 6 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::{config, token};
use crate::{auth::SPOTIFY_CLIENT_ID, config, token};

#[derive(Clone, Default)]
/// A Spotify client to interact with Spotify API server
Expand All @@ -19,7 +19,12 @@ pub struct Spotify {
config: Config,
token: Arc<Mutex<Option<Token>>>,
http: HttpClient,
client_id: String,
/// User-provided client ID
///
/// This client ID is mainly used to support Spotify Connect feature
/// because Spotify client ID doesn't have access to user available devices
/// (https://developer.spotify.com/documentation/web-api/reference/get-a-users-available-devices)
user_client_id: String,
pub(crate) session: Arc<tokio::sync::Mutex<Option<Session>>>,
}

Expand Down Expand Up @@ -47,9 +52,7 @@ 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()
user_client_id: config::get_config()
.app_config
.get_client_id()
.expect("get client_id"),
Expand Down Expand Up @@ -84,6 +87,14 @@ impl Spotify {
)),
}
}

/// Get a Spotify access token based on a user-provided client ID
// TODO: implement caching
pub async fn access_token_from_user_client_id(&self) -> Result<String> {
let session = self.session().await;
let token = token::get_token_librespot(&session, &self.user_client_id).await?;
Ok(token.access_token)
}
}

// TODO: remove the below uses of `maybe_async` crate once
Expand Down Expand Up @@ -116,7 +127,7 @@ impl BaseClient for Spotify {
return Ok(old_token);
}

match token::get_token(&session, &self.client_id).await {
match token::get_token_rspotify(&session, SPOTIFY_CLIENT_ID).await {
Ok(token) => Ok(Some(token)),
Err(err) => {
tracing::error!("Failed to get a new token: {err:#}");
Expand Down
6 changes: 3 additions & 3 deletions spotify_player/src/token.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ const SCOPES: [&str; 15] = [
"user-library-modify",
];

async fn retrieve_token(
pub async fn get_token_librespot(
session: &Session,
client_id: &str,
) -> Result<librespot_core::token::Token> {
Expand All @@ -46,10 +46,10 @@ async fn retrieve_token(
Ok(token)
}

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

let fut = retrieve_token(session, client_id);
let fut = get_token_librespot(session, client_id);
let token =
match tokio::time::timeout(std::time::Duration::from_secs(TIMEOUT_IN_SECS), fut).await {
Ok(Ok(token)) => token,
Expand Down
35 changes: 18 additions & 17 deletions spotify_player/src/ui/playback.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,32 +141,33 @@ pub fn render_playback_window(
duration,
);
render_playback_progress_bar(frame, ui, progress, duration, progress_bar_rect);
return other_rect;
}
} else {
// Previously rendered image can result in a weird rendering text,
// clear the previous widget's area before rendering the text.
#[cfg(feature = "image")]
{
if ui.last_cover_image_render_info.rendered {
clear_area(
frame,
ui.last_cover_image_render_info.render_area,
&ui.theme,
);
ui.last_cover_image_render_info = ImageRenderInfo::default();
}
}

// Previously rendered image can result in a weird rendering text,
// clear the previous widget's area before rendering the text.
#[cfg(feature = "image")]
{
if ui.last_cover_image_render_info.rendered {
clear_area(
frame,
ui.last_cover_image_render_info.render_area,
&ui.theme,
);
ui.last_cover_image_render_info = ImageRenderInfo::default();
}
}

frame.render_widget(
frame.render_widget(
Paragraph::new(
"No playback found.\n \
Please make sure there is a running Spotify device and try to connect to one using the `SwitchDevice` command.\n \
"No playback found. Please start a new playback.\n \
Make sure there is a running Spotify device and try to connect to one using the `SwitchDevice` command.\n \
You may also need to set up Spotify Connect to see available devices as in https://github.com/aome510/spotify-player#spotify-connect."
)
.wrap(Wrap { trim: true }),
rect,
);
};

other_rect
}
Expand Down