use std::collections::HashSet;
use std::ops::Deref;
use std::{borrow::Cow, collections::HashMap, sync::Arc};
use crate::state::Lyrics;
use crate::{auth, config};
use crate::{
auth::AuthConfig,
state::{
store_data_into_file_cache, Album, AlbumId, Artist, ArtistId, Category, Context, ContextId,
Device, FileCacheKey, Item, ItemId, MemoryCaches, Playback, PlaybackMetadata, Playlist,
PlaylistFolderItem, PlaylistId, SearchResults, SharedState, Show, ShowId, Track, TrackId,
UserId, TTL_CACHE_DURATION, USER_LIKED_TRACKS_URI, USER_RECENTLY_PLAYED_TRACKS_URI,
USER_TOP_TRACKS_URI,
},
};
use std::io::Write;
use anyhow::Context as _;
use anyhow::Result;
use librespot_core::SpotifyUri;
#[cfg(feature = "streaming")]
use parking_lot::Mutex;
use reqwest::StatusCode;
use rspotify::{http::Query, prelude::*};
mod handlers;
mod request;
mod spotify;
pub use handlers::*;
pub use request::*;
use serde::Deserialize;
const SPOTIFY_API_ENDPOINT: &str = "https://api.spotify.com/v1";
const PLAYBACK_TYPES: [&rspotify::model::AdditionalType; 2] = [
&rspotify::model::AdditionalType::Track,
&rspotify::model::AdditionalType::Episode,
];
#[derive(Clone)]
pub struct AppClient {
http: reqwest::Client,
spotify: Arc<spotify::Spotify>,
auth_config: AuthConfig,
user_client: Option<rspotify::AuthCodePkceSpotify>,
#[cfg(feature = "streaming")]
stream_conn: Arc<Mutex<Option<librespot_connect::Spirc>>>,
}
impl Deref for AppClient {
type Target = rspotify::AuthCodePkceSpotify;
fn deref(&self) -> &Self::Target {
self.user_client
.as_ref()
.expect("user-provided client should be initialized")
}
}
impl AppClient {
pub async fn new() -> Result<Self> {
let configs = config::get_config();
let auth_config = AuthConfig::new(configs)?;
let mut user_client = configs.app_config.get_user_client_id()?.clone().map(|id| {
let creds = rspotify::Credentials { id, secret: None };
let mut scopes = auth::OAUTH_SCOPES
.iter()
.map(ToString::to_string)
.collect::<HashSet<_>>();
scopes.remove("user-personalized");
let oauth = rspotify::OAuth {
redirect_uri: configs.app_config.login_redirect_uri.clone(),
scopes,
..Default::default()
};
let config = rspotify::Config {
token_cached: true,
cache_path: configs.cache_folder.join("user_client_token.json"),
..Default::default()
};
rspotify::AuthCodePkceSpotify::with_config(creds, oauth, config)
});
if let Some(client) = &mut user_client {
let url = client
.get_authorize_url(None)
.context("get authorize URL for user-provided client")?;
client
.prompt_for_token(&url)
.await
.context("get token for user-provided client")?;
}
Ok(Self {
spotify: Arc::new(spotify::Spotify::new()),
http: reqwest::Client::new(),
auth_config,
user_client,
#[cfg(feature = "streaming")]
stream_conn: Arc::new(Mutex::new(None)),
})
}
async fn token(&self) -> Result<String> {
self.auto_reauth().await?;
Ok(self
.get_token()
.lock()
.await
.unwrap()
.as_ref()
.context("no access token")?
.access_token
.clone())
}
pub fn initialize_playback(&self, state: &SharedState) {
tokio::task::spawn({
let client = self.clone();
let state = state.clone();
async move {
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;
}
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})!");
state.player.write().buffered_playback = None;
client.update_playback(&state);
break;
}
}
}
}
});
}
pub async fn new_session(&self, state: Option<&SharedState>, reauth: bool) -> Result<()> {
let session = self.auth_config.session();
let creds = auth::get_creds(&self.auth_config, reauth, true).context("get credentials")?;
self.spotify.set_session(session.clone()).await;
#[allow(unused_mut)]
let mut connected = false;
#[cfg(feature = "streaming")]
if let Some(state) = state {
if state.is_streaming_enabled() {
self.new_streaming_connection(state.clone(), session.clone(), creds.clone())
.await
.context("new streaming connection")?;
connected = true;
}
}
if !connected {
session
.connect(creds, true)
.await
.context("connect to a session")?;
}
tracing::info!("Used a new session for Spotify client.");
self.refresh_token().await.context("refresh auth token")?;
if let Some(state) = state {
state.data.write().caches = MemoryCaches::new();
self.initialize_playback(state);
}
Ok(())
}
pub async fn check_valid_session(&self, state: &SharedState) -> Result<()> {
if self.spotify.session().await.is_invalid() {
tracing::info!("Client's current session is invalid, creating a new session...");
self.new_session(Some(state), false)
.await
.context("create new client session")?;
}
Ok(())
}
#[cfg(feature = "streaming")]
pub async fn new_streaming_connection(
&self,
state: SharedState,
session: librespot_core::Session,
creds: librespot_core::authentication::Credentials,
) -> Result<()> {
let new_conn =
crate::streaming::new_connection(self.clone(), state, session, creds).await?;
let mut stream_conn = self.stream_conn.lock();
if let Some(conn) = stream_conn.as_ref() {
if let Err(err) = conn.shutdown() {
log::error!("Failed to shutdown old streaming connection: {err:#}");
}
}
*stream_conn = Some(new_conn);
Ok(())
}
pub async fn handle_player_request(
&self,
request: PlayerRequest,
mut playback: Option<PlaybackMetadata>,
) -> Result<Option<PlaybackMetadata>> {
match request {
PlayerRequest::TransferPlayback(device_id, force_play) => {
self.transfer_playback(&device_id, Some(force_play)).await?;
tracing::info!("Transferred playback to device with id={}", device_id);
return Ok(None);
}
PlayerRequest::StartPlayback(p, shuffle) => {
if let (Some(shuffle), Some(playback)) = (shuffle, playback.as_mut()) {
playback.shuffle_state = shuffle;
}
let device_id = playback.as_ref().and_then(|p| p.device_id.as_deref());
self.start_playback(p, device_id).await?;
if let Some(ref playback) = playback {
self.shuffle(playback.shuffle_state, device_id).await?;
}
return Ok(None);
}
_ => {}
}
let mut playback = playback.context("no playback found")?;
let device_id = playback.device_id.as_deref();
match request {
PlayerRequest::NextTrack => self.next_track(device_id).await?,
PlayerRequest::PreviousTrack => self.previous_track(device_id).await?,
PlayerRequest::Resume => {
if !playback.is_playing {
self.resume_playback(device_id, None).await?;
playback.is_playing = true;
}
}
PlayerRequest::Pause => {
if playback.is_playing {
self.pause_playback(device_id).await?;
playback.is_playing = false;
}
}
PlayerRequest::ResumePause => {
if playback.is_playing {
self.pause_playback(device_id).await?;
} else {
self.resume_playback(device_id, None).await?;
}
playback.is_playing = !playback.is_playing;
}
PlayerRequest::SeekTrack(position_ms) => {
self.seek_track(position_ms, device_id).await?;
}
PlayerRequest::Repeat => {
let next_repeat_state = match playback.repeat_state {
rspotify::model::RepeatState::Off => rspotify::model::RepeatState::Track,
rspotify::model::RepeatState::Track => rspotify::model::RepeatState::Context,
rspotify::model::RepeatState::Context => rspotify::model::RepeatState::Off,
};
self.repeat(next_repeat_state, device_id).await?;
playback.repeat_state = next_repeat_state;
}
PlayerRequest::Shuffle => {
self.shuffle(!playback.shuffle_state, device_id).await?;
playback.shuffle_state = !playback.shuffle_state;
}
PlayerRequest::Volume(volume) => {
self.volume(volume, device_id).await?;
playback.volume = Some(u32::from(volume));
playback.mute_state = None;
}
PlayerRequest::ToggleMute => {
let new_mute_state = match playback.mute_state {
None => {
self.volume(0, device_id).await?;
Some(playback.volume.unwrap_or_default())
}
Some(volume) => {
self.volume(volume as u8, device_id).await?;
None
}
};
playback.mute_state = new_mute_state;
}
PlayerRequest::StartPlayback(..) => {
anyhow::bail!("`StartPlayback` should be handled earlier")
}
PlayerRequest::TransferPlayback(..) => {
anyhow::bail!("`TransferPlayback` should be handled earlier")
}
}
Ok(Some(playback))
}
pub(crate) async fn handle_request(
&self,
state: &SharedState,
request: ClientRequest,
) -> Result<()> {
let timer = tokio::time::Instant::now();
match request {
ClientRequest::GetBrowseCategories => {
let categories = self.browse_categories().await?;
state.data.write().browse.categories = categories;
}
ClientRequest::GetBrowseCategoryPlaylists(category) => {
let playlists = self.browse_category_playlists(&category.id).await?;
state
.data
.write()
.browse
.category_playlists
.insert(category.id, playlists);
}
ClientRequest::GetLyrics { track_id } => {
let uri = track_id.uri();
if !state.data.read().caches.lyrics.contains_key(&uri) {
let lyrics = self.lyrics(track_id).await?;
state
.data
.write()
.caches
.lyrics
.insert(uri, lyrics, *TTL_CACHE_DURATION);
}
}
#[cfg(feature = "streaming")]
ClientRequest::RestartIntegratedClient => {
self.new_session(Some(state), false).await?;
}
ClientRequest::GetCurrentUser => {
let user = self.current_user().await?;
state.data.write().user_data.user = Some(user);
}
ClientRequest::Player(request) => {
let playback = state.player.read().buffered_playback.clone();
let playback = self.handle_player_request(request, playback).await?;
state.player.write().buffered_playback = playback;
self.update_playback(state);
}
ClientRequest::GetCurrentPlayback => {
self.retrieve_current_playback(state, true).await?;
}
ClientRequest::GetDevices => {
#[allow(unused_mut)]
let mut devices: Vec<Device> = self
.available_devices()
.await?
.into_iter()
.filter_map(Device::try_from_device)
.collect();
#[cfg(feature = "streaming")]
{
let configs = config::get_config();
let session = self.spotify.session().await;
let local_device = Device {
id: session.device_id().to_string(),
name: configs.app_config.device.name.clone(),
};
if !devices.iter().any(|d| d.id == local_device.id) {
devices.push(local_device);
}
}
state.player.write().devices = devices;
}
ClientRequest::GetUserPlaylists => {
let playlists = self.current_user_playlists().await?;
let node = state.data.read().user_data.playlist_folder_node.clone();
let playlists = if let Some(node) = node.filter(|n| !n.children.is_empty()) {
crate::playlist_folders::structurize(playlists, &node.children)
} else {
playlists
.into_iter()
.map(PlaylistFolderItem::Playlist)
.collect()
};
store_data_into_file_cache(
FileCacheKey::Playlists,
&config::get_config().cache_folder,
&playlists,
)
.context("store user's playlists into the cache folder")?;
state.data.write().user_data.playlists = playlists;
}
ClientRequest::GetUserFollowedArtists => {
let artists = self.current_user_followed_artists().await?;
store_data_into_file_cache(
FileCacheKey::FollowedArtists,
&config::get_config().cache_folder,
&artists,
)
.context("store user's followed artists into the cache folder")?;
state.data.write().user_data.followed_artists = artists;
}
ClientRequest::GetUserSavedAlbums => {
let albums = self.current_user_saved_albums().await?;
store_data_into_file_cache(
FileCacheKey::SavedAlbums,
&config::get_config().cache_folder,
&albums,
)
.context("store user's saved albums into the cache folder")?;
state.data.write().user_data.saved_albums = albums;
}
ClientRequest::GetUserSavedShows => {
let shows = self.current_user_saved_shows().await?;
store_data_into_file_cache(
FileCacheKey::SavedShows,
&config::get_config().cache_folder,
&shows,
)
.context("store user's saved shows into the cache folder")?;
state.data.write().user_data.saved_shows = shows;
}
ClientRequest::GetContext(context) => {
let uri = context.uri();
let cache_miss = uri != USER_LIKED_TRACKS_URI
&& !state.data.read().caches.context.contains_key(&uri);
let is_liked = uri == USER_LIKED_TRACKS_URI;
if cache_miss || is_liked {
let ctx = match context {
ContextId::Playlist(playlist_id) => {
self.playlist_context(playlist_id).await?
}
ContextId::Album(album_id) => self.album_context(album_id).await?,
ContextId::Artist(artist_id) => self.artist_context(artist_id).await?,
ContextId::Tracks(tracks_id) => match tracks_id.uri.as_str() {
USER_TOP_TRACKS_URI => Context::Tracks {
tracks: self.current_user_top_tracks().await?,
desc: "User's top tracks".to_string(),
},
USER_RECENTLY_PLAYED_TRACKS_URI => Context::Tracks {
tracks: self.current_user_recently_played_tracks().await?,
desc: "User's recently played tracks".to_string(),
},
USER_LIKED_TRACKS_URI => {
let tracks = self.current_user_saved_tracks().await?;
let tracks_hm = tracks
.iter()
.map(|t| (t.id.uri(), t.clone()))
.collect::<HashMap<_, _>>();
store_data_into_file_cache(
FileCacheKey::SavedTracks,
&config::get_config().cache_folder,
&tracks_hm,
)
.context("store user's saved tracks into the cache folder")?;
state.data.write().user_data.saved_tracks = tracks_hm;
Context::Tracks {
tracks,
desc: "User's liked tracks".to_string(),
}
}
u if u.starts_with("radio:") => Context::Tracks {
tracks: self.radio_tracks(u["radio:".len()..].to_string()).await?,
desc: tracks_id.kind.clone(),
},
uri => anyhow::bail!("unsupported Tracks context: {uri}"),
},
ContextId::Show(show_id) => self.show_context(show_id).await?,
};
state
.data
.write()
.caches
.context
.insert(uri, ctx, *TTL_CACHE_DURATION);
}
}
ClientRequest::Search(query) => {
if !state.data.read().caches.search.contains_key(&query) {
let results = self.search(&query).await?;
state
.data
.write()
.caches
.search
.insert(query, results, *TTL_CACHE_DURATION);
}
}
ClientRequest::AddPlayableToQueue(playable_id) => {
self.add_item_to_queue(playable_id, None).await?;
}
ClientRequest::AddPlayableToPlaylist(playlist_id, playable_id) => {
self.add_item_to_playlist(state, playlist_id, playable_id)
.await?;
}
ClientRequest::AddAlbumToQueue(album_id) => {
let album_context = self.album_context(album_id).await?;
if let Context::Album { album: _, tracks } = album_context {
for track in tracks {
self.add_item_to_queue(PlayableId::Track(track.id), None)
.await?;
}
}
}
ClientRequest::DeleteTrackFromPlaylist(playlist_id, track_id) => {
self.delete_track_from_playlist(state, playlist_id, track_id)
.await?;
}
ClientRequest::AddToLibrary(item) => {
self.add_to_library(state, item).await?;
}
ClientRequest::DeleteFromLibrary(id) => {
self.delete_from_library(state, id).await?;
}
ClientRequest::GetCurrentUserQueue => {
let queue = self.current_user_queue().await?;
state.player.write().queue = Some(queue);
}
ClientRequest::ReorderPlaylistItems {
playlist_id,
insert_index,
range_start,
range_length,
snapshot_id,
} => {
self.reorder_playlist_items(
state,
playlist_id,
insert_index,
range_start,
range_length,
snapshot_id.as_deref(),
)
.await?;
}
ClientRequest::CreatePlaylist {
playlist_name,
public,
collab,
desc,
} => {
let user_id = state
.data
.read()
.user_data
.user
.as_ref()
.map(|u| u.id.clone())
.unwrap();
self.create_new_playlist(
state,
user_id,
playlist_name.as_str(),
public,
collab,
desc.as_str(),
)
.await?;
}
}
tracing::info!(
"Successfully handled the client request, took: {}ms",
timer.elapsed().as_millis()
);
Ok(())
}
pub async fn lyrics(&self, track_id: TrackId<'static>) -> Result<Option<Lyrics>> {
let session = self.spotify.session().await;
let uri = SpotifyUri::from_uri(&track_id.uri())?;
match uri {
SpotifyUri::Track { id } => {
match librespot_metadata::Lyrics::get(&session, &id).await {
Ok(lyrics) => Ok(Some(lyrics.into())),
Err(err) => {
if err.to_string().to_lowercase().contains("not found") {
Ok(None)
} else {
Err(err.into())
}
}
}
}
_ => Ok(None),
}
}
pub async fn available_devices(&self) -> Result<Vec<rspotify::model::Device>> {
Ok(self.device().await?)
}
pub fn update_playback(&self, state: &SharedState) {
let client = self.clone();
let state = state.clone();
tokio::task::spawn(async move {
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!(
"Encountered an error when updating the playback state: {err:#}"
);
}
}
});
}
pub async fn browse_categories(&self) -> Result<Vec<Category>> {
let first_page = self
.categories_manual(Some("EN"), None, Some(50), None)
.await?;
Ok(first_page.items.into_iter().map(Category::from).collect())
}
pub async fn browse_category_playlists(&self, category_id: &str) -> Result<Vec<Playlist>> {
#[derive(Deserialize, Debug)]
struct BrowseCategoryPlaylistsResponse {
playlists: rspotify::model::Page<serde_json::Value>,
}
Ok(self
.http_get::<BrowseCategoryPlaylistsResponse>(
&format!("{SPOTIFY_API_ENDPOINT}/browse/categories/{category_id}/playlists"),
&Query::from([("limit", "50")]),
)
.await?
.playlists
.items
.into_iter()
.filter_map(|item| {
serde_json::from_value::<rspotify::model::SimplifiedPlaylist>(item).ok()
})
.map(Into::into)
.collect())
}
async fn find_available_device(&self) -> Result<Option<String>> {
let devices = self.available_devices().await?;
tracing::info!("Available devices: {devices:?}");
if let Some(d) = devices.iter().find(|d| d.is_active) {
return Ok(d.id.clone());
}
let mut devices = devices
.into_iter()
.filter_map(|d| d.id.map(|id| (d.name, id)))
.collect::<Vec<_>>();
let configs = config::get_config();
#[cfg(feature = "streaming")]
{
let session = self.spotify.session().await;
devices.push((
configs.app_config.device.name.clone(),
session.device_id().to_string(),
));
}
if devices.is_empty() {
return Ok(None);
}
let id = devices
.iter()
.position(|d| d.0 == configs.app_config.default_device)
.unwrap_or_default();
Ok(Some(devices.remove(id).1))
}
pub async fn current_user_saved_tracks(&self) -> Result<Vec<Track>> {
let tracks = self
.all_paging_items::<rspotify::model::SavedTrack>(
&format!("{SPOTIFY_API_ENDPOINT}/me/tracks"),
0, )
.await?;
Ok(tracks
.into_iter()
.filter_map(|t| Track::try_from_full_track(t.track))
.collect())
}
pub async fn current_user_recently_played_tracks(&self) -> Result<Vec<Track>> {
let first_page = self.current_user_recently_played(Some(50), None).await?;
let play_histories = self.all_cursor_based_paging_items(first_page).await?;
let mut tracks = Vec::<Track>::new();
for history in play_histories {
if !tracks.iter().any(|t| t.name == history.track.name) {
if let Some(track) = Track::try_from_full_track(history.track) {
tracks.push(track);
}
}
}
Ok(tracks)
}
pub async fn current_user_top_tracks(&self) -> Result<Vec<Track>> {
let tracks = self
.all_paging_items::<rspotify::model::FullTrack>(
&format!("{SPOTIFY_API_ENDPOINT}/me/top/tracks"),
0, )
.await?;
Ok(tracks
.into_iter()
.filter_map(Track::try_from_full_track)
.collect())
}
pub async fn current_user_playlists(&self) -> Result<Vec<Playlist>> {
let playlists = self
.all_paging_items::<rspotify::model::SimplifiedPlaylist>(
&format!("{SPOTIFY_API_ENDPOINT}/me/playlists"),
0, )
.await?;
Ok(playlists
.into_iter()
.map(std::convert::Into::into)
.collect())
}
pub async fn current_user_followed_artists(&self) -> Result<Vec<Artist>> {
let first_page = self
.deref()
.current_user_followed_artists(None, None)
.await?;
let mut artists = first_page.items;
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())
.await?
.artists;
artists.append(&mut next_page.items);
maybe_next = next_page.next;
}
Ok(artists.into_iter().map(std::convert::Into::into).collect())
}
pub async fn current_user_saved_albums(&self) -> Result<Vec<Album>> {
let albums = self
.all_paging_items::<rspotify::model::SavedAlbum>(
&format!("{SPOTIFY_API_ENDPOINT}/me/albums"),
0, )
.await?;
Ok(albums.into_iter().map(Album::from).collect())
}
pub async fn current_user_saved_shows(&self) -> Result<Vec<Show>> {
let shows = self
.all_paging_items::<rspotify::model::Show>(
&format!("{SPOTIFY_API_ENDPOINT}/me/shows"),
0, )
.await?;
Ok(shows.into_iter().map(|s| s.show.into()).collect())
}
pub async fn artist_albums(&self, artist_id: ArtistId<'_>) -> Result<Vec<Album>> {
let albums = self
.all_paging_items::<rspotify::model::SimplifiedAlbum>(
&format!(
"{SPOTIFY_API_ENDPOINT}/artists/{}/albums?include_groups=album,single",
artist_id.id()
),
0, )
.await?
.into_iter()
.filter_map(Album::try_from_simplified_album)
.collect();
Ok(AppClient::process_artist_albums(albums))
}
async fn start_playback(&self, playback: Playback, device_id: Option<&str>) -> Result<()> {
match playback {
Playback::Context(id, offset) => match id {
ContextId::Album(id) => {
self.start_context_playback(PlayContextId::from(id), device_id, offset, None)
.await?;
}
ContextId::Artist(id) => {
self.start_context_playback(PlayContextId::from(id), device_id, offset, None)
.await?;
}
ContextId::Playlist(id) => {
self.start_context_playback(PlayContextId::from(id), device_id, offset, None)
.await?;
}
ContextId::Show(id) => {
self.start_context_playback(PlayContextId::from(id), device_id, offset, None)
.await?;
}
ContextId::Tracks(_) => {
anyhow::bail!("`StartPlayback` request for `tracks` context is not supported")
}
},
Playback::URIs(ids, offset) => {
self.start_uris_playback(ids, device_id, offset, None)
.await?;
}
}
Ok(())
}
pub async fn radio_tracks(&self, seed_uri: String) -> Result<Vec<Track>> {
#[derive(Debug, Deserialize)]
struct TrackData {
original_gid: String,
}
#[derive(Debug, Deserialize)]
struct RadioStationResponse {
tracks: Vec<TrackData>,
}
let session = self.spotify.session().await;
let autoplay_query_url = format!("hm://autoplay-enabled/query?uri={seed_uri}");
let response = session
.mercury()
.get(autoplay_query_url)
.map_err(|err| anyhow::anyhow!("Failed to get autoplay URI: {err:#}"))?
.await?;
if response.status_code != 200 {
anyhow::bail!(
"Failed to get autoplay URI: got non-OK status code: {}",
response.status_code
);
}
let autoplay_uri = String::from_utf8(response.payload[0].clone())?;
let radio_query_url = format!("hm://radio-apollo/v3/stations/{autoplay_uri}");
let response = session
.mercury()
.get(radio_query_url)
.map_err(|err| anyhow::anyhow!("Failed to get radio data of {autoplay_uri}: {err:#}"))?
.await?;
if response.status_code != 200 {
anyhow::bail!(
"Failed to get radio data of {autoplay_uri}: got non-OK status code: {}",
response.status_code
);
}
let track_ids = serde_json::from_slice::<RadioStationResponse>(&response.payload[0])?
.tracks
.into_iter()
.filter_map(|t| TrackId::from_id(t.original_gid).ok());
let tracks = self
.tracks(track_ids, Some(rspotify::model::Market::FromToken))
.await?;
let mut tracks: Vec<_> = tracks
.into_iter()
.filter_map(Track::try_from_full_track)
.collect();
if let Ok(track_id) = TrackId::from_uri(&seed_uri) {
match self.track(track_id).await {
Ok(track) => move_seed_track_to_front(&mut tracks, track),
Err(err) => {
tracing::warn!("Failed to fetch track radio seed {seed_uri}: {err:#}");
}
}
}
Ok(tracks)
}
pub async fn search(&self, query: &str) -> Result<SearchResults> {
let (
track_result,
artist_result,
album_result,
playlist_result,
show_result,
episode_result,
) = tokio::try_join!(
self.search_specific_type(query, rspotify::model::SearchType::Track),
self.search_specific_type(query, rspotify::model::SearchType::Artist),
self.search_specific_type(query, rspotify::model::SearchType::Album),
self.search_specific_type(query, rspotify::model::SearchType::Playlist),
self.search_specific_type(query, rspotify::model::SearchType::Show),
self.search_specific_type(query, rspotify::model::SearchType::Episode)
)?;
let (tracks, artists, albums, playlists, shows, episodes) = (
match track_result {
rspotify::model::SearchResult::Tracks(p) => p
.items
.into_iter()
.filter_map(Track::try_from_full_track)
.collect(),
_ => anyhow::bail!("expect a track search result"),
},
match artist_result {
rspotify::model::SearchResult::Artists(p) => {
p.items.into_iter().map(std::convert::Into::into).collect()
}
_ => anyhow::bail!("expect an artist search result"),
},
match album_result {
rspotify::model::SearchResult::Albums(p) => p
.items
.into_iter()
.filter_map(Album::try_from_simplified_album)
.collect(),
_ => anyhow::bail!("expect an album search result"),
},
match playlist_result {
rspotify::model::SearchResult::Playlists(p) => {
p.items.into_iter().map(std::convert::Into::into).collect()
}
_ => anyhow::bail!("expect a playlist search result"),
},
match show_result {
rspotify::model::SearchResult::Shows(p) => {
p.items.into_iter().map(std::convert::Into::into).collect()
}
_ => anyhow::bail!("expect a show search result"),
},
match episode_result {
rspotify::model::SearchResult::Episodes(p) => {
p.items.into_iter().map(std::convert::Into::into).collect()
}
_ => anyhow::bail!("expect a episode search result"),
},
);
Ok(SearchResults {
tracks,
artists,
albums,
playlists,
shows,
episodes,
})
}
pub async fn search_specific_type(
&self,
query: &str,
typ: rspotify::model::SearchType,
) -> Result<rspotify::model::SearchResult> {
Ok(self
.deref()
.search(query, typ, None, None, None, None)
.await?)
}
pub async fn add_item_to_playlist(
&self,
state: &SharedState,
playlist_id: PlaylistId<'_>,
playable_id: PlayableId<'_>,
) -> Result<()> {
self.playlist_remove_all_occurrences_of_items(
playlist_id.as_ref(),
[playable_id.as_ref()],
None,
)
.await?;
self.playlist_add_items(playlist_id.as_ref(), [playable_id.as_ref()], None)
.await?;
state.data.write().caches.context.remove(&playlist_id.uri());
Ok(())
}
pub async fn delete_track_from_playlist(
&self,
state: &SharedState,
playlist_id: PlaylistId<'_>,
track_id: TrackId<'_>,
) -> Result<()> {
self.playlist_remove_all_occurrences_of_items(
playlist_id.as_ref(),
[PlayableId::Track(track_id.as_ref())],
None,
)
.await?;
if let Some(Context::Playlist { tracks, .. }) = state
.data
.write()
.caches
.context
.get_mut(&playlist_id.uri())
{
tracks.retain(|t| t.id != track_id);
}
Ok(())
}
async fn reorder_playlist_items(
&self,
state: &SharedState,
playlist_id: PlaylistId<'_>,
insert_index: usize,
range_start: usize,
range_length: Option<usize>,
snapshot_id: Option<&str>,
) -> Result<()> {
let insert_before = if insert_index > range_start {
insert_index + 1
} else {
insert_index
};
self.playlist_reorder_items(
playlist_id.clone(),
Some(range_start as i32),
Some(insert_before as i32),
range_length.map(|range_length| range_length as u32),
snapshot_id,
)
.await?;
if let Some(Context::Playlist { tracks, .. }) = state
.data
.write()
.caches
.context
.get_mut(&playlist_id.uri())
{
let track = tracks.remove(range_start);
tracks.insert(insert_index, track);
}
Ok(())
}
async fn add_to_library(&self, state: &SharedState, item: Item) -> Result<()> {
match item {
Item::Track(track) => {
let contains = self
.current_user_saved_tracks_contains([track.id.as_ref()])
.await?;
if !contains[0] {
self.current_user_saved_tracks_add([track.id.as_ref()])
.await?;
state
.data
.write()
.user_data
.saved_tracks
.insert(track.id.uri(), track);
}
}
Item::Album(album) => {
let contains = self
.current_user_saved_albums_contains([album.id.as_ref()])
.await?;
if !contains[0] {
self.current_user_saved_albums_add([album.id.as_ref()])
.await?;
state.data.write().user_data.saved_albums.insert(0, album);
}
}
Item::Artist(artist) => {
let follows = self.user_artist_check_follow([artist.id.as_ref()]).await?;
if !follows[0] {
self.user_follow_artists([artist.id.as_ref()]).await?;
state
.data
.write()
.user_data
.followed_artists
.insert(0, artist);
}
}
Item::Playlist(playlist) => {
let user_id = state
.data
.read()
.user_data
.user
.as_ref()
.map(|u| u.id.clone());
if let Some(user_id) = user_id {
let follows = self
.playlist_check_follow(playlist.id.as_ref(), &[user_id])
.await?;
if !follows[0] {
self.playlist_follow(playlist.id.as_ref(), None).await?;
state
.data
.write()
.user_data
.playlists
.insert(0, PlaylistFolderItem::Playlist(playlist));
}
}
}
Item::Show(show) => {
let follows = self.check_users_saved_shows([show.id.as_ref()]).await?;
if !follows[0] {
self.save_shows([show.id.as_ref()]).await?;
state.data.write().user_data.saved_shows.insert(0, show);
}
}
}
Ok(())
}
async fn delete_from_library(&self, state: &SharedState, id: ItemId) -> Result<()> {
match id {
ItemId::Track(id) => {
let uri = id.uri();
self.current_user_saved_tracks_delete([id]).await?;
state.data.write().user_data.saved_tracks.remove(&uri);
}
ItemId::Album(id) => {
state
.data
.write()
.user_data
.saved_albums
.retain(|a| a.id != id);
self.current_user_saved_albums_delete([id]).await?;
}
ItemId::Artist(id) => {
state
.data
.write()
.user_data
.followed_artists
.retain(|a| a.id != id);
self.user_unfollow_artists([id]).await?;
}
ItemId::Playlist(id) => {
state
.data
.write()
.user_data
.playlists
.retain(|item| match item {
PlaylistFolderItem::Playlist(p) => p.id != id,
PlaylistFolderItem::Folder(_) => true,
});
self.playlist_unfollow(id).await?;
}
ItemId::Show(id) => {
state
.data
.write()
.user_data
.saved_shows
.retain(|s| s.id != id);
self.remove_users_saved_shows([id], Some(rspotify::model::Market::FromToken))
.await?;
}
}
Ok(())
}
pub async fn track(&self, track_id: TrackId<'_>) -> Result<Track> {
Track::try_from_full_track(
self.deref()
.track(track_id, Some(rspotify::model::Market::FromToken))
.await?,
)
.context("convert FullTrack into Track")
}
pub async fn playlist_context(&self, playlist_id: PlaylistId<'_>) -> Result<Context> {
let playlist_uri = playlist_id.uri();
tracing::info!("Get playlist context: {}", playlist_uri);
let playlist = self
.playlist(
playlist_id.clone(),
None,
Some(rspotify::model::Market::FromToken),
)
.await?;
let tracks = self
.all_paging_items(
&format!(
"{SPOTIFY_API_ENDPOINT}/playlists/{}/tracks",
playlist_id.id(),
),
playlist.tracks.total as usize,
)
.await?
.into_iter()
.filter_map(Track::try_from_playlist_item)
.collect::<Vec<_>>();
Ok(Context::Playlist {
playlist: playlist.into(),
tracks,
})
}
pub async fn album_context(&self, album_id: AlbumId<'_>) -> Result<Context> {
let album_uri = album_id.uri();
tracing::info!("Get album context: {}", album_uri);
let album = self
.album(album_id.clone(), Some(rspotify::model::Market::FromToken))
.await?;
let total_tracks = album.tracks.total as usize;
let album: Album = album.into();
let tracks = self
.all_paging_items(
&format!("{SPOTIFY_API_ENDPOINT}/albums/{}/tracks", album_id.id()),
total_tracks,
)
.await?
.into_iter()
.filter_map(|t| {
Track::try_from_simplified_track(t).map(|mut t| {
t.album = Some(album.clone());
t
})
})
.collect::<Vec<_>>();
Ok(Context::Album { album, tracks })
}
pub async fn artist_context(&self, artist_id: ArtistId<'_>) -> Result<Context> {
let artist_uri = artist_id.uri();
tracing::info!("Get artist context: {}", artist_uri);
let artist = self
.artist(artist_id.as_ref())
.await
.context("get artist")?
.into();
let top_tracks = self
.artist_top_tracks(artist_id.as_ref(), Some(rspotify::model::Market::FromToken))
.await
.context("get artist's top tracks")?
.into_iter()
.filter_map(Track::try_from_full_track)
.collect::<Vec<_>>();
#[allow(deprecated)]
let related_artists = self
.artist_related_artists(artist_id.as_ref())
.await
.ok()
.unwrap_or_default()
.into_iter()
.map(std::convert::Into::into)
.collect::<Vec<_>>();
let albums = self
.artist_albums(artist_id.as_ref())
.await
.context("get artist's albums")?;
Ok(Context::Artist {
artist,
top_tracks,
albums,
related_artists,
})
}
pub async fn show_context(&self, show_id: ShowId<'_>) -> Result<Context> {
let show_uri = show_id.uri();
tracing::info!("Get show context: {}", show_uri);
let show = self.get_a_show(show_id.clone(), None).await?;
let episodes = self
.all_paging_items::<rspotify::model::SimplifiedEpisode>(
&format!("{SPOTIFY_API_ENDPOINT}/shows/{}/episodes", show_id.id()),
show.episodes.total as usize,
)
.await?
.into_iter()
.map(std::convert::Into::into)
.collect::<Vec<_>>();
let show: Show = show.into();
Ok(Context::Show { show, episodes })
}
async fn http_get<T>(&self, url: &str, payload: &Query<'_>) -> Result<T>
where
T: serde::de::DeserializeOwned,
{
fn process_spotify_api_response(text: &str) -> String {
text.to_string()
}
let access_token = self.token().await.context("get token")?;
tracing::debug!("{access_token} {url}");
let response = self
.http
.get(url)
.query(payload)
.header(
reqwest::header::AUTHORIZATION,
format!("Bearer {access_token}"),
)
.send()
.await?;
let status = response.status();
let text = process_spotify_api_response(&response.text().await?);
tracing::debug!("{text}");
if status != StatusCode::OK {
anyhow::bail!("failed to send a Spotify API request {url}: {text}");
}
Ok(serde_json::from_str(&text)?)
}
async fn all_paging_items<T>(&self, base_url: &str, mut count: usize) -> Result<Vec<T>>
where
T: serde::de::DeserializeOwned + std::fmt::Debug,
{
const PAGE_LIMIT: usize = 50;
const MAX_PARALLEL: usize = 8;
let mut all_items = Vec::new();
let mut offset = 0;
if count == 0 {
count = usize::MAX;
}
while offset < count {
let n_jobs = std::cmp::min(MAX_PARALLEL, (count - offset).div_ceil(PAGE_LIMIT));
let mut futures = Vec::with_capacity(n_jobs);
for i in 0..n_jobs {
let current_offset = offset + i * PAGE_LIMIT;
let limit_str = PAGE_LIMIT.to_string();
let offset_str = current_offset.to_string();
futures.push(async move {
let params = Query::from([
("market", "from_token"),
("limit", &limit_str),
("offset", &offset_str),
]);
self.http_get::<rspotify::model::Page<T>>(base_url, ¶ms)
.await
});
}
let results = futures::future::try_join_all(futures).await?;
let mut found_empty = false;
for mut page in results {
if page.items.is_empty() {
found_empty = true;
break;
}
all_items.append(&mut page.items);
}
if found_empty {
break;
}
offset += n_jobs * PAGE_LIMIT;
}
Ok(all_items)
}
async fn all_cursor_based_paging_items<T>(
&self,
first_page: rspotify::model::CursorBasedPage<T>,
) -> Result<Vec<T>>
where
T: serde::de::DeserializeOwned,
{
let mut items = first_page.items;
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())
.await?;
items.append(&mut next_page.items);
maybe_next = next_page.next;
}
Ok(items)
}
pub async fn current_playback2(
&self,
) -> Result<Option<rspotify::model::CurrentPlaybackContext>> {
Ok(self.current_playback(None, PLAYBACK_TYPES.into()).await?)
}
pub async fn retrieve_current_playback(
&self,
state: &SharedState,
reset_buffered_playback: bool,
) -> Result<()> {
let new_playback = {
let playback = self.current_playback2().await?;
let mut player = state.player.write();
let prev_item = player.currently_playing();
let prev_name = match prev_item {
Some(rspotify::model::PlayableItem::Track(track)) => track.name.clone(),
Some(rspotify::model::PlayableItem::Episode(episode)) => episode.name.clone(),
Some(rspotify::model::PlayableItem::Unknown(_)) | None => String::new(),
};
player.playback = playback;
player.playback_last_updated_time = Some(std::time::Instant::now());
let curr_item = player.currently_playing();
let curr_name = match curr_item {
Some(rspotify::model::PlayableItem::Track(track)) => track.name.clone(),
Some(rspotify::model::PlayableItem::Episode(episode)) => episode.name.clone(),
Some(rspotify::model::PlayableItem::Unknown(_)) | None => String::new(),
};
let new_playback = prev_name != curr_name && !curr_name.is_empty();
let needs_update = match (&player.buffered_playback, &player.playback) {
(Some(bp), Some(p)) => bp.device_id != p.device.id || new_playback,
(None, None) => false,
_ => true,
};
if reset_buffered_playback || needs_update {
player.buffered_playback = player.playback.as_ref().map(|p| {
let mut playback = PlaybackMetadata::from_playback(p);
if let Some(bp) = &player.buffered_playback {
if let Some(volume) = bp.mute_state {
playback.volume = Some(volume);
}
playback.mute_state = bp.mute_state;
}
playback
});
}
new_playback
};
if !new_playback {
return Ok(());
}
self.handle_new_playback_event(state).await?;
Ok(())
}
async fn handle_new_playback_event(&self, state: &SharedState) -> Result<()> {
let configs = config::get_config();
let curr_item = {
let player = state.player.read();
let Some(track_or_episode) = player.currently_playing() else {
return Ok(());
};
track_or_episode.clone()
};
let curr_artist = match &curr_item {
rspotify::model::PlayableItem::Track(full_track) => {
let cached = state
.data
.read()
.caches
.genres
.contains_key(&full_track.artists[0].name);
if cached {
None
} else {
match &full_track.artists[0].id {
Some(id) => self.artist(id.clone()).await.ok(),
None => None,
}
}
}
rspotify::model::PlayableItem::Episode(_)
| rspotify::model::PlayableItem::Unknown(_) => None,
};
if let Some(artist) = curr_artist {
if !artist.genres.is_empty() {
state.data.write().caches.genres.insert(
artist.name,
artist.genres,
*TTL_CACHE_DURATION,
);
}
}
let url = match curr_item {
rspotify::model::PlayableItem::Track(ref track) => {
crate::utils::get_track_album_image_url(track)
.ok_or(anyhow::anyhow!("missing image"))?
}
rspotify::model::PlayableItem::Episode(ref episode) => {
crate::utils::get_episode_show_image_url(episode)
.ok_or(anyhow::anyhow!("missing image"))?
}
rspotify::model::PlayableItem::Unknown(_) => return Ok(()),
};
let filename = (match curr_item {
rspotify::model::PlayableItem::Track(ref track) => {
format!(
"{}-{}-cover-{}.jpg",
track.album.name,
track.album.artists.first().unwrap().name,
&track.album.id.as_ref().unwrap().id()[..6]
)
}
rspotify::model::PlayableItem::Episode(ref episode) => {
format!(
"{}-{}-cover-{}.jpg",
episode.show.name,
episode.show.publisher,
&episode.show.id.as_ref().id()[..6]
)
}
rspotify::model::PlayableItem::Unknown(_) => return Ok(()),
})
.replace('/', ""); let path = configs.cache_folder.join("image").join(filename);
if configs.app_config.enable_cover_image_cache {
self.retrieve_image(url, &path, true).await?;
}
#[cfg(feature = "image")]
if !state.data.read().caches.images.contains_key(url) {
let bytes = self.retrieve_image(url, &path, false).await?;
#[cfg(not(feature = "pixelate"))]
let image =
image::load_from_memory(&bytes).context("Failed to load image from memory")?;
#[cfg(feature = "pixelate")]
let mut image =
image::load_from_memory(&bytes).context("Failed to load image from memory")?;
#[cfg(feature = "pixelate")]
{
Self::pixelate_image(&mut image);
}
state
.data
.write()
.caches
.images
.insert(url.to_owned(), image, *TTL_CACHE_DURATION);
}
#[cfg(all(feature = "notify", feature = "streaming"))]
if configs.app_config.enable_notify
&& (!configs.app_config.notify_streaming_only || self.stream_conn.lock().is_some())
{
Self::notify_new_playback(&curr_item, &path)?;
}
#[cfg(all(feature = "notify", not(feature = "streaming")))]
if configs.app_config.enable_notify {
Self::notify_new_playback(&curr_item, &path)?;
}
Ok(())
}
async fn create_new_playlist(
&self,
state: &SharedState,
user_id: UserId<'static>,
playlist_name: &str,
public: bool,
collab: bool,
desc: &str,
) -> Result<()> {
let playlist: Playlist = self
.user_playlist_create(
user_id,
playlist_name,
Some(public),
Some(collab),
Some(desc),
)
.await?
.into();
tracing::info!(
"new playlist (name={},id={}) was successfully created",
playlist.name,
playlist.id
);
state
.data
.write()
.user_data
.playlists
.insert(0, PlaylistFolderItem::Playlist(playlist));
Ok(())
}
#[cfg(feature = "notify")]
fn notify_new_playback(
playable: &rspotify::model::PlayableItem,
cover_img_path: &std::path::Path,
) -> Result<()> {
let mut n = notify_rust::Notification::new();
let re = regex::Regex::new(r"\{.*?\}").unwrap();
let get_text_from_format_str = |format_str: &str| {
let mut text = String::new();
let mut ptr = 0;
for m in re.find_iter(format_str) {
let s = m.start();
let e = m.end();
if ptr < s {
text += &format_str[ptr..s];
}
ptr = e;
match m.as_str() {
"{track}" => {
let name = match playable {
rspotify::model::PlayableItem::Track(ref track) => &track.name,
rspotify::model::PlayableItem::Episode(ref episode) => &episode.name,
rspotify::model::PlayableItem::Unknown(_) => continue,
};
text += name;
}
"{artists}" => {
if let rspotify::model::PlayableItem::Track(ref track) = playable {
text += &crate::utils::map_join(&track.artists, |a| &a.name, ", ");
}
}
"{album}" => match playable {
rspotify::model::PlayableItem::Track(ref track) => {
text += &track.album.name;
}
rspotify::model::PlayableItem::Episode(ref episode) => {
text += &episode.show.name;
}
rspotify::model::PlayableItem::Unknown(_) => {}
},
&_ => {}
}
}
if ptr < format_str.len() {
text += &format_str[ptr..];
}
text
};
let configs = config::get_config();
n.appname("spotify_player")
.summary(&get_text_from_format_str(
&configs.app_config.notify_format.summary,
))
.body(&get_text_from_format_str(
&configs.app_config.notify_format.body,
));
if cover_img_path.exists() {
n.icon(cover_img_path.to_str().context("valid cover_img_path")?);
}
if configs.app_config.notify_timeout_in_secs > 0 {
n.timeout(std::time::Duration::from_secs(
configs.app_config.notify_timeout_in_secs,
));
}
#[cfg(all(unix, not(target_os = "macos")))]
if configs.app_config.notify_transient {
use notify_rust::Hint;
n.hint(Hint::Transient(true));
}
n.show()?;
Ok(())
}
async fn retrieve_image(
&self,
url: &str,
path: &std::path::Path,
saved: bool,
) -> Result<Vec<u8>> {
if path.exists() {
tracing::debug!("Retrieving image from file: {}", path.display());
return Ok(std::fs::read(path)?);
}
tracing::info!("Retrieving image from url: {url}");
let bytes = self
.http
.get(url)
.send()
.await
.with_context(|| format!("get image from url {url}"))?
.bytes()
.await?;
if saved {
tracing::info!("Saving the retrieved image into {}", path.display());
let mut file = std::fs::File::create(path)?;
file.write_all(&bytes)?;
}
Ok(bytes.to_vec())
}
#[cfg(feature = "pixelate")]
fn pixelate_image(image: &mut image::DynamicImage) {
let pixels = config::get_config().app_config.cover_img_pixels;
let pixelated_image = image.resize(pixels, pixels, image::imageops::FilterType::Nearest);
*image = pixelated_image.resize(
image.width(),
image.height(),
image::imageops::FilterType::Nearest,
);
}
fn process_artist_albums(mut albums: Vec<Album>) -> Vec<Album> {
albums.sort_by(|x, y| y.release_date.partial_cmp(&x.release_date).unwrap());
if config::get_config().app_config.sort_artist_albums_by_type {
fn get_priority(album_type: &str) -> usize {
match album_type {
"album" => 0,
"single" => 1,
"appears_on" => 2,
"compilation" => 3,
_ => 4,
}
}
albums.sort_by_key(|a| get_priority(&a.album_type()));
}
albums
}
}
fn move_seed_track_to_front(tracks: &mut Vec<Track>, seed_track: Track) {
tracks.retain(|track| track.id != seed_track.id);
tracks.insert(0, seed_track);
}
#[cfg(test)]
mod tests {
use super::move_seed_track_to_front;
use crate::state::Track;
use rspotify::model::TrackId;
fn sample_track(id: &'static str, name: &str) -> Track {
Track {
id: TrackId::from_id(id).unwrap().into_static(),
name: name.to_string(),
artists: vec![],
album: None,
duration: std::time::Duration::default(),
explicit: false,
added_at: 0,
}
}
#[test]
fn move_seed_track_to_front_prepends_missing_seed() {
let seed = sample_track("3n3Ppam7vgaVa1iaRUc9Lp", "seed");
let second = sample_track("4uLU6hMCjMI75M1A2tKUQC", "second");
let third = sample_track("1301WleyT98MSxVHPZCA6M", "third");
let mut tracks = vec![second.clone(), third];
move_seed_track_to_front(&mut tracks, seed.clone());
assert_eq!(tracks.len(), 3);
assert_eq!(tracks[0].id, seed.id);
assert_eq!(tracks[1].id, second.id);
}
#[test]
fn move_seed_track_to_front_reorders_existing_seed_without_duplication() {
let seed = sample_track("3n3Ppam7vgaVa1iaRUc9Lp", "seed");
let second = sample_track("4uLU6hMCjMI75M1A2tKUQC", "second");
let mut tracks = vec![second.clone(), seed.clone()];
move_seed_track_to_front(&mut tracks, seed.clone());
assert_eq!(tracks.len(), 2);
assert_eq!(tracks[0].id, seed.id);
assert_eq!(tracks[1].id, second.id);
}
}