use crate::app::{
ActiveBlock, AlbumTableContext, App, Artist, ArtistBlock, EpisodeTableContext, PlaylistFolder,
PlaylistFolderItem, PlaylistFolderNode, PlaylistFolderNodeType, RouteId, ScrollableResultPages,
SelectedAlbum, SelectedFullAlbum, SelectedFullShow, SelectedShow, TrackTableContext,
};
use crate::config::ClientConfig;
use crate::ui::util::create_artist_string;
use anyhow::anyhow;
use chrono::TimeDelta;
use reqwest::Method;
use rspotify::{
model::{
album::SimplifiedAlbum,
artist::FullArtist,
enums::{Country, RepeatState, SearchType},
idtypes::{AlbumId, ArtistId, PlayContextId, PlayableId, PlaylistId, ShowId, TrackId, UserId},
page::Page,
playlist::PlaylistItem,
recommend::Recommendations,
search::SearchResult,
show::SimplifiedShow,
track::FullTrack,
Market, PlayableItem,
},
prelude::*,
AuthCodePkceSpotify,
};
use serde::{de::DeserializeOwned, Deserialize};
use serde_json::{json, Value};
use std::{
sync::{Arc, OnceLock},
time::{Duration, Instant},
};
use tokio::sync::Mutex;
use tokio::try_join;
#[cfg(feature = "streaming")]
use crate::player::StreamingPlayer;
#[cfg(feature = "streaming")]
use librespot_connect::{LoadRequest, LoadRequestOptions, PlayingTrack};
pub enum IoEvent {
GetCurrentPlayback,
#[allow(dead_code)]
EnsurePlaybackContinues(String),
RefreshAuthentication,
GetPlaylists,
GetDevices,
GetSearchResults(String, Option<Country>),
SetTracksToTable(Vec<FullTrack>),
GetPlaylistItems(PlaylistId<'static>, u32),
GetCurrentSavedTracks(Option<u32>),
StartPlayback(
Option<PlayContextId<'static>>,
Option<Vec<PlayableId<'static>>>,
Option<usize>,
),
UpdateSearchLimits(u32, u32),
Seek(u32),
NextTrack,
PreviousTrack,
Shuffle(bool), Repeat(RepeatState),
PausePlayback,
ChangeVolume(u8),
GetArtist(ArtistId<'static>, String, Option<Country>),
GetAlbumTracks(Box<SimplifiedAlbum>),
GetRecommendationsForSeed(
Option<Vec<ArtistId<'static>>>,
Option<Vec<TrackId<'static>>>,
Box<Option<FullTrack>>,
Option<Country>,
),
GetCurrentUserSavedAlbums(Option<u32>),
CurrentUserSavedAlbumsContains(Vec<AlbumId<'static>>),
CurrentUserSavedAlbumDelete(AlbumId<'static>),
CurrentUserSavedAlbumAdd(AlbumId<'static>),
UserUnfollowArtists(Vec<ArtistId<'static>>),
UserFollowArtists(Vec<ArtistId<'static>>),
UserFollowPlaylist(UserId<'static>, PlaylistId<'static>, Option<bool>),
UserUnfollowPlaylist(UserId<'static>, PlaylistId<'static>),
AddTrackToPlaylist(PlaylistId<'static>, TrackId<'static>),
RemoveTrackFromPlaylistAtPosition(PlaylistId<'static>, TrackId<'static>, usize),
GetUser,
ToggleSaveTrack(PlayableId<'static>),
GetRecommendationsForTrackId(TrackId<'static>, Option<Country>),
GetRecentlyPlayed,
GetFollowedArtists(Option<ArtistId<'static>>),
SetArtistsToTable(Vec<FullArtist>),
UserArtistFollowCheck(Vec<ArtistId<'static>>),
GetAlbum(AlbumId<'static>),
TransferPlaybackToDevice(String, bool),
#[allow(dead_code)]
AutoSelectStreamingDevice(String, bool), GetAlbumForTrack(TrackId<'static>),
CurrentUserSavedTracksContains(Vec<TrackId<'static>>),
GetCurrentUserSavedShows(Option<u32>),
CurrentUserSavedShowsContains(Vec<ShowId<'static>>),
CurrentUserSavedShowDelete(ShowId<'static>),
CurrentUserSavedShowAdd(ShowId<'static>),
GetShowEpisodes(Box<SimplifiedShow>),
GetShow(ShowId<'static>),
GetCurrentShowEpisodes(ShowId<'static>, Option<u32>),
AddItemToQueue(PlayableId<'static>),
IncrementGlobalSongCount,
FetchGlobalSongCount,
GetLyrics(String, String, f64),
#[allow(dead_code)]
StartCollectionPlayback(usize),
PreFetchAllSavedTracks,
PreFetchAllPlaylistTracks(PlaylistId<'static>),
GetUserTopTracks(crate::app::DiscoverTimeRange),
GetTopArtistsMix,
FetchAllPlaylistTracksAndSort(PlaylistId<'static>),
}
pub struct Network {
pub spotify: AuthCodePkceSpotify,
large_search_limit: u32,
small_search_limit: u32,
pub client_config: ClientConfig,
pub app: Arc<Mutex<App>>,
#[cfg(feature = "streaming")]
streaming_player: Option<Arc<StreamingPlayer>>,
}
#[derive(Deserialize, Debug)]
#[allow(non_snake_case)]
struct LrcResponse {
syncedLyrics: Option<String>,
plainLyrics: Option<String>,
}
#[derive(Deserialize, Debug)]
#[allow(dead_code)]
struct GlobalSongCountResponse {
count: u64,
}
#[derive(Deserialize, Debug)]
struct ArtistSearchResponse {
artists: Page<FullArtist>,
}
static SPOTIFY_API_PACING: OnceLock<Mutex<Option<Instant>>> = OnceLock::new();
const SPOTIFY_API_MIN_INTERVAL: Duration = Duration::from_millis(250);
impl Network {
#[cfg(feature = "streaming")]
pub fn new(
spotify: AuthCodePkceSpotify,
client_config: ClientConfig,
app: &Arc<Mutex<App>>,
streaming_player: Option<Arc<StreamingPlayer>>,
) -> Self {
Network {
spotify,
large_search_limit: 50,
small_search_limit: 4,
client_config,
app: Arc::clone(app),
streaming_player,
}
}
#[cfg(not(feature = "streaming"))]
pub fn new(
spotify: AuthCodePkceSpotify,
client_config: ClientConfig,
app: &Arc<Mutex<App>>,
) -> Self {
Network {
spotify,
large_search_limit: 50,
small_search_limit: 4,
client_config,
app: Arc::clone(app),
}
}
#[cfg(feature = "streaming")]
async fn is_native_streaming_active_for_playback(&self) -> bool {
let player_connected = self
.streaming_player
.as_ref()
.is_some_and(|p| p.is_connected());
if !player_connected {
return false;
}
let native_device_name = self
.streaming_player
.as_ref()
.map(|p| p.device_name().to_lowercase());
let app = self.app.lock().await;
let Some(ref ctx) = app.current_playback_context else {
return app.is_streaming_active;
};
if let (Some(current_id), Some(native_id)) =
(ctx.device.id.as_ref(), app.native_device_id.as_ref())
{
if current_id == native_id {
return true;
}
}
if let Some(native_name) = native_device_name.as_ref() {
let current_device_name = ctx.device.name.to_lowercase();
if current_device_name == native_name.as_str() {
return true;
}
}
false
}
#[cfg(feature = "streaming")]
fn is_native_streaming_active(&self) -> bool {
self
.streaming_player
.as_ref()
.is_some_and(|p| p.is_connected())
}
#[allow(clippy::cognitive_complexity)]
pub async fn handle_network_event(&mut self, io_event: IoEvent) {
match io_event {
IoEvent::RefreshAuthentication => {
self.refresh_authentication().await;
}
IoEvent::EnsurePlaybackContinues(previous_track_id) => {
self.ensure_playback_continues(previous_track_id).await;
}
IoEvent::GetPlaylists => {
self.get_current_user_playlists().await;
}
IoEvent::GetUser => {
self.get_user().await;
}
IoEvent::GetDevices => {
self.get_devices().await;
}
IoEvent::GetCurrentPlayback => {
self.get_current_playback().await;
}
IoEvent::SetTracksToTable(full_tracks) => {
self.set_tracks_to_table(full_tracks).await;
}
IoEvent::GetSearchResults(search_term, country) => {
self.get_search_results(search_term, country).await;
}
IoEvent::GetPlaylistItems(playlist_id, playlist_offset) => {
self.get_playlist_tracks(playlist_id, playlist_offset).await;
}
IoEvent::GetCurrentSavedTracks(offset) => {
self.get_current_user_saved_tracks(offset).await;
}
IoEvent::StartPlayback(context_uri, uris, offset) => {
self.start_playback(context_uri, uris, offset).await;
}
IoEvent::UpdateSearchLimits(large_search_limit, small_search_limit) => {
self.large_search_limit = large_search_limit;
self.small_search_limit = small_search_limit;
}
IoEvent::Seek(position_ms) => {
self.seek(position_ms).await;
}
IoEvent::NextTrack => {
self.next_track().await;
}
IoEvent::PreviousTrack => {
self.previous_track().await;
}
IoEvent::Repeat(repeat_state) => {
self.repeat(repeat_state).await;
}
IoEvent::PausePlayback => {
self.pause_playback().await;
}
IoEvent::ChangeVolume(volume) => {
self.change_volume(volume).await;
}
IoEvent::GetArtist(artist_id, input_artist_name, country) => {
self.get_artist(artist_id, input_artist_name, country).await;
}
IoEvent::GetAlbumTracks(album) => {
self.get_album_tracks(album).await;
}
IoEvent::GetRecommendationsForSeed(seed_artists, seed_tracks, first_track, country) => {
self
.get_recommendations_for_seed(seed_artists, seed_tracks, first_track, country)
.await;
}
IoEvent::GetCurrentUserSavedAlbums(offset) => {
self.get_current_user_saved_albums(offset).await;
}
IoEvent::CurrentUserSavedAlbumsContains(album_ids) => {
self.current_user_saved_albums_contains(album_ids).await;
}
IoEvent::CurrentUserSavedAlbumDelete(album_id) => {
self.current_user_saved_album_delete(album_id).await;
}
IoEvent::CurrentUserSavedAlbumAdd(album_id) => {
self.current_user_saved_album_add(album_id).await;
}
IoEvent::UserUnfollowArtists(artist_ids) => {
self.user_unfollow_artists(artist_ids).await;
}
IoEvent::UserFollowArtists(artist_ids) => {
self.user_follow_artists(artist_ids).await;
}
IoEvent::UserFollowPlaylist(playlist_owner_id, playlist_id, is_public) => {
self
.user_follow_playlist(playlist_owner_id, playlist_id, is_public)
.await;
}
IoEvent::UserUnfollowPlaylist(user_id, playlist_id) => {
self.user_unfollow_playlist(user_id, playlist_id).await;
}
IoEvent::AddTrackToPlaylist(playlist_id, track_id) => {
self.add_track_to_playlist(playlist_id, track_id).await;
}
IoEvent::RemoveTrackFromPlaylistAtPosition(playlist_id, track_id, position) => {
self
.remove_track_from_playlist_at_position(playlist_id, track_id, position)
.await;
}
IoEvent::ToggleSaveTrack(track_id) => {
self.toggle_save_track(track_id).await;
}
IoEvent::GetRecommendationsForTrackId(track_id, country) => {
self
.get_recommendations_for_track_id(track_id, country)
.await;
}
IoEvent::GetRecentlyPlayed => {
self.get_recently_played().await;
}
IoEvent::GetFollowedArtists(after) => {
self.get_followed_artists(after).await;
}
IoEvent::SetArtistsToTable(full_artists) => {
self.set_artists_to_table(full_artists).await;
}
IoEvent::UserArtistFollowCheck(artist_ids) => {
self.user_artist_check_follow(artist_ids).await;
}
IoEvent::GetAlbum(album_id) => {
self.get_album(album_id).await;
}
IoEvent::TransferPlaybackToDevice(device_id, persist_device_id) => {
self
.transfert_playback_to_device(device_id, persist_device_id)
.await;
}
#[cfg(feature = "streaming")]
IoEvent::AutoSelectStreamingDevice(device_name, persist_device_id) => {
self
.auto_select_streaming_device(device_name, persist_device_id)
.await;
}
#[cfg(not(feature = "streaming"))]
IoEvent::AutoSelectStreamingDevice(..) => {} IoEvent::GetAlbumForTrack(track_id) => {
self.get_album_for_track(track_id).await;
}
IoEvent::Shuffle(shuffle_state) => {
self.shuffle(shuffle_state).await;
}
IoEvent::CurrentUserSavedTracksContains(track_ids) => {
self.current_user_saved_tracks_contains(track_ids).await;
}
IoEvent::GetCurrentUserSavedShows(offset) => {
self.get_current_user_saved_shows(offset).await;
}
IoEvent::CurrentUserSavedShowsContains(show_ids) => {
self.current_user_saved_shows_contains(show_ids).await;
}
IoEvent::CurrentUserSavedShowDelete(show_id) => {
self.current_user_saved_shows_delete(show_id).await;
}
IoEvent::CurrentUserSavedShowAdd(show_id) => {
self.current_user_saved_shows_add(show_id).await;
}
IoEvent::GetShowEpisodes(show) => {
self.get_show_episodes(show).await;
}
IoEvent::GetShow(show_id) => {
self.get_show(show_id).await;
}
IoEvent::GetCurrentShowEpisodes(show_id, offset) => {
self.get_current_show_episodes(show_id, offset).await;
}
IoEvent::AddItemToQueue(item) => {
self.add_item_to_queue(item).await;
}
IoEvent::IncrementGlobalSongCount => {
self.increment_global_song_count().await;
}
IoEvent::FetchGlobalSongCount => {
self.fetch_global_song_count().await;
}
IoEvent::GetLyrics(track, artist, duration) => {
self.get_lyrics(track, artist, duration).await;
}
IoEvent::StartCollectionPlayback(offset) => {
self.start_collection_playback(offset).await;
}
IoEvent::PreFetchAllSavedTracks => {
let spotify = self.spotify.clone();
let app = self.app.clone();
let large_search_limit = self.large_search_limit;
tokio::spawn(async move {
Self::prefetch_all_saved_tracks_task(spotify, app, large_search_limit).await;
});
}
IoEvent::PreFetchAllPlaylistTracks(playlist_id) => {
let spotify = self.spotify.clone();
let app = self.app.clone();
let large_search_limit = self.large_search_limit;
tokio::spawn(async move {
Self::prefetch_all_playlist_tracks_task(spotify, app, large_search_limit, playlist_id)
.await;
});
}
IoEvent::GetUserTopTracks(time_range) => {
self.get_user_top_tracks(time_range).await;
}
IoEvent::GetTopArtistsMix => {
self.get_top_artists_mix().await;
}
IoEvent::FetchAllPlaylistTracksAndSort(playlist_id) => {
self.fetch_all_playlist_tracks_and_sort(playlist_id).await;
}
};
{
let mut app = self.app.lock().await;
app.is_loading = false;
}
}
async fn handle_error(&mut self, e: anyhow::Error) {
let mut app = self.app.lock().await;
app.handle_error(e);
}
async fn show_status_message(&self, message: String, ttl_secs: u64) {
let mut app = self.app.lock().await;
app.status_message = Some(message);
app.status_message_expires_at = Some(Instant::now() + Duration::from_secs(ttl_secs));
}
fn is_rate_limited_error(e: &anyhow::Error) -> bool {
let text = e.to_string();
text.contains("429") || text.contains("Too Many Requests") || text.contains("Too many requests")
}
fn is_transient_network_error(e: &anyhow::Error) -> bool {
let text = e.to_string().to_lowercase();
text.contains("error sending request for url")
|| text.contains("connection reset")
|| text.contains("connection refused")
|| text.contains("timed out")
|| text.contains("temporary failure")
|| text.contains("dns")
}
async fn pace_spotify_api_call() {
let pacing_lock = SPOTIFY_API_PACING.get_or_init(|| Mutex::new(None));
let mut last_request_started_at = pacing_lock.lock().await;
if let Some(last) = *last_request_started_at {
let elapsed = last.elapsed();
if elapsed < SPOTIFY_API_MIN_INTERVAL {
tokio::time::sleep(SPOTIFY_API_MIN_INTERVAL - elapsed).await;
}
}
*last_request_started_at = Some(Instant::now());
}
async fn spotify_api_request_json_for(
spotify: &AuthCodePkceSpotify,
method: Method,
path: &str,
query: &[(&str, String)],
body: Option<Value>,
) -> anyhow::Result<Value> {
let mut url = reqwest::Url::parse("https://api.spotify.com/v1/")?.join(path)?;
if !query.is_empty() {
let mut qp = url.query_pairs_mut();
for (k, v) in query {
qp.append_pair(k, v);
}
}
let client = reqwest::Client::new();
let mut attempt: u8 = 0;
let max_attempts: u8 = 4;
let mut refreshed_after_unauthorized = false;
loop {
let access_token = {
let token_lock = spotify.token.lock().await.expect("Failed to lock token");
token_lock
.as_ref()
.map(|t| t.access_token.clone())
.ok_or_else(|| anyhow!("No access token available"))?
};
Self::pace_spotify_api_call().await;
let mut request = client
.request(method.clone(), url.clone())
.header("Authorization", format!("Bearer {}", access_token))
.header("Content-Type", "application/json");
if let Some(payload) = body.clone() {
request = request.json(&payload);
}
let response = match request.send().await {
Ok(response) => response,
Err(e) => {
if attempt + 1 < max_attempts && (e.is_connect() || e.is_timeout() || e.is_request()) {
let backoff_secs = 1 + u64::from(attempt);
tokio::time::sleep(Duration::from_secs(backoff_secs)).await;
attempt += 1;
continue;
}
return Err(anyhow!("Spotify API request failed: {}", e));
}
};
if response.status().is_success() {
let response_body = response.text().await?;
if response_body.trim().is_empty() {
return Ok(Value::Null);
}
return Ok(serde_json::from_str(&response_body)?);
}
let status = response.status();
if status == reqwest::StatusCode::UNAUTHORIZED && !refreshed_after_unauthorized {
match spotify.refresh_token().await {
Ok(_) => {
refreshed_after_unauthorized = true;
continue;
}
Err(refresh_err) => {
let body = response.text().await.unwrap_or_default();
return Err(anyhow!(
"Spotify API {} failed: {} (token refresh failed: {})",
status,
body,
refresh_err
));
}
}
}
if status == reqwest::StatusCode::TOO_MANY_REQUESTS && attempt + 1 < max_attempts {
let retry_after_secs = response
.headers()
.get("retry-after")
.and_then(|h| h.to_str().ok())
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(1);
let backoff_secs = retry_after_secs.max(1) + u64::from(attempt);
tokio::time::sleep(Duration::from_secs(backoff_secs)).await;
attempt += 1;
continue;
}
let body = response.text().await.unwrap_or_default();
return Err(anyhow!("Spotify API {} failed: {}", status, body));
}
}
fn normalize_spotify_payload(value: &mut Value) {
match value {
Value::Object(map) => {
if let Some(Value::Array(items)) = map.get_mut("items") {
items.retain(|item| !item.is_null());
}
if map.contains_key("snapshot_id") && map.contains_key("owner") && map.contains_key("id") {
if !map.contains_key("tracks") {
if let Some(items_obj) = map.get("items").cloned() {
map.insert("tracks".to_string(), items_obj);
} else {
map.insert("tracks".to_string(), json!({ "href": "", "total": 0 }));
}
}
}
if map.contains_key("added_at") && !map.contains_key("track") {
if let Some(item_obj) = map.get("item").cloned() {
map.insert("track".to_string(), item_obj);
}
}
if map.contains_key("album")
&& map.contains_key("artists")
&& map.contains_key("track_number")
&& map.contains_key("duration_ms")
{
map
.entry("available_markets".to_string())
.or_insert_with(|| json!([]));
map
.entry("external_ids".to_string())
.or_insert_with(|| json!({}));
map.entry("linked_from".to_string()).or_insert(Value::Null);
map
.entry("popularity".to_string())
.or_insert_with(|| json!(0));
}
if map.contains_key("media_type")
&& map.contains_key("languages")
&& map.contains_key("description")
&& map.contains_key("name")
{
map
.entry("available_markets".to_string())
.or_insert_with(|| json!([]));
map
.entry("publisher".to_string())
.or_insert_with(|| json!(""));
}
if map.contains_key("album_type")
&& map.contains_key("artists")
&& map.contains_key("images")
&& map.contains_key("name")
{
if map.contains_key("tracks") {
map
.entry("available_markets".to_string())
.or_insert(Value::Null);
map
.entry("external_ids".to_string())
.or_insert_with(|| json!({}));
map
.entry("popularity".to_string())
.or_insert_with(|| json!(0));
map.entry("label".to_string()).or_insert(Value::Null);
} else {
map
.entry("available_markets".to_string())
.or_insert_with(|| json!([]));
}
}
let looks_like_artist = map
.get("type")
.and_then(Value::as_str)
.is_some_and(|t| t == "artist")
|| (map.contains_key("external_urls")
&& map.contains_key("name")
&& map.contains_key("id")
&& (map.contains_key("genres") || map.contains_key("images")));
if looks_like_artist {
map.entry("href".to_string()).or_insert_with(|| json!(""));
map.entry("genres".to_string()).or_insert_with(|| json!([]));
map.entry("images".to_string()).or_insert_with(|| json!([]));
map
.entry("followers".to_string())
.or_insert_with(|| json!({ "href": null, "total": 0 }));
map
.entry("popularity".to_string())
.or_insert_with(|| json!(0));
}
for child in map.values_mut() {
Self::normalize_spotify_payload(child);
}
}
Value::Array(values) => {
values.retain(|item| !item.is_null());
for child in values.iter_mut() {
Self::normalize_spotify_payload(child);
}
}
_ => {}
}
}
async fn spotify_get_typed_compat_for<T: DeserializeOwned>(
spotify: &AuthCodePkceSpotify,
path: &str,
query: &[(&str, String)],
) -> anyhow::Result<T> {
let mut value =
Self::spotify_api_request_json_for(spotify, Method::GET, path, query, None).await?;
Self::normalize_spotify_payload(&mut value);
Ok(serde_json::from_value(value)?)
}
async fn spotify_get_typed_compat<T: DeserializeOwned>(
&self,
path: &str,
query: &[(&str, String)],
) -> anyhow::Result<T> {
Self::spotify_get_typed_compat_for(&self.spotify, path, query).await
}
async fn library_contains_uris(&self, uris: &[String]) -> anyhow::Result<Vec<bool>> {
Self::spotify_get_typed_compat_for(
&self.spotify,
"me/library/contains",
&[("uris", uris.join(","))],
)
.await
}
async fn library_save_uris(&self, uris: &[String]) -> anyhow::Result<()> {
Self::spotify_api_request_json_for(
&self.spotify,
Method::PUT,
"me/library",
&[],
Some(json!({ "uris": uris })),
)
.await?;
Ok(())
}
async fn library_remove_uris(&self, uris: &[String]) -> anyhow::Result<()> {
Self::spotify_api_request_json_for(
&self.spotify,
Method::DELETE,
"me/library",
&[],
Some(json!({ "uris": uris })),
)
.await?;
Ok(())
}
async fn get_user(&mut self) {
match self.spotify.me().await {
Ok(user) => {
let mut app = self.app.lock().await;
app.user = Some(user);
}
Err(e) => {
let err = anyhow!(e);
if Self::is_rate_limited_error(&err) {
self
.show_status_message(
"Spotify rate limit hit while loading profile. Retrying automatically.".to_string(),
6,
)
.await;
return;
}
self.handle_error(err).await;
}
}
}
async fn get_devices(&mut self) {
if let Ok(devices_vec) = self.spotify.device().await {
let mut app = self.app.lock().await;
app.push_navigation_stack(RouteId::SelectedDevice, ActiveBlock::SelectDevice);
if !devices_vec.is_empty() {
let result = rspotify::model::device::DevicePayload {
devices: devices_vec,
};
app.devices = Some(result);
app.selected_device_index = Some(0);
}
}
}
async fn get_current_playback(&mut self) {
#[cfg(feature = "streaming")]
let local_state: Option<(Option<u8>, bool, rspotify::model::RepeatState, Option<bool>)> =
if self.is_native_streaming_active() {
let app = self.app.lock().await;
if let Some(ref ctx) = app.current_playback_context {
let volume = self.streaming_player.as_ref().map(|p| p.get_volume());
Some((
volume,
ctx.shuffle_state,
ctx.repeat_state,
app.native_is_playing,
))
} else {
None
}
} else {
None
};
let context = self
.spotify_get_typed_compat::<Option<rspotify::model::CurrentPlaybackContext>>(
"me/player",
&[("additional_types", "episode,track".to_string())],
)
.await;
let mut app = self.app.lock().await;
match context {
#[allow(unused_mut)]
Ok(Some(mut c)) => {
app.instant_since_last_current_playback_poll = Instant::now();
#[cfg(feature = "streaming")]
let is_native_device = self.streaming_player.as_ref().is_some_and(|p| {
if let (Some(current_id), Some(native_id)) =
(c.device.id.as_ref(), app.native_device_id.as_ref())
{
return current_id == native_id;
}
let native_name = p.device_name().to_lowercase();
c.device.name.to_lowercase() == native_name
});
#[cfg(feature = "streaming")]
if is_native_device && app.native_device_id.is_none() {
if let Some(id) = c.device.id.clone() {
app.native_device_id = Some(id);
}
}
if let Some(ref item) = c.item {
match item {
PlayableItem::Track(track) => {
if let Some(ref track_id) = track.id {
let track_id_str = track_id.id().to_string();
if app.last_track_id.as_ref() != Some(&track_id_str) {
if app.user_config.behavior.enable_global_song_count {
app.dispatch(IoEvent::IncrementGlobalSongCount);
}
let duration_secs = track.duration.num_seconds() as f64;
app.dispatch(IoEvent::GetLyrics(
track.name.clone(),
create_artist_string(&track.artists),
duration_secs,
));
app.dispatch(IoEvent::CurrentUserSavedTracksContains(vec![track_id
.clone()
.into_static()]));
}
app.last_track_id = Some(track_id_str);
};
}
PlayableItem::Episode(_episode) => { }
}
};
#[cfg(feature = "streaming")]
if is_native_device {
if let Some((volume, shuffle, repeat, native_is_playing)) = local_state {
if let Some(vol) = volume {
c.device.volume_percent = Some(vol.into());
}
c.shuffle_state = shuffle;
c.repeat_state = repeat;
if let Some(is_playing) = native_is_playing {
c.is_playing = is_playing;
}
}
}
#[cfg(feature = "streaming")]
if local_state.is_none() && is_native_device {
c.shuffle_state = app.user_config.behavior.shuffle_enabled;
if let Some(ref player) = self.streaming_player {
let _ = player.set_shuffle(app.user_config.behavior.shuffle_enabled);
}
}
app.current_playback_context = Some(c);
#[cfg(feature = "streaming")]
{
app.is_streaming_active = is_native_device;
if is_native_device {
app.native_activation_pending = false;
}
}
if let Some(ref native_info) = app.native_track_info {
if let Some(ref ctx) = app.current_playback_context {
if let Some(ref item) = ctx.item {
let api_track_name = match item {
PlayableItem::Track(t) => &t.name,
PlayableItem::Episode(e) => &e.name,
};
if api_track_name == &native_info.name {
app.native_track_info = None;
}
}
}
} else {
app.native_track_info = None;
}
}
Ok(None) => {
app.instant_since_last_current_playback_poll = Instant::now();
}
Err(e) => {
app.is_fetching_current_playback = false;
let err = anyhow!(e);
if Self::is_rate_limited_error(&err) {
app.status_message = Some(
"Spotify rate limit hit. Retrying automatically; please wait a few seconds."
.to_string(),
);
app.status_message_expires_at = Some(Instant::now() + Duration::from_secs(6));
app.instant_since_last_current_playback_poll = Instant::now();
return;
}
if Self::is_transient_network_error(&err) {
app.status_message = Some(
"Temporary Spotify network error while polling playback; retrying automatically."
.to_string(),
);
app.status_message_expires_at = Some(Instant::now() + Duration::from_secs(5));
app.instant_since_last_current_playback_poll = Instant::now();
return;
}
drop(app); self.handle_error(err).await;
return;
}
}
app.seek_ms.take();
app.is_fetching_current_playback = false;
}
async fn current_user_saved_tracks_contains(&mut self, ids: Vec<TrackId<'_>>) {
let uris: Vec<String> = ids
.iter()
.map(|id| format!("spotify:track:{}", id.id()))
.collect();
match self.library_contains_uris(&uris).await {
Ok(is_saved_vec) => {
let mut app = self.app.lock().await;
for (i, id) in ids.iter().enumerate() {
if let Some(is_liked) = is_saved_vec.get(i) {
if *is_liked {
app.liked_song_ids_set.insert(id.id().to_string());
} else {
if app.liked_song_ids_set.contains(id.id()) {
app.liked_song_ids_set.remove(id.id());
}
}
};
}
}
Err(e) => {
let mut app = self.app.lock().await;
app.status_message = Some(format!("Could not check liked track state: {}", e));
app.status_message_expires_at = Some(Instant::now() + Duration::from_secs(5));
}
}
}
async fn get_playlist_tracks(&mut self, playlist_id: PlaylistId<'_>, playlist_offset: u32) {
let path = format!("playlists/{}/items", playlist_id.id());
match self
.spotify_get_typed_compat::<Page<PlaylistItem>>(
&path,
&[
("limit", self.large_search_limit.to_string()),
("offset", playlist_offset.to_string()),
],
)
.await
{
Ok(playlist_tracks) => {
self.set_playlist_tracks_to_table(&playlist_tracks).await;
let mut app = self.app.lock().await;
app.playlist_tracks = Some(playlist_tracks);
app.push_navigation_stack(RouteId::TrackTable, ActiveBlock::TrackTable);
}
Err(e) => {
self.handle_error(anyhow!(e)).await;
}
}
}
async fn set_playlist_tracks_to_table(&mut self, playlist_track_page: &Page<PlaylistItem>) {
let mut tracks: Vec<FullTrack> = Vec::new();
let mut positions: Vec<usize> = Vec::new();
for (idx, item) in playlist_track_page.items.iter().enumerate() {
if let Some(PlayableItem::Track(full_track)) = item.track.as_ref() {
tracks.push(full_track.clone());
positions.push(playlist_track_page.offset as usize + idx);
}
}
self.set_tracks_to_table(tracks).await;
let mut app = self.app.lock().await;
app.playlist_track_positions = Some(positions);
}
async fn set_tracks_to_table(&mut self, tracks: Vec<FullTrack>) {
let track_ids: Vec<TrackId<'static>> = tracks
.iter()
.filter_map(|item| item.id.as_ref().map(|id| id.clone().into_static()))
.collect();
let mut app = self.app.lock().await;
app.playlist_track_positions = None;
let track_count = tracks.len();
if track_count > 0 {
if let Some(pending) = app.pending_track_table_selection.take() {
app.track_table.selected_index = match pending {
crate::app::PendingTrackSelection::First => 0,
crate::app::PendingTrackSelection::Last => track_count.saturating_sub(1),
};
} else {
let max_index = track_count.saturating_sub(1);
if app.track_table.selected_index > max_index {
app.track_table.selected_index = max_index;
}
}
} else {
app.track_table.selected_index = 0;
}
app.track_table.tracks = tracks;
app.dispatch(IoEvent::CurrentUserSavedTracksContains(track_ids));
}
async fn set_artists_to_table(&mut self, artists: Vec<FullArtist>) {
let mut app = self.app.lock().await;
app.artists = artists;
}
async fn get_current_user_saved_shows(&mut self, offset: Option<u32>) {
let mut query = vec![("limit", self.large_search_limit.to_string())];
if let Some(offset) = offset {
query.push(("offset", offset.to_string()));
}
match self
.spotify_get_typed_compat::<Page<rspotify::model::show::Show>>("me/shows", &query)
.await
{
Ok(saved_shows) => {
if !saved_shows.items.is_empty() {
let mut app = self.app.lock().await;
app.library.saved_shows.add_pages(saved_shows);
}
}
Err(e) => {
self.handle_error(anyhow!(e)).await;
}
}
}
async fn current_user_saved_shows_contains(&mut self, show_ids: Vec<ShowId<'_>>) {
let uris: Vec<String> = show_ids
.iter()
.map(|id| format!("spotify:show:{}", id.id()))
.collect();
match self.library_contains_uris(&uris).await {
Ok(is_saved_vec) => {
let mut app = self.app.lock().await;
for (i, id) in show_ids.iter().enumerate() {
if let Some(is_saved) = is_saved_vec.get(i) {
if *is_saved {
app.saved_show_ids_set.insert(id.id().to_string());
} else {
if app.saved_show_ids_set.contains(id.id()) {
app.saved_show_ids_set.remove(id.id());
}
}
};
}
}
Err(e) => {
self.handle_error(anyhow!(e)).await;
}
}
}
async fn get_show_episodes(&mut self, show: Box<SimplifiedShow>) {
let show_id = show.id.clone();
let path = format!("shows/{}/episodes", show_id.id());
let query = vec![
("limit", self.large_search_limit.to_string()),
("offset", "0".to_string()),
];
match self
.spotify_get_typed_compat::<Page<rspotify::model::show::SimplifiedEpisode>>(&path, &query)
.await
{
Ok(episodes) => {
if !episodes.items.is_empty() {
let mut app = self.app.lock().await;
app.library.show_episodes = ScrollableResultPages::new();
app.library.show_episodes.add_pages(episodes);
app.selected_show_simplified = Some(SelectedShow { show: *show });
app.episode_table_context = EpisodeTableContext::Simplified;
app.push_navigation_stack(RouteId::PodcastEpisodes, ActiveBlock::EpisodeTable);
}
}
Err(e) => {
self.handle_error(anyhow!(e)).await;
}
}
}
async fn get_show(&mut self, show_id: ShowId<'_>) {
let path = format!("shows/{}", show_id.id());
match self
.spotify_get_typed_compat::<rspotify::model::show::FullShow>(&path, &[])
.await
{
Ok(show) => {
let selected_show = SelectedFullShow { show };
let mut app = self.app.lock().await;
app.selected_show_full = Some(selected_show);
app.episode_table_context = EpisodeTableContext::Full;
app.push_navigation_stack(RouteId::PodcastEpisodes, ActiveBlock::EpisodeTable);
}
Err(e) => {
self.handle_error(anyhow!(e)).await;
}
}
}
async fn get_current_show_episodes(&mut self, show_id: ShowId<'_>, offset: Option<u32>) {
let path = format!("shows/{}/episodes", show_id.id());
let mut query = vec![("limit", self.large_search_limit.to_string())];
if let Some(offset) = offset {
query.push(("offset", offset.to_string()));
}
match self
.spotify_get_typed_compat::<Page<rspotify::model::show::SimplifiedEpisode>>(&path, &query)
.await
{
Ok(episodes) => {
if !episodes.items.is_empty() {
let mut app = self.app.lock().await;
app.library.show_episodes.add_pages(episodes);
}
}
Err(e) => {
self.handle_error(anyhow!(e)).await;
}
}
}
async fn get_search_results(&mut self, search_term: String, country: Option<Country>) {
let _market = country.map(Market::Country);
let search_track = self.spotify.search(
&search_term,
SearchType::Track,
None,
None, Some(self.small_search_limit),
Some(0),
);
let search_album = self.spotify.search(
&search_term,
SearchType::Album,
None,
None, Some(self.small_search_limit),
Some(0),
);
let search_playlist = self.spotify.search(
&search_term,
SearchType::Playlist,
None,
None, Some(self.small_search_limit),
Some(0),
);
let search_show = self.spotify.search(
&search_term,
SearchType::Show,
None,
None, Some(self.small_search_limit),
Some(0),
);
let artist_query = vec![
("q", search_term.clone()),
("type", "artist".to_string()),
("limit", self.small_search_limit.to_string()),
("offset", "0".to_string()),
];
let (main_search, playlist_search, artist_search) = tokio::join!(
async { try_join!(search_track, search_album, search_show) },
search_playlist,
self.spotify_get_typed_compat::<ArtistSearchResponse>("search", &artist_query)
);
let (track_result, album_result, show_result) = match main_search {
Ok((
SearchResult::Tracks(tracks),
SearchResult::Albums(albums),
SearchResult::Shows(shows),
)) => (Some(tracks), Some(albums), Some(shows)),
Err(e) => {
self.handle_error(anyhow!(e)).await;
return;
}
_ => return,
};
let artist_result = artist_search.ok().map(|res| res.artists);
let playlist_result = match playlist_search {
Ok(SearchResult::Playlists(playlists)) => Some(playlists),
Err(_) => None,
_ => None,
};
let mut app = self.app.lock().await;
if let Some(ref album_results) = album_result {
let artist_ids = album_results
.items
.iter()
.filter_map(|item| {
item
.id
.as_ref()
.map(|id| ArtistId::from_id(id.id()).unwrap().into_static())
})
.collect();
app.dispatch(IoEvent::UserArtistFollowCheck(artist_ids));
let album_ids = album_results
.items
.iter()
.filter_map(|album| {
album
.id
.as_ref()
.map(|id| AlbumId::from_id(id.id()).unwrap().into_static())
})
.collect();
app.dispatch(IoEvent::CurrentUserSavedAlbumsContains(album_ids));
}
if let Some(ref show_results) = show_result {
let show_ids = show_results
.items
.iter()
.map(|show| show.id.clone().into_static())
.collect();
app.dispatch(IoEvent::CurrentUserSavedShowsContains(show_ids));
}
app.search_results.tracks = track_result;
app.search_results.artists = artist_result;
app.search_results.albums = album_result;
app.search_results.playlists = playlist_result;
app.search_results.shows = show_result;
}
async fn get_current_user_saved_tracks(&mut self, offset: Option<u32>) {
let mut query = vec![("limit", self.large_search_limit.to_string())];
if let Some(offset) = offset {
query.push(("offset", offset.to_string()));
}
match self
.spotify_get_typed_compat::<Page<rspotify::model::SavedTrack>>("me/tracks", &query)
.await
{
Ok(saved_tracks) => {
let mut app = self.app.lock().await;
app.track_table.tracks = saved_tracks
.items
.clone()
.into_iter()
.map(|item| item.track)
.collect::<Vec<FullTrack>>();
saved_tracks.items.iter().for_each(|item| {
if let Some(track_id) = &item.track.id {
app.liked_song_ids_set.insert(track_id.to_string());
}
});
let track_count = app.track_table.tracks.len();
if track_count > 0 {
if let Some(pending) = app.pending_track_table_selection.take() {
app.track_table.selected_index = match pending {
crate::app::PendingTrackSelection::First => 0,
crate::app::PendingTrackSelection::Last => track_count.saturating_sub(1),
};
}
}
app.library.saved_tracks.add_pages(saved_tracks);
app.track_table.context = Some(TrackTableContext::SavedTracks);
}
Err(e) => {
self.handle_error(anyhow!(e)).await;
}
}
}
async fn start_playback(
&mut self,
context_id: Option<PlayContextId<'_>>,
uris: Option<Vec<PlayableId<'_>>>,
offset: Option<usize>,
) {
#[cfg(feature = "streaming")]
if self.is_native_streaming_active_for_playback().await {
if let Some(ref player) = self.streaming_player {
let activation_time = Instant::now();
let should_transfer = {
let app = self.app.lock().await;
let activation_pending = app.native_activation_pending;
let recent_activation = app
.last_device_activation
.is_some_and(|instant| instant.elapsed() < Duration::from_secs(5));
if activation_pending {
!recent_activation
} else {
!app.is_streaming_active && !recent_activation
}
};
if should_transfer {
let _ = player.transfer(None);
}
player.activate();
{
let mut app = self.app.lock().await;
app.is_streaming_active = true;
app.last_device_activation = Some(activation_time);
app.native_activation_pending = false;
}
if context_id.is_none() && uris.is_none() {
player.play();
let mut app = self.app.lock().await;
if let Some(ctx) = &mut app.current_playback_context {
ctx.is_playing = true;
}
return;
}
let mut options = LoadRequestOptions {
start_playing: true,
seek_to: 0,
context_options: None,
playing_track: None,
};
let request = match (context_id, uris) {
(Some(context), Some(track_uris)) => {
if let Some(first_uri) = track_uris.first() {
options.playing_track = Some(PlayingTrack::Uri(first_uri.uri()));
} else if let Some(i) = offset.and_then(|i| u32::try_from(i).ok()) {
options.playing_track = Some(PlayingTrack::Index(i));
}
LoadRequest::from_context_uri(context.uri(), options)
}
(Some(context), None) => {
if let Some(i) = offset.and_then(|i| u32::try_from(i).ok()) {
options.playing_track = Some(PlayingTrack::Index(i));
}
LoadRequest::from_context_uri(context.uri(), options)
}
(None, Some(track_uris)) => {
if let Some(i) = offset.and_then(|i| u32::try_from(i).ok()) {
options.playing_track = Some(PlayingTrack::Index(i));
}
let uris = track_uris.into_iter().map(|u| u.uri()).collect::<Vec<_>>();
LoadRequest::from_tracks(uris, options)
}
(None, None) => {
player.play();
let mut app = self.app.lock().await;
if let Some(ctx) = &mut app.current_playback_context {
ctx.is_playing = true;
}
return;
}
};
match player.load(request) {
Ok(()) => {
player.play();
let mut app = self.app.lock().await;
app.song_progress_ms = 0;
app.native_is_playing = Some(true);
if let Some(ctx) = &mut app.current_playback_context {
ctx.is_playing = true;
}
}
Err(e) => {
self.handle_error(e).await;
}
}
return;
}
}
let has_playback_context = {
let app = self.app.lock().await;
app.current_playback_context.is_some()
};
let device_id = if has_playback_context {
None
} else {
self.client_config.device_id.as_deref()
};
let should_disable_shuffle = context_id.is_some() && uris.is_none() && offset.is_some();
let mut original_shuffle_state = false;
#[cfg(feature = "streaming")]
let is_native = self.is_native_streaming_active_for_playback().await;
#[cfg(not(feature = "streaming"))]
let is_native = false;
if should_disable_shuffle && !is_native {
if let Ok(Some(playback)) = self.spotify.current_playback(None, None::<Vec<_>>).await {
original_shuffle_state = playback.shuffle_state;
if original_shuffle_state {
let _ = self.spotify.shuffle(false, device_id).await;
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
}
}
}
let has_both = context_id.is_some() && uris.is_some();
let is_resume = context_id.is_none() && uris.is_none();
let result = if has_both {
let context = context_id.unwrap();
let track_uris = uris.unwrap();
if let Some(first_uri) = track_uris.first() {
let offset = rspotify::model::Offset::Uri(first_uri.uri());
self
.spotify
.start_context_playback(context, device_id, Some(offset), None)
.await
} else {
self
.spotify
.start_context_playback(context, device_id, None, None)
.await
}
} else if let Some(context_id) = context_id {
let offset = offset
.and_then(|i| i64::try_from(i).ok())
.map(|i| rspotify::model::Offset::Position(chrono::Duration::milliseconds(i)));
self
.spotify
.start_context_playback(context_id, device_id, offset, None)
.await
} else if let Some(mut uris) = uris {
if let Some(offset_pos) = offset {
if offset_pos < uris.len() && offset_pos > 0 {
let selected = uris.remove(offset_pos);
uris.insert(0, selected);
}
}
self
.spotify
.start_uris_playback(uris, device_id, None, None)
.await
} else {
#[cfg(feature = "streaming")]
if self.is_native_streaming_active_for_playback().await {
if let Some(ref player) = self.streaming_player {
player.play();
let mut app = self.app.lock().await;
if let Some(ctx) = &mut app.current_playback_context {
ctx.is_playing = true;
}
return;
}
}
self.spotify.resume_playback(device_id, None).await
};
match result {
Ok(()) => {
if should_disable_shuffle && original_shuffle_state && !is_native {
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
let _ = self.spotify.shuffle(true, device_id).await;
}
{
let mut app = self.app.lock().await;
if !is_resume {
app.song_progress_ms = 0;
}
if let Some(ctx) = &mut app.current_playback_context {
ctx.is_playing = true;
}
}
tokio::time::sleep(tokio::time::Duration::from_millis(300)).await;
self.get_current_playback().await;
}
Err(e) => {
#[cfg(feature = "streaming")]
if is_native {
if let Some(ref player) = self.streaming_player {
player.activate();
player.play();
{
let mut app = self.app.lock().await;
app.song_progress_ms = 0;
if let Some(ctx) = &mut app.current_playback_context {
ctx.is_playing = true;
}
}
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
self.get_current_playback().await;
return;
}
}
if should_disable_shuffle && original_shuffle_state {
let _ = self.spotify.shuffle(true, device_id).await;
}
self.handle_error(anyhow!(e)).await;
}
}
}
async fn start_collection_playback(&mut self, offset: usize) {
let user_id = {
let app = self.app.lock().await;
app.user.as_ref().map(|u| u.id.to_string())
};
let user_id = match user_id {
Some(id) => id,
None => {
self.handle_error(anyhow!("User not logged in")).await;
return;
}
};
let token = {
let token_lock = self
.spotify
.token
.lock()
.await
.expect("Failed to lock token");
token_lock.as_ref().map(|t| t.access_token.clone())
};
let access_token = match token {
Some(t) => t,
None => {
self
.handle_error(anyhow!("No access token available"))
.await;
return;
}
};
let context_uri = format!("spotify:user:{}:collection", user_id);
let mut body = serde_json::json!({
"context_uri": context_uri,
"offset": { "position": offset }
});
if let Some(ref device_id) = self.client_config.device_id {
body["device_id"] = serde_json::json!(device_id);
}
let client = reqwest::Client::new();
let url = match self.client_config.device_id.as_ref() {
Some(device_id) => format!(
"https://api.spotify.com/v1/me/player/play?device_id={}",
device_id
),
None => "https://api.spotify.com/v1/me/player/play".to_string(),
};
let result = client
.put(&url)
.header("Authorization", format!("Bearer {}", access_token))
.header("Content-Type", "application/json")
.json(&body)
.send()
.await;
match result {
Ok(response) => {
if response.status().is_success() {
{
let mut app = self.app.lock().await;
app.song_progress_ms = 0;
if let Some(ctx) = &mut app.current_playback_context {
ctx.is_playing = true;
}
}
tokio::time::sleep(tokio::time::Duration::from_millis(300)).await;
self.get_current_playback().await;
} else {
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
self
.handle_error(anyhow!(
"Failed to start collection playback: {}",
error_text
))
.await;
}
}
Err(e) => {
self
.handle_error(anyhow!("HTTP request failed: {}", e))
.await;
}
}
}
async fn prefetch_all_saved_tracks_task(
spotify: AuthCodePkceSpotify,
app: Arc<Mutex<App>>,
large_search_limit: u32,
) {
let (current_total, pages_loaded) = {
let app = app.lock().await;
if let Some(saved_tracks) = app.library.saved_tracks.get_results(Some(0)) {
(
saved_tracks.total,
app.library.saved_tracks.pages.len() as u32,
)
} else {
return; }
};
let tracks_loaded = pages_loaded * large_search_limit;
let max_tracks_to_prefetch = 500; let mut offset = tracks_loaded;
while offset < current_total && offset < tracks_loaded + max_tracks_to_prefetch {
let query = vec![
("limit", large_search_limit.to_string()),
("offset", offset.to_string()),
];
match Self::spotify_get_typed_compat_for::<Page<rspotify::model::SavedTrack>>(
&spotify,
"me/tracks",
&query,
)
.await
{
Ok(saved_tracks) => {
{
let mut app = app.lock().await;
saved_tracks.items.iter().for_each(|item| {
if let Some(track_id) = &item.track.id {
app.liked_song_ids_set.insert(track_id.to_string());
}
});
app.library.saved_tracks.pages.push(saved_tracks);
}
tokio::task::yield_now().await;
}
Err(_e) => {
break;
}
}
offset += large_search_limit;
}
}
async fn prefetch_all_playlist_tracks_task(
spotify: AuthCodePkceSpotify,
app: Arc<Mutex<App>>,
large_search_limit: u32,
playlist_id: PlaylistId<'static>,
) {
let current_total = {
let app = app.lock().await;
if let Some(playlist_tracks) = &app.playlist_tracks {
playlist_tracks.total
} else {
return;
}
};
let current_offset = {
let app = app.lock().await;
app.playlist_offset
};
let max_tracks_to_prefetch = 500; let mut offset = current_offset + large_search_limit;
while offset < current_total && offset < current_offset + max_tracks_to_prefetch {
let path = format!("playlists/{}/items", playlist_id.id());
let query = vec![
("limit", large_search_limit.to_string()),
("offset", offset.to_string()),
];
match Self::spotify_get_typed_compat_for::<Page<PlaylistItem>>(&spotify, &path, &query).await
{
Ok(playlist_page) => {
{
let mut app = app.lock().await;
if let Some(ref mut existing) = app.playlist_tracks {
existing.items.extend(playlist_page.items);
existing.total = playlist_page.total; }
}
tokio::task::yield_now().await;
}
Err(_e) => {
break;
}
}
offset += large_search_limit;
}
}
async fn fetch_all_playlist_tracks_and_sort(&mut self, playlist_id: PlaylistId<'_>) {
use rspotify::model::PlayableItem;
let (total, sort_state) = {
let app = self.app.lock().await;
let total = app.playlist_tracks.as_ref().map(|p| p.total).unwrap_or(0);
let sort_state = app.playlist_sort;
(total, sort_state)
};
if total == 0 {
return;
}
let mut all_items_with_positions: Vec<(usize, rspotify::model::playlist::PlaylistItem)> =
Vec::new();
let mut offset = 0;
while offset < total {
let path = format!("playlists/{}/items", playlist_id.id());
let query = vec![
("limit", self.large_search_limit.to_string()),
("offset", offset.to_string()),
];
match self
.spotify_get_typed_compat::<Page<PlaylistItem>>(&path, &query)
.await
{
Ok(page) => {
for (idx, item) in page.items.into_iter().enumerate() {
all_items_with_positions.push((offset as usize + idx, item));
}
}
Err(_e) => {
break;
}
}
offset += self.large_search_limit;
}
all_items_with_positions.sort_by(|a, b| {
let track_a = a.1.track.as_ref().and_then(|t| match t {
PlayableItem::Track(track) => Some(track),
PlayableItem::Episode(_) => None,
});
let track_b = b.1.track.as_ref().and_then(|t| match t {
PlayableItem::Track(track) => Some(track),
PlayableItem::Episode(_) => None,
});
let cmp = match (track_a, track_b) {
(Some(ta), Some(tb)) => match sort_state.field {
crate::sort::SortField::Default => std::cmp::Ordering::Equal,
crate::sort::SortField::Name => ta.name.to_lowercase().cmp(&tb.name.to_lowercase()),
crate::sort::SortField::Artist => {
let artist_a = ta
.artists
.first()
.map(|ar| ar.name.to_lowercase())
.unwrap_or_default();
let artist_b = tb
.artists
.first()
.map(|ar| ar.name.to_lowercase())
.unwrap_or_default();
artist_a.cmp(&artist_b)
}
crate::sort::SortField::Album => ta
.album
.name
.to_lowercase()
.cmp(&tb.album.name.to_lowercase()),
crate::sort::SortField::Duration => ta.duration.cmp(&tb.duration),
crate::sort::SortField::DateAdded => a.1.added_at.cmp(&b.1.added_at),
},
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => std::cmp::Ordering::Equal,
};
if sort_state.order == crate::sort::SortOrder::Descending {
cmp.reverse()
} else {
cmp
}
});
let mut sorted_playlist_items: Vec<rspotify::model::playlist::PlaylistItem> = Vec::new();
let mut sorted_tracks: Vec<rspotify::model::FullTrack> = Vec::new();
let mut sorted_positions: Vec<usize> = Vec::new();
for (original_position, item) in all_items_with_positions {
if let Some(PlayableItem::Track(full_track)) = item.track.as_ref() {
sorted_tracks.push(full_track.clone());
sorted_positions.push(original_position);
}
sorted_playlist_items.push(item);
}
let mut app = self.app.lock().await;
if let Some(ref mut playlist_tracks) = app.playlist_tracks {
playlist_tracks.items = sorted_playlist_items;
playlist_tracks.total = total;
}
app.track_table.tracks = sorted_tracks;
app.track_table.selected_index = 0;
app.playlist_track_positions = Some(sorted_positions);
}
async fn seek(&mut self, position_ms: u32) {
#[cfg(feature = "streaming")]
if self.is_native_streaming_active_for_playback().await {
if let Some(ref player) = self.streaming_player {
player.seek(position_ms);
let mut app = self.app.lock().await;
app.song_progress_ms = position_ms as u128;
app.seek_ms = None;
return;
}
}
let position = TimeDelta::milliseconds(position_ms as i64);
match self.spotify.seek_track(position, None).await {
Ok(()) => {
}
Err(e) => {
self.handle_error(anyhow!(e)).await;
}
};
}
async fn next_track(&mut self) {
#[cfg(feature = "streaming")]
if self.is_native_streaming_active_for_playback().await {
if let Some(ref player) = self.streaming_player {
player.activate();
player.next();
let player = Arc::clone(player);
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(300)).await;
player.activate();
player.play();
});
let mut app = self.app.lock().await;
app.song_progress_ms = 0;
return;
}
}
let was_playing = {
let mut app = self.app.lock().await;
app.song_progress_ms = 0;
app
.current_playback_context
.as_ref()
.map(|c| c.is_playing)
.unwrap_or(false)
};
match self.spotify.next_track(None).await {
Ok(()) => {
if was_playing {
tokio::time::sleep(Duration::from_millis(100)).await;
let _ = self.spotify.resume_playback(None, None).await;
}
tokio::time::sleep(Duration::from_millis(150)).await;
self.get_current_playback().await;
}
Err(e) => {
self.handle_error(anyhow!(e)).await;
}
};
}
async fn previous_track(&mut self) {
#[cfg(feature = "streaming")]
if self.is_native_streaming_active_for_playback().await {
if let Some(ref player) = self.streaming_player {
player.activate();
player.prev();
let player = Arc::clone(player);
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(300)).await;
player.activate();
player.play();
});
let mut app = self.app.lock().await;
app.song_progress_ms = 0;
return;
}
}
let was_playing = {
let mut app = self.app.lock().await;
app.song_progress_ms = 0;
app
.current_playback_context
.as_ref()
.map(|c| c.is_playing)
.unwrap_or(false)
};
match self.spotify.previous_track(None).await {
Ok(()) => {
if was_playing {
tokio::time::sleep(Duration::from_millis(100)).await;
let _ = self.spotify.resume_playback(None, None).await;
}
tokio::time::sleep(Duration::from_millis(150)).await;
self.get_current_playback().await;
}
Err(e) => {
self.handle_error(anyhow!(e)).await;
}
};
}
async fn shuffle(&mut self, desired_shuffle_state: bool) {
let new_shuffle_state = desired_shuffle_state;
let is_startup_sync = {
let app = self.app.lock().await;
app.current_playback_context.is_none()
};
#[cfg(feature = "streaming")]
if self.is_native_streaming_active_for_playback().await {
if let Some(ref player) = self.streaming_player {
let shuffle_result = player.set_shuffle(new_shuffle_state);
{
let mut app = self.app.lock().await;
if let Some(ctx) = &mut app.current_playback_context {
ctx.shuffle_state = new_shuffle_state;
}
app.user_config.behavior.shuffle_enabled = new_shuffle_state;
let _ = app.user_config.save_config();
}
if let Err(_e) = shuffle_result {
}
return;
}
}
{
let mut app = self.app.lock().await;
if let Some(ctx) = &mut app.current_playback_context {
ctx.shuffle_state = new_shuffle_state;
}
app.user_config.behavior.shuffle_enabled = new_shuffle_state;
let _ = app.user_config.save_config();
}
if let Err(e) = self.spotify.shuffle(new_shuffle_state, None).await {
let err = anyhow!(e);
if Self::is_rate_limited_error(&err) {
self
.show_status_message(
"Spotify rate limit hit while syncing shuffle. It will sync shortly.".to_string(),
5,
)
.await;
return;
}
if is_startup_sync {
if let Some(rspotify::ClientError::Http(http)) = err.downcast_ref::<rspotify::ClientError>()
{
if let rspotify::http::HttpError::StatusCode(response) = http.as_ref() {
if response.status().as_u16() == 404 {
let mut app = self.app.lock().await;
app.user_config.behavior.shuffle_enabled = new_shuffle_state;
let _ = app.user_config.save_config();
return;
}
}
}
}
self.handle_error(err).await;
}
}
async fn repeat(&mut self, repeat_state: RepeatState) {
let next_repeat_state = match repeat_state {
RepeatState::Off => RepeatState::Context,
RepeatState::Context => RepeatState::Track,
RepeatState::Track => RepeatState::Off,
};
#[cfg(feature = "streaming")]
if self.is_native_streaming_active_for_playback().await {
if let Some(ref player) = self.streaming_player {
if let Err(e) = player.set_repeat(repeat_state) {
self.handle_error(anyhow!(e)).await;
}
}
let mut app = self.app.lock().await;
if let Some(ctx) = &mut app.current_playback_context {
ctx.repeat_state = next_repeat_state;
}
return;
}
{
let mut app = self.app.lock().await;
if let Some(ctx) = &mut app.current_playback_context {
ctx.repeat_state = next_repeat_state;
}
}
if let Err(e) = self.spotify.repeat(next_repeat_state, None).await {
{
let mut app = self.app.lock().await;
if let Some(ctx) = &mut app.current_playback_context {
ctx.repeat_state = repeat_state; }
}
self.handle_error(anyhow!(e)).await;
}
}
async fn pause_playback(&mut self) {
#[cfg(feature = "streaming")]
if self.is_native_streaming_active_for_playback().await {
if let Some(ref player) = self.streaming_player {
player.pause();
let mut app = self.app.lock().await;
if let Some(ctx) = &mut app.current_playback_context {
ctx.is_playing = false;
}
return;
}
}
match self.spotify.pause_playback(None).await {
Ok(()) => {
let mut app = self.app.lock().await;
if let Some(ctx) = &mut app.current_playback_context {
ctx.is_playing = false;
}
}
Err(e) => {
self.handle_error(anyhow!(e)).await;
}
};
}
async fn ensure_playback_continues(&mut self, previous_track_id: String) {
tokio::time::sleep(Duration::from_millis(250)).await;
self.get_current_playback().await;
let (current_track_id, is_playing) = {
let app = self.app.lock().await;
let current_track_id = app
.current_playback_context
.as_ref()
.and_then(|ctx| ctx.item.as_ref())
.and_then(|item| match item {
PlayableItem::Track(track) => track.id.as_ref().map(|id| id.id().to_string()),
_ => None,
});
let is_playing = app
.current_playback_context
.as_ref()
.map(|ctx| ctx.is_playing)
.unwrap_or(false);
(current_track_id, is_playing)
};
let Some(current_id) = current_track_id else {
return;
};
let current_uri = format!("spotify:track:{current_id}");
let is_new_track = previous_track_id != current_id && previous_track_id != current_uri;
let should_resume = is_new_track && !is_playing;
if should_resume {
self.start_playback(None, None, None).await;
self.get_current_playback().await;
}
}
async fn change_volume(&mut self, volume_percent: u8) {
#[cfg(feature = "streaming")]
if self.is_native_streaming_active_for_playback().await {
if let Some(ref player) = self.streaming_player {
player.set_volume(volume_percent);
let mut app = self.app.lock().await;
if let Some(ctx) = &mut app.current_playback_context {
ctx.device.volume_percent = Some(volume_percent.into());
}
app.user_config.behavior.volume_percent = volume_percent;
let _ = app.user_config.save_config();
return;
}
}
{
let mut app = self.app.lock().await;
if let Some(ctx) = &mut app.current_playback_context {
ctx.device.volume_percent = Some(volume_percent.into());
}
app.user_config.behavior.volume_percent = volume_percent;
let _ = app.user_config.save_config();
}
if let Err(e) = self.spotify.volume(volume_percent, None).await {
self.handle_error(anyhow!(e)).await;
}
}
async fn get_artist(
&mut self,
artist_id: ArtistId<'_>,
input_artist_name: String,
country: Option<Country>,
) {
let _market = country.map(Market::Country);
let albums = self.spotify.artist_albums_manual(
artist_id.clone(),
None,
None,
Some(self.large_search_limit),
Some(0),
);
let artist_name = if input_artist_name.is_empty() {
self
.spotify
.artist(artist_id.clone())
.await
.map(|full_artist| full_artist.name)
.unwrap_or_default()
} else {
input_artist_name
};
match albums.await {
Ok(albums) => {
let top_tracks = self
.spotify
.artist_top_tracks(artist_id.clone(), None)
.await
.unwrap_or_default();
#[allow(deprecated)]
let related_artist = self
.spotify
.artist_related_artists(artist_id.clone())
.await
.unwrap_or_else(|_| Vec::new());
let mut app = self.app.lock().await;
app.dispatch(IoEvent::CurrentUserSavedAlbumsContains(
albums
.items
.iter()
.filter_map(|item| {
item
.id
.as_ref()
.map(|id| AlbumId::from_id(id.id()).unwrap().into_static())
})
.collect(),
));
app.artist = Some(Artist {
artist_name,
albums,
related_artists: related_artist,
top_tracks,
selected_album_index: 0,
selected_related_artist_index: 0,
selected_top_track_index: 0,
artist_hovered_block: ArtistBlock::TopTracks,
artist_selected_block: ArtistBlock::Empty,
});
app.push_navigation_stack(RouteId::Artist, ActiveBlock::ArtistBlock);
}
Err(e) => {
eprintln!("DEBUG: Error fetching artist: {:?}", e);
self
.handle_error(anyhow!("Failed to fetch artist: {}", e))
.await;
}
}
}
async fn get_album_tracks(&mut self, album: Box<SimplifiedAlbum>) {
if let Some(album_id) = &album.id {
match self
.spotify
.album_track_manual(
album_id.clone(),
None,
Some(self.large_search_limit),
Some(0),
)
.await
{
Ok(tracks) => {
let track_ids = tracks
.items
.iter()
.filter_map(|item| {
item
.id
.as_ref()
.map(|id| TrackId::from_id(id.id()).unwrap().into_static())
})
.collect::<Vec<TrackId<'static>>>();
let mut app = self.app.lock().await;
app.selected_album_simplified = Some(SelectedAlbum {
album: *album,
tracks,
selected_index: 0,
});
app.album_table_context = AlbumTableContext::Simplified;
app.push_navigation_stack(RouteId::AlbumTracks, ActiveBlock::AlbumTracks);
app.dispatch(IoEvent::CurrentUserSavedTracksContains(track_ids));
}
Err(e) => {
self.handle_error(anyhow!(e)).await;
}
}
}
}
async fn get_recommendations_for_seed(
&mut self,
seed_artists: Option<Vec<ArtistId<'static>>>,
seed_tracks: Option<Vec<TrackId<'static>>>,
first_track: Box<Option<FullTrack>>,
country: Option<Country>,
) {
let market = country.map(Market::Country);
let seed_genres: Option<Vec<&str>> = None;
match self
.spotify
.recommendations(
[], seed_artists, seed_genres, seed_tracks, market, Some(self.large_search_limit), )
.await
{
Ok(result) => {
if let Some(mut recommended_tracks) = self.extract_recommended_tracks(&result).await {
if let Some(track) = *first_track {
recommended_tracks.insert(0, track);
}
let track_ids = recommended_tracks
.iter()
.filter_map(|x| {
x.id
.as_ref()
.map(|id| PlayableId::Track(id.clone().into_static()))
})
.collect::<Vec<PlayableId>>();
self.set_tracks_to_table(recommended_tracks.clone()).await;
let mut app = self.app.lock().await;
app.recommended_tracks = recommended_tracks;
app.track_table.context = Some(TrackTableContext::RecommendedTracks);
if app.get_current_route().id != RouteId::Recommendations {
app.push_navigation_stack(RouteId::Recommendations, ActiveBlock::TrackTable);
};
app.dispatch(IoEvent::StartPlayback(None, Some(track_ids), Some(0)));
}
}
Err(e) => {
self.handle_error(anyhow!(e)).await;
}
}
}
async fn extract_recommended_tracks(
&mut self,
recommendations: &Recommendations,
) -> Option<Vec<FullTrack>> {
let track_ids = recommendations
.tracks
.iter()
.take(10)
.filter_map(|track| track.id.clone())
.collect::<Vec<TrackId>>();
if track_ids.is_empty() {
return Some(Vec::new());
}
let mut tracks = Vec::with_capacity(track_ids.len());
for track_id in track_ids {
let path = format!("tracks/{}", track_id.id());
if let Ok(track) = self
.spotify_get_typed_compat::<rspotify::model::track::FullTrack>(&path, &[])
.await
{
tracks.push(track);
}
}
if tracks.is_empty() {
None
} else {
Some(tracks)
}
}
async fn get_recommendations_for_track_id(
&mut self,
track_id: TrackId<'_>,
country: Option<Country>,
) {
if let Ok(track) = self.spotify.track(track_id.clone(), None).await {
let track_id_list = vec![track_id.into_static()];
self
.get_recommendations_for_seed(None, Some(track_id_list), Box::new(Some(track)), country)
.await;
}
}
async fn toggle_save_track(&mut self, playable_id: PlayableId<'_>) {
match playable_id {
PlayableId::Track(track_id) => {
let uri = format!("spotify:track:{}", track_id.id());
match self.library_contains_uris(std::slice::from_ref(&uri)).await {
Ok(saved) => {
if saved.first() == Some(&true) {
match self.library_remove_uris(std::slice::from_ref(&uri)).await {
Ok(()) => {
let mut app = self.app.lock().await;
app.liked_song_ids_set.remove(track_id.id());
}
Err(e) => {
self.handle_error(anyhow!(e)).await;
}
}
} else {
match self.library_save_uris(std::slice::from_ref(&uri)).await {
Ok(()) => {
let mut app = self.app.lock().await;
app.liked_song_ids_set.insert(track_id.id().to_string());
app.liked_song_animation_frame = Some(10);
}
Err(e) => {
self.handle_error(anyhow!(e)).await;
}
}
}
}
Err(e) => {
self.handle_error(anyhow!(e)).await;
}
}
}
PlayableId::Episode(episode_id) => {
match self.spotify.get_an_episode(episode_id, None).await {
Ok(episode) => {
let show_id = episode.show.id;
match self
.spotify
.check_users_saved_shows([show_id.clone()])
.await
{
Ok(saved) => {
if saved.first() == Some(&true) {
match self
.spotify
.remove_users_saved_shows([show_id.clone()], None)
.await
{
Ok(()) => {
let mut app = self.app.lock().await;
app.saved_show_ids_set.remove(show_id.id());
}
Err(e) => {
self.handle_error(anyhow!(e)).await;
}
}
} else {
match self.spotify.save_shows([show_id.clone()]).await {
Ok(()) => {
let mut app = self.app.lock().await;
app.saved_show_ids_set.insert(show_id.id().to_string());
}
Err(e) => {
self.handle_error(anyhow!(e)).await;
}
}
}
}
Err(e) => {
self.handle_error(anyhow!(e)).await;
}
}
}
Err(e) => {
self.handle_error(anyhow!(e)).await;
}
}
}
}
}
async fn get_followed_artists(&mut self, after: Option<ArtistId<'_>>) {
let mut query = vec![
("type", "artist".to_string()),
("limit", self.large_search_limit.to_string()),
];
if let Some(after) = after.as_ref() {
query.push(("after", after.id().to_string()));
}
match self
.spotify_get_typed_compat::<rspotify::model::artist::CursorPageFullArtists>(
"me/following",
&query,
)
.await
{
Ok(saved_artists_page) => {
let saved_artists = saved_artists_page.artists;
let mut app = self.app.lock().await;
app.artists = saved_artists.items.to_owned();
app.library.saved_artists.add_pages(saved_artists);
}
Err(e) => {
self.handle_error(anyhow!(e)).await;
}
};
}
async fn user_artist_check_follow(&mut self, artist_ids: Vec<ArtistId<'_>>) {
let uris: Vec<String> = artist_ids
.iter()
.map(|id| format!("spotify:artist:{}", id.id()))
.collect();
if let Ok(are_followed) = self.library_contains_uris(&uris).await {
let mut app = self.app.lock().await;
artist_ids
.iter()
.zip(are_followed.iter())
.for_each(|(id, &is_followed)| {
if is_followed {
app.followed_artist_ids_set.insert(id.id().to_string());
} else {
app.followed_artist_ids_set.remove(id.id());
}
});
}
}
async fn get_current_user_saved_albums(&mut self, offset: Option<u32>) {
let mut query = vec![("limit", self.large_search_limit.to_string())];
if let Some(offset) = offset {
query.push(("offset", offset.to_string()));
}
match self
.spotify_get_typed_compat::<Page<rspotify::model::SavedAlbum>>("me/albums", &query)
.await
{
Ok(saved_albums) => {
if !saved_albums.items.is_empty() {
let mut app = self.app.lock().await;
app.library.saved_albums.add_pages(saved_albums);
}
}
Err(e) => {
self.handle_error(anyhow!(e)).await;
}
};
}
async fn current_user_saved_albums_contains(&mut self, album_ids: Vec<AlbumId<'_>>) {
let uris: Vec<String> = album_ids
.iter()
.map(|id| format!("spotify:album:{}", id.id()))
.collect();
if let Ok(are_followed) = self.library_contains_uris(&uris).await {
let mut app = self.app.lock().await;
album_ids
.iter()
.zip(are_followed.iter())
.for_each(|(id, &is_followed)| {
if is_followed {
app.saved_album_ids_set.insert(id.id().to_string());
} else {
app.saved_album_ids_set.remove(id.id());
}
});
}
}
pub async fn current_user_saved_album_delete(&mut self, album_id: AlbumId<'_>) {
let uri = format!("spotify:album:{}", album_id.id());
match self.library_remove_uris(std::slice::from_ref(&uri)).await {
Ok(_) => {
self.get_current_user_saved_albums(None).await;
let mut app = self.app.lock().await;
app.saved_album_ids_set.remove(album_id.id());
}
Err(e) => {
self.handle_error(anyhow!(e)).await;
}
};
}
async fn current_user_saved_album_add(&mut self, album_id: AlbumId<'_>) {
let uri = format!("spotify:album:{}", album_id.id());
match self.library_save_uris(std::slice::from_ref(&uri)).await {
Ok(_) => {
let mut app = self.app.lock().await;
app.saved_album_ids_set.insert(album_id.id().to_string());
}
Err(e) => self.handle_error(anyhow!(e)).await,
}
}
async fn current_user_saved_shows_delete(&mut self, show_id: ShowId<'_>) {
let uri = format!("spotify:show:{}", show_id.id());
match self.library_remove_uris(std::slice::from_ref(&uri)).await {
Ok(_) => {
self.get_current_user_saved_shows(None).await;
let mut app = self.app.lock().await;
app.saved_show_ids_set.remove(show_id.id());
}
Err(e) => {
self.handle_error(anyhow!(e)).await;
}
}
}
async fn current_user_saved_shows_add(&mut self, show_id: ShowId<'_>) {
let uri = format!("spotify:show:{}", show_id.id());
match self.library_save_uris(std::slice::from_ref(&uri)).await {
Ok(_) => {
self.get_current_user_saved_shows(None).await;
let mut app = self.app.lock().await;
app.saved_show_ids_set.insert(show_id.id().to_string());
}
Err(e) => {
self.handle_error(anyhow!(e)).await;
}
}
}
async fn user_unfollow_artists(&mut self, artist_ids: Vec<ArtistId<'_>>) {
let uris: Vec<String> = artist_ids
.iter()
.map(|id| format!("spotify:artist:{}", id.id()))
.collect();
match self.library_remove_uris(&uris).await {
Ok(_) => {
self.get_followed_artists(None).await;
let mut app = self.app.lock().await;
artist_ids.iter().for_each(|id| {
app.followed_artist_ids_set.remove(id.id());
});
}
Err(e) => {
self.handle_error(anyhow!(e)).await;
}
}
}
async fn user_follow_artists(&mut self, artist_ids: Vec<ArtistId<'_>>) {
let uris: Vec<String> = artist_ids
.iter()
.map(|id| format!("spotify:artist:{}", id.id()))
.collect();
match self.library_save_uris(&uris).await {
Ok(_) => {
self.get_followed_artists(None).await;
let mut app = self.app.lock().await;
artist_ids.iter().for_each(|id| {
app.followed_artist_ids_set.insert(id.id().to_string());
});
}
Err(e) => {
self.handle_error(anyhow!(e)).await;
}
}
}
async fn user_follow_playlist(
&mut self,
_playlist_owner_id: UserId<'_>,
playlist_id: PlaylistId<'_>,
is_public: Option<bool>,
) {
let _ = is_public;
let uri = format!("spotify:playlist:{}", playlist_id.id());
match self.library_save_uris(std::slice::from_ref(&uri)).await {
Ok(_) => {
self.get_current_user_playlists().await;
}
Err(e) => {
self.handle_error(anyhow!(e)).await;
}
}
}
async fn user_unfollow_playlist(&mut self, _user_id: UserId<'_>, playlist_id: PlaylistId<'_>) {
let uri = format!("spotify:playlist:{}", playlist_id.id());
match self.library_remove_uris(std::slice::from_ref(&uri)).await {
Ok(_) => {
self.get_current_user_playlists().await;
}
Err(e) => {
self.handle_error(anyhow!(e)).await;
}
}
}
fn playlist_mutation_error_message(error: &anyhow::Error, action: &str) -> String {
let msg = error.to_string();
if msg.contains("Spotify API 403") {
return format!("No permission to {} playlist", action);
}
if msg.contains("Spotify API 404") {
return format!("Playlist/track not found while trying to {}", action);
}
if msg.contains("Spotify API 429") {
return "Spotify rate limit hit, retry shortly".to_string();
}
format!("Could not {}: {}", action, msg)
}
fn playlist_items_path(playlist_id: &PlaylistId<'_>) -> String {
format!("playlists/{}/items", playlist_id.id())
}
fn playlist_uris_payload(uris: &[String]) -> Value {
json!({
"uris": uris
})
}
fn remove_playlist_item_uri_at_position(
mut uris: Vec<String>,
position: usize,
) -> anyhow::Result<Vec<String>> {
if position >= uris.len() {
return Err(anyhow!(
"Cannot resolve track position {} in playlist with {} items",
position,
uris.len()
));
}
uris.remove(position);
Ok(uris)
}
fn playlist_item_uri(item: &PlaylistItem) -> anyhow::Result<String> {
match item.track.as_ref() {
Some(PlayableItem::Track(track)) => track
.id
.as_ref()
.map(|id| format!("spotify:track:{}", id.id()))
.ok_or_else(|| anyhow!("Playlist contains a local track that cannot be edited")),
Some(PlayableItem::Episode(episode)) => Ok(format!("spotify:episode:{}", episode.id.id())),
None => Err(anyhow!(
"Playlist contains an unavailable item that cannot be edited"
)),
}
}
async fn get_playlist_item_uris(
&self,
playlist_id: &PlaylistId<'_>,
) -> anyhow::Result<Vec<String>> {
let path = Self::playlist_items_path(playlist_id);
let mut uris: Vec<String> = Vec::new();
let mut offset: u32 = 0;
let limit: u32 = 100;
loop {
let page = self
.spotify_get_typed_compat::<Page<PlaylistItem>>(
&path,
&[("limit", limit.to_string()), ("offset", offset.to_string())],
)
.await?;
if page.items.is_empty() {
break;
}
for item in &page.items {
uris.push(Self::playlist_item_uri(item)?);
}
offset = offset.saturating_add(page.items.len() as u32);
if offset >= page.total {
break;
}
}
Ok(uris)
}
async fn replace_playlist_items_with_uris(
&self,
playlist_id: &PlaylistId<'_>,
uris: &[String],
) -> anyhow::Result<()> {
let path = Self::playlist_items_path(playlist_id);
let first_chunk: Vec<String> = uris.iter().take(100).cloned().collect();
Self::spotify_api_request_json_for(
&self.spotify,
Method::PUT,
&path,
&[],
Some(Self::playlist_uris_payload(&first_chunk)),
)
.await?;
for chunk in uris.chunks(100).skip(1) {
let chunk_vec = chunk.to_vec();
Self::spotify_api_request_json_for(
&self.spotify,
Method::POST,
&path,
&[],
Some(Self::playlist_uris_payload(&chunk_vec)),
)
.await?;
}
Ok(())
}
async fn refresh_playlist_if_open(&mut self, playlist_id: PlaylistId<'_>) {
let playlist_id_str = playlist_id.id().to_string();
let mut app = self.app.lock().await;
let should_refresh = match app.track_table.context {
Some(TrackTableContext::MyPlaylists) => app
.active_playlist_index
.and_then(|idx| app.all_playlists.get(idx))
.is_some_and(|p| p.id.id() == playlist_id_str),
Some(TrackTableContext::PlaylistSearch) => app
.search_results
.selected_playlists_index
.and_then(|idx| {
app
.search_results
.playlists
.as_ref()
.and_then(|r| r.items.get(idx))
})
.is_some_and(|p| p.id.id() == playlist_id_str),
_ => false,
};
if should_refresh {
let offset = app.playlist_offset;
app.dispatch(IoEvent::GetPlaylistItems(playlist_id.into_static(), offset));
}
}
async fn add_track_to_playlist(&mut self, playlist_id: PlaylistId<'_>, track_id: TrackId<'_>) {
let path = Self::playlist_items_path(&playlist_id);
let track_uri = format!("spotify:track:{}", track_id.id());
let payload = Self::playlist_uris_payload(&[track_uri]);
match Self::spotify_api_request_json_for(&self.spotify, Method::POST, &path, &[], Some(payload))
.await
{
Ok(_) => {
self
.show_status_message("Track added to playlist".to_string(), 4)
.await;
self.refresh_playlist_if_open(playlist_id).await;
}
Err(e) => {
self
.show_status_message(
Self::playlist_mutation_error_message(&e, "add track to playlist"),
5,
)
.await;
}
}
}
async fn remove_track_from_playlist_at_position(
&mut self,
playlist_id: PlaylistId<'_>,
track_id: TrackId<'_>,
position: usize,
) {
let result = async {
let uris = self.get_playlist_item_uris(&playlist_id).await?;
let expected_track_uri = format!("spotify:track:{}", track_id.id());
let selected_uri = uris
.get(position)
.ok_or_else(|| anyhow!("Cannot resolve track position for removal"))?;
if selected_uri != &expected_track_uri {
return Err(anyhow!(
"Selected playlist row is out of sync with Spotify; refresh and retry"
));
}
let uris_after_removal = Self::remove_playlist_item_uri_at_position(uris, position)?;
self
.replace_playlist_items_with_uris(&playlist_id, &uris_after_removal)
.await
}
.await;
match result {
Ok(()) => {
self
.show_status_message("Track removed from playlist".to_string(), 4)
.await;
self.refresh_playlist_if_open(playlist_id).await;
}
Err(e) => {
self
.show_status_message(
Self::playlist_mutation_error_message(&e, "remove track from playlist"),
5,
)
.await;
}
}
}
async fn get_current_user_playlists(&mut self) {
let first_query = vec![("limit", self.large_search_limit.to_string())];
let first_page = match self
.spotify_get_typed_compat::<Page<rspotify::model::playlist::SimplifiedPlaylist>>(
"me/playlists",
&first_query,
)
.await
{
Ok(p) => p,
Err(e) => {
let err = anyhow!(e);
if Self::is_rate_limited_error(&err) {
self
.show_status_message(
"Spotify rate limit hit while loading playlists. Will retry on next refresh."
.to_string(),
6,
)
.await;
return;
}
self.handle_error(err).await;
return;
}
};
let total = first_page.total;
let first_page_count = first_page.items.len() as u32;
let first_page_items = first_page.items.clone();
let (refresh_generation, preferred_playlist_id, preferred_folder_id, preferred_selected_index) = {
let mut app = self.app.lock().await;
let preferred_playlist_id = app.get_selected_playlist_id();
let preferred_folder_id = app.current_playlist_folder_id;
let preferred_selected_index = app.selected_playlist_index;
app.playlist_refresh_generation = app.playlist_refresh_generation.saturating_add(1);
(
app.playlist_refresh_generation,
preferred_playlist_id,
preferred_folder_id,
preferred_selected_index,
)
};
{
let mut app = self.app.lock().await;
app.all_playlists = first_page_items.clone();
app.playlists = Some(first_page);
app.playlist_folder_nodes = None;
app.playlist_folder_items = build_flat_playlist_items(&app.all_playlists);
reconcile_playlist_selection(
&mut app,
preferred_playlist_id.as_deref(),
preferred_folder_id,
preferred_selected_index,
);
}
let spotify = self.spotify.clone();
let app = self.app.clone();
let limit = self.large_search_limit;
#[cfg(feature = "streaming")]
{
let streaming_player = self.streaming_player.clone();
tokio::spawn(async move {
Self::fetch_remaining_playlists_and_folders_task(
spotify,
app,
limit,
first_page_count,
total,
first_page_items,
refresh_generation,
preferred_playlist_id,
preferred_folder_id,
preferred_selected_index,
streaming_player,
)
.await;
});
}
#[cfg(not(feature = "streaming"))]
{
tokio::spawn(async move {
Self::fetch_remaining_playlists_and_folders_task(
spotify,
app,
limit,
first_page_count,
total,
first_page_items,
refresh_generation,
preferred_playlist_id,
preferred_folder_id,
preferred_selected_index,
)
.await;
});
}
}
async fn fetch_remaining_playlists_and_folders_task(
spotify: AuthCodePkceSpotify,
app: Arc<Mutex<App>>,
limit: u32,
first_page_count: u32,
total: u32,
mut all_playlists: Vec<rspotify::model::playlist::SimplifiedPlaylist>,
refresh_generation: u64,
preferred_playlist_id: Option<String>,
preferred_folder_id: usize,
preferred_selected_index: Option<usize>,
#[cfg(feature = "streaming")] streaming_player: Option<Arc<StreamingPlayer>>,
) {
let max_playlists: u32 = 10_000;
#[cfg(feature = "streaming")]
let (remaining_playlists, had_playlist_error, folder_nodes) = {
let remaining_handle = tokio::spawn(async move {
let mut remaining = Vec::new();
let mut offset = first_page_count;
let mut had_error = false;
while offset < total && offset < max_playlists {
let query = vec![("limit", limit.to_string()), ("offset", offset.to_string())];
match Self::spotify_get_typed_compat_for::<
Page<rspotify::model::playlist::SimplifiedPlaylist>,
>(&spotify, "me/playlists", &query)
.await
{
Ok(page) => {
let items_count = page.items.len() as u32;
remaining.extend(page.items);
if items_count < limit {
break;
}
offset += items_count;
}
Err(e) => {
had_error = true;
eprintln!("Failed to fetch playlist page at offset {}: {}", offset, e);
break;
}
}
tokio::task::yield_now().await;
}
(remaining, had_error)
});
let folder_nodes = fetch_rootlist_folders(&streaming_player).await;
if folder_nodes.is_some() {
let mut app_guard = app.lock().await;
if app_guard.playlist_refresh_generation == refresh_generation {
let folder_items = if let Some(ref nodes) = folder_nodes {
structurize_playlist_folders(nodes, &app_guard.all_playlists)
} else {
build_flat_playlist_items(&app_guard.all_playlists)
};
app_guard.playlist_folder_nodes = folder_nodes.clone();
app_guard.playlist_folder_items = folder_items;
reconcile_playlist_selection(
&mut app_guard,
preferred_playlist_id.as_deref(),
preferred_folder_id,
preferred_selected_index,
);
}
}
let (remaining, had_error) = match remaining_handle.await {
Ok(result) => result,
Err(_) => (Vec::new(), true),
};
(remaining, had_error, folder_nodes)
};
#[cfg(not(feature = "streaming"))]
let (remaining_playlists, had_playlist_error, folder_nodes) = {
let mut remaining = Vec::new();
let mut offset = first_page_count;
let mut had_error = false;
while offset < total && offset < max_playlists {
let query = vec![("limit", limit.to_string()), ("offset", offset.to_string())];
match Self::spotify_get_typed_compat_for::<
Page<rspotify::model::playlist::SimplifiedPlaylist>,
>(&spotify, "me/playlists", &query)
.await
{
Ok(page) => {
let items_count = page.items.len() as u32;
remaining.extend(page.items);
if items_count < limit {
break;
}
offset += items_count;
}
Err(e) => {
had_error = true;
eprintln!("Failed to fetch playlist page at offset {}: {}", offset, e);
break;
}
}
tokio::task::yield_now().await;
}
let folder_nodes: Option<Vec<PlaylistFolderNode>> = None;
(remaining, had_error, folder_nodes)
};
all_playlists.extend(remaining_playlists);
let mut app = app.lock().await;
if app.playlist_refresh_generation != refresh_generation {
return;
}
app.all_playlists = all_playlists;
let first_items: Vec<_> = app
.all_playlists
.iter()
.take(limit as usize)
.cloned()
.collect();
let total_items = app.all_playlists.len() as u32;
if let Some(playlists) = app.playlists.as_mut() {
playlists.items = first_items;
playlists.total = total_items;
playlists.offset = 0;
playlists.next = None;
playlists.previous = None;
}
let folder_items = if let Some(ref nodes) = folder_nodes {
structurize_playlist_folders(nodes, &app.all_playlists)
} else {
build_flat_playlist_items(&app.all_playlists)
};
app.playlist_folder_nodes = folder_nodes;
app.playlist_folder_items = folder_items;
reconcile_playlist_selection(
&mut app,
preferred_playlist_id.as_deref(),
preferred_folder_id,
preferred_selected_index,
);
if had_playlist_error {
app.status_message = Some("Playlists partially loaded (network error)".to_string());
app.status_message_expires_at = Some(Instant::now() + Duration::from_secs(4));
}
}
async fn get_recently_played(&mut self) {
let query = vec![("limit", self.large_search_limit.to_string())];
match self
.spotify_get_typed_compat::<rspotify::model::CursorBasedPage<rspotify::model::PlayHistory>>(
"me/player/recently-played",
&query,
)
.await
{
Ok(result) => {
let track_ids = result
.items
.iter()
.filter_map(|item| {
item
.track
.id
.as_ref()
.map(|id| TrackId::from_id(id.id()).unwrap().into_static())
})
.collect::<Vec<TrackId<'static>>>();
self.current_user_saved_tracks_contains(track_ids).await;
let mut app = self.app.lock().await;
app.recently_played.result = Some(result.clone());
}
Err(e) => {
self.handle_error(anyhow!(e)).await;
}
}
}
async fn get_album(&mut self, album_id: AlbumId<'_>) {
match self.spotify.album(album_id, None).await {
Ok(album) => {
let selected_album = SelectedFullAlbum {
album,
selected_index: 0,
};
let mut app = self.app.lock().await;
app.selected_album_full = Some(selected_album);
app.album_table_context = AlbumTableContext::Full;
app.push_navigation_stack(RouteId::AlbumTracks, ActiveBlock::AlbumTracks);
}
Err(e) => {
self.handle_error(anyhow!(e)).await;
}
}
}
async fn get_album_for_track(&mut self, track_id: TrackId<'_>) {
match self.spotify.track(track_id, None).await {
Ok(track) => {
let album_id = match track.album.id {
Some(id) => id,
None => return,
};
if let Ok(album) = self.spotify.album(album_id, None).await {
let zero_indexed_track_number = track.track_number - 1;
let selected_album = SelectedFullAlbum {
album,
selected_index: zero_indexed_track_number as usize,
};
let mut app = self.app.lock().await;
app.selected_album_full = Some(selected_album.clone());
app.saved_album_tracks_index = selected_album.selected_index;
app.album_table_context = AlbumTableContext::Full;
app.push_navigation_stack(RouteId::AlbumTracks, ActiveBlock::AlbumTracks);
}
}
Err(e) => {
self.handle_error(anyhow!(e)).await;
}
}
}
async fn transfert_playback_to_device(&mut self, device_id: String, persist_device_id: bool) {
#[cfg(feature = "streaming")]
{
let is_native_device = if let Some(ref player) = self.streaming_player {
let native_name = player.device_name().to_lowercase();
let app = self.app.lock().await;
let matches_cached_device = app.devices.as_ref().is_some_and(|payload| {
payload
.devices
.iter()
.any(|d| d.id.as_ref() == Some(&device_id) && d.name.to_lowercase() == native_name)
});
matches_cached_device || app.native_device_id.as_ref() == Some(&device_id)
} else {
false
};
if is_native_device {
if let Some(ref player) = self.streaming_player {
let activation_time = Instant::now();
let should_transfer = {
let app = self.app.lock().await;
let recent_activation = app
.last_device_activation
.is_some_and(|instant| instant.elapsed() < Duration::from_secs(5));
!app.native_activation_pending && !app.is_streaming_active && !recent_activation
};
{
let mut app = self.app.lock().await;
app.is_streaming_active = true;
app.native_device_id = Some(device_id.clone());
app.last_device_activation = Some(activation_time);
app.native_activation_pending = true;
app.instant_since_last_current_playback_poll = activation_time - Duration::from_secs(6);
}
let player = Arc::clone(player);
let app = Arc::clone(&self.app);
let device_id_for_task = device_id.clone();
tokio::spawn(async move {
if should_transfer {
let _ = player.transfer(None);
}
player.activate();
let mut app = app.lock().await;
app.is_streaming_active = true;
app.native_device_id = Some(device_id_for_task);
app.last_device_activation = Some(activation_time);
app.native_activation_pending = false;
app.instant_since_last_current_playback_poll = activation_time - Duration::from_secs(6);
});
if persist_device_id {
match self.client_config.set_device_id(device_id) {
Ok(()) => {
let mut app = self.app.lock().await;
app.pop_navigation_stack();
}
Err(e) => {
self.handle_error(e).await;
}
};
} else {
let mut app = self.app.lock().await;
app.pop_navigation_stack();
}
return;
}
}
}
match self.spotify.transfer_playback(&device_id, Some(true)).await {
Ok(()) => {
#[cfg(feature = "streaming")]
{
let mut app = self.app.lock().await;
app.is_streaming_active = false;
app.native_device_id = None;
app.last_device_activation = None;
app.native_activation_pending = false;
}
self.get_current_playback().await;
}
Err(e) => {
self.handle_error(anyhow!(e)).await;
return;
}
};
if persist_device_id {
match self.client_config.set_device_id(device_id) {
Ok(()) => {
let mut app = self.app.lock().await;
app.pop_navigation_stack();
}
Err(e) => {
self.handle_error(e).await;
}
};
} else {
let mut app = self.app.lock().await;
app.pop_navigation_stack();
}
}
#[cfg(feature = "streaming")]
async fn auto_select_streaming_device(&mut self, device_name: String, persist_device_id: bool) {
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
if let Some(ref player) = self.streaming_player {
let activation_time = Instant::now();
let should_transfer = {
let app = self.app.lock().await;
let recent_activation = app
.last_device_activation
.is_some_and(|instant| instant.elapsed() < Duration::from_secs(5));
!app.native_activation_pending && !app.is_streaming_active && !recent_activation
};
{
let mut app = self.app.lock().await;
app.is_streaming_active = true;
app.native_activation_pending = true;
app.last_device_activation = Some(activation_time);
app.instant_since_last_current_playback_poll = activation_time - Duration::from_secs(6);
}
let player = Arc::clone(player);
let app = Arc::clone(&self.app);
tokio::spawn(async move {
if should_transfer {
let _ = player.transfer(None);
}
player.activate();
let mut app = app.lock().await;
app.is_streaming_active = true;
app.native_activation_pending = false;
app.last_device_activation = Some(activation_time);
app.instant_since_last_current_playback_poll = activation_time - Duration::from_secs(6);
});
for attempt in 0..2 {
if attempt > 0 {
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
}
match self.spotify.device().await {
Ok(devices) => {
if let Some(device) = devices
.iter()
.find(|d| d.name.to_lowercase() == device_name.to_lowercase())
{
if let Some(device_id) = &device.id {
if persist_device_id {
let _ = self.client_config.set_device_id(device_id.clone());
}
let mut app = self.app.lock().await;
app.native_device_id = Some(device_id.clone());
return;
}
}
}
Err(_) => {
continue;
}
}
}
}
}
async fn refresh_authentication(&mut self) {
}
async fn add_item_to_queue(&mut self, item: PlayableId<'_>) {
match self.spotify.add_item_to_queue(item, None).await {
Ok(()) => (),
Err(e) => {
self.handle_error(anyhow!(e)).await;
}
}
}
#[cfg(feature = "telemetry")]
async fn increment_global_song_count(&self) {
self.update_global_song_count(reqwest::Method::POST).await;
}
#[cfg(feature = "telemetry")]
async fn fetch_global_song_count(&self) {
self.update_global_song_count(reqwest::Method::GET).await;
}
#[cfg(feature = "telemetry")]
async fn update_global_song_count(&self, method: reqwest::Method) {
const TELEMETRY_ENDPOINT: &str = "https://spotatui-counter.spotatui.workers.dev";
let app = Arc::clone(&self.app);
tokio::spawn(async move {
let client = match reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(5))
.build()
{
Ok(client) => client,
Err(_) => {
let mut app = app.lock().await;
app.global_song_count_failed = true;
return;
}
};
let response = client
.request(method, TELEMETRY_ENDPOINT)
.header(reqwest::header::ACCEPT, "application/json")
.send()
.await;
let parsed_response = match response {
Ok(resp) => resp.json::<GlobalSongCountResponse>().await,
Err(e) => Err(e),
};
match parsed_response {
Ok(data) => {
let mut app = app.lock().await;
app.global_song_count = Some(data.count);
app.global_song_count_failed = false;
}
Err(_) => {
let mut app = app.lock().await;
app.global_song_count_failed = true;
}
}
});
}
#[cfg(not(feature = "telemetry"))]
async fn increment_global_song_count(&self) {
}
#[cfg(not(feature = "telemetry"))]
async fn fetch_global_song_count(&self) {
}
async fn get_lyrics(&mut self, track_name: String, artist_name: String, duration_sec: f64) {
use crate::app::LyricsStatus;
{
let mut app = self.app.lock().await;
app.lyrics_status = LyricsStatus::Loading;
app.lyrics = None;
}
let client = reqwest::Client::new();
let params = [
("artist_name", artist_name),
("track_name", track_name),
("duration", duration_sec.to_string()),
];
match client
.get("https://lrclib.net/api/get")
.query(¶ms)
.send()
.await
{
Ok(resp) => {
if let Ok(lrc_resp) = resp.json::<LrcResponse>().await {
if let Some(synced) = lrc_resp.syncedLyrics {
let parsed = self.parse_lrc(&synced);
let mut app = self.app.lock().await;
app.lyrics = Some(parsed);
app.lyrics_status = LyricsStatus::Found;
} else if let Some(plain) = lrc_resp.plainLyrics {
let mut app = self.app.lock().await;
app.lyrics = Some(vec![(0, plain)]);
app.lyrics_status = LyricsStatus::Found;
} else {
let mut app = self.app.lock().await;
app.lyrics_status = LyricsStatus::NotFound;
}
} else {
let mut app = self.app.lock().await;
app.lyrics_status = LyricsStatus::NotFound;
}
}
Err(_) => {
let mut app = self.app.lock().await;
app.lyrics_status = LyricsStatus::NotFound;
}
}
}
fn parse_lrc(&self, lrc: &str) -> Vec<(u128, String)> {
let mut lyrics = Vec::new();
for line in lrc.lines() {
if let Some(idx) = line.find(']') {
if line.starts_with('[') && idx < line.len() {
let time_part = &line[1..idx];
let text_part = line[idx + 1..].trim().to_string();
if let Some(time) = self.parse_time_ms(time_part) {
lyrics.push((time, text_part));
}
}
}
}
lyrics
}
fn parse_time_ms(&self, time_str: &str) -> Option<u128> {
let parts: Vec<&str> = time_str.split(':').collect();
if parts.len() != 2 {
return None;
}
let min: u128 = parts[0].parse().ok()?;
let sec_parts: Vec<&str> = parts[1].split('.').collect();
if sec_parts.len() != 2 {
return None;
}
let sec: u128 = sec_parts[0].parse().ok()?;
let ms_part = sec_parts[1];
let ms: u128 = ms_part.parse().ok()?;
let ms_val = if ms_part.len() == 2 { ms * 10 } else { ms };
Some(min * 60000 + sec * 1000 + ms_val)
}
async fn get_user_top_tracks(&mut self, time_range: crate::app::DiscoverTimeRange) {
use crate::app::DiscoverTimeRange;
use rspotify::model::TimeRange;
let spotify_time_range = match time_range {
DiscoverTimeRange::Short => TimeRange::ShortTerm,
DiscoverTimeRange::Medium => TimeRange::MediumTerm,
DiscoverTimeRange::Long => TimeRange::LongTerm,
};
let spotify_time_range_param = match spotify_time_range {
TimeRange::ShortTerm => "short_term",
TimeRange::MediumTerm => "medium_term",
TimeRange::LongTerm => "long_term",
};
{
let mut app = self.app.lock().await;
app.discover_loading = true;
}
let query = vec![
("time_range", spotify_time_range_param.to_string()),
("limit", "50".to_string()),
("offset", "0".to_string()),
];
let tracks = match self
.spotify_get_typed_compat::<Page<FullTrack>>("me/top/tracks", &query)
.await
{
Ok(page) => page.items,
Err(e) => {
let err = anyhow!(e);
if Self::is_rate_limited_error(&err) {
self
.show_status_message(
"Spotify rate limit hit while loading top tracks. Try again in a few seconds."
.to_string(),
6,
)
.await;
} else {
self.handle_error(err).await;
}
let mut app = self.app.lock().await;
app.discover_loading = false;
return;
}
};
let mut app = self.app.lock().await;
app.discover_top_tracks = tracks.clone();
app.discover_loading = false;
app.track_table.tracks = tracks;
app.track_table.context = Some(crate::app::TrackTableContext::DiscoverPlaylist);
app.track_table.selected_index = 0;
app.push_navigation_stack(
crate::app::RouteId::TrackTable,
crate::app::ActiveBlock::TrackTable,
);
}
async fn get_top_artists_mix(&mut self) {
use rand::seq::SliceRandom;
{
let mut app = self.app.lock().await;
app.discover_loading = true;
}
let artist_query = vec![
("time_range", "medium_term".to_string()),
("limit", "5".to_string()),
("offset", "0".to_string()),
];
let artists = match self
.spotify_get_typed_compat::<Page<FullArtist>>("me/top/artists", &artist_query)
.await
{
Ok(page) => page.items,
Err(e) => {
let err = anyhow!(e);
if Self::is_rate_limited_error(&err) {
self
.show_status_message(
"Spotify rate limit hit while loading top artists. Try again in a few seconds."
.to_string(),
6,
)
.await;
} else {
self.handle_error(err).await;
}
let mut app = self.app.lock().await;
app.discover_loading = false;
return;
}
};
let seed_artists = artists
.iter()
.take(5)
.map(|artist| artist.id.clone())
.map(|id| id.into_static())
.collect::<Vec<ArtistId<'static>>>();
let mut all_tracks = if seed_artists.is_empty() {
Vec::new()
} else {
let seed_genres: Option<Vec<&str>> = None;
let seed_tracks: Option<Vec<TrackId<'static>>> = None;
match self
.spotify
.recommendations(
[],
Some(seed_artists),
seed_genres,
seed_tracks,
None,
Some(10),
)
.await
{
Ok(result) => self
.extract_recommended_tracks(&result)
.await
.unwrap_or_default(),
Err(_) => Vec::new(),
}
};
if all_tracks.is_empty() {
let fallback_query = vec![
("time_range", "medium_term".to_string()),
("limit", "50".to_string()),
("offset", "0".to_string()),
];
all_tracks = self
.spotify_get_typed_compat::<Page<FullTrack>>("me/top/tracks", &fallback_query)
.await
.map(|page| page.items)
.unwrap_or_default();
}
{
let mut rng = rand::thread_rng();
all_tracks.shuffle(&mut rng);
}
let mut app = self.app.lock().await;
app.discover_artists_mix = all_tracks.clone();
app.discover_loading = false;
app.track_table.tracks = all_tracks;
app.track_table.context = Some(crate::app::TrackTableContext::DiscoverPlaylist);
app.track_table.selected_index = 0;
app.push_navigation_stack(
crate::app::RouteId::TrackTable,
crate::app::ActiveBlock::TrackTable,
);
}
}
#[cfg(feature = "streaming")]
async fn fetch_rootlist_folders(
streaming_player: &Option<Arc<StreamingPlayer>>,
) -> Option<Vec<PlaylistFolderNode>> {
let player = streaming_player.as_ref()?;
let session = player.session();
let bytes = match session.spclient().get_rootlist(0, Some(100_000)).await {
Ok(b) => b,
Err(e) => {
eprintln!("Failed to fetch rootlist: {}", e);
return None;
}
};
use protobuf::Message;
let selected: librespot_protocol::playlist4_external::SelectedListContent =
match Message::parse_from_bytes(&bytes) {
Ok(s) => s,
Err(e) => {
eprintln!("Failed to parse rootlist protobuf: {}", e);
return None;
}
};
let contents = selected.contents.as_ref()?;
let items = &contents.items;
Some(parse_rootlist_items(items))
}
fn build_flat_playlist_items(
playlists: &[rspotify::model::playlist::SimplifiedPlaylist],
) -> Vec<PlaylistFolderItem> {
playlists
.iter()
.enumerate()
.map(|(idx, _)| PlaylistFolderItem::Playlist {
index: idx,
current_id: 0,
})
.collect()
}
fn reconcile_playlist_selection(
app: &mut App,
preferred_playlist_id: Option<&str>,
preferred_folder_id: usize,
preferred_selected_index: Option<usize>,
) {
if app.playlist_folder_items.is_empty() {
app.current_playlist_folder_id = 0;
app.selected_playlist_index = None;
return;
}
let folder_has_visible = |folder_id: usize, app: &App| {
app.playlist_folder_items.iter().any(|item| match item {
PlaylistFolderItem::Folder(folder) => folder.current_id == folder_id,
PlaylistFolderItem::Playlist { current_id, .. } => *current_id == folder_id,
})
};
app.current_playlist_folder_id = if folder_has_visible(preferred_folder_id, app) {
preferred_folder_id
} else {
0
};
if let Some(playlist_id) = preferred_playlist_id {
let visible_playlist_index = app
.playlist_folder_items
.iter()
.filter(|item| app.is_playlist_item_visible_in_current_folder(item))
.enumerate()
.find_map(|(display_idx, item)| match item {
PlaylistFolderItem::Playlist { index, .. } => app
.all_playlists
.get(*index)
.filter(|playlist| playlist.id.id() == playlist_id)
.map(|_| display_idx),
PlaylistFolderItem::Folder(_) => None,
});
if let Some(display_idx) = visible_playlist_index {
app.selected_playlist_index = Some(display_idx);
return;
}
let mut target_folder: Option<usize> = None;
for item in &app.playlist_folder_items {
if let PlaylistFolderItem::Playlist { index, current_id } = item {
if let Some(playlist) = app.all_playlists.get(*index) {
if playlist.id.id() == playlist_id {
target_folder = Some(*current_id);
break;
}
}
}
}
if let Some(folder_id) = target_folder {
app.current_playlist_folder_id = folder_id;
let display_idx = app
.playlist_folder_items
.iter()
.filter(|item| app.is_playlist_item_visible_in_current_folder(item))
.enumerate()
.find_map(|(idx, item)| match item {
PlaylistFolderItem::Playlist { index, .. } => app
.all_playlists
.get(*index)
.filter(|playlist| playlist.id.id() == playlist_id)
.map(|_| idx),
PlaylistFolderItem::Folder(_) => None,
});
if let Some(idx) = display_idx {
app.selected_playlist_index = Some(idx);
return;
}
}
}
let visible_count = app.get_playlist_display_count();
if visible_count == 0 {
app.current_playlist_folder_id = 0;
let root_count = app.get_playlist_display_count();
app.selected_playlist_index = if root_count == 0 {
None
} else {
Some(preferred_selected_index.unwrap_or(0).min(root_count - 1))
};
return;
}
app.selected_playlist_index = Some(preferred_selected_index.unwrap_or(0).min(visible_count - 1));
}
#[cfg(feature = "streaming")]
fn parse_rootlist_items(
items: &[librespot_protocol::playlist4_external::Item],
) -> Vec<PlaylistFolderNode> {
let mut root: Vec<PlaylistFolderNode> = Vec::new();
let mut stack: Vec<Vec<PlaylistFolderNode>> = Vec::new();
let mut name_stack: Vec<(String, String)> = Vec::new();
for item in items {
let uri = item.uri();
if let Some(rest) = uri.strip_prefix("spotify:start-group:") {
let (group_id, name) = match rest.find(':') {
Some(pos) => (rest[..pos].to_string(), rest[pos + 1..].to_string()),
None => (rest.to_string(), String::new()),
};
name_stack.push((group_id, name.clone()));
stack.push(std::mem::take(&mut root));
root = Vec::new();
} else if uri.starts_with("spotify:end-group:") {
if let Some((group_id, name)) = name_stack.pop() {
let children = std::mem::take(&mut root);
root = stack.pop().unwrap_or_default();
root.push(PlaylistFolderNode {
name: Some(name),
node_type: PlaylistFolderNodeType::Folder,
uri: format!("spotify:folder:{}", group_id),
children,
});
}
} else {
root.push(PlaylistFolderNode {
name: None,
node_type: PlaylistFolderNodeType::Playlist,
uri: uri.to_string(),
children: Vec::new(),
});
}
}
while let Some((group_id, name)) = name_stack.pop() {
let children = std::mem::take(&mut root);
root = stack.pop().unwrap_or_default();
root.push(PlaylistFolderNode {
name: Some(name),
node_type: PlaylistFolderNodeType::Folder,
uri: format!("spotify:folder:{}", group_id),
children,
});
}
root
}
fn structurize_playlist_folders(
nodes: &[PlaylistFolderNode],
playlists: &[rspotify::model::playlist::SimplifiedPlaylist],
) -> Vec<PlaylistFolderItem> {
use std::collections::HashMap;
let playlist_map: HashMap<String, usize> = playlists
.iter()
.enumerate()
.map(|(idx, p)| (p.id.id().to_string(), idx))
.collect();
let mut items: Vec<PlaylistFolderItem> = Vec::new();
let mut next_folder_id: usize = 1; let mut used_playlist_indices: std::collections::HashSet<usize> =
std::collections::HashSet::new();
fn walk(
nodes: &[PlaylistFolderNode],
current_folder_id: usize,
items: &mut Vec<PlaylistFolderItem>,
next_folder_id: &mut usize,
playlist_map: &HashMap<String, usize>,
used_playlist_indices: &mut std::collections::HashSet<usize>,
) {
for node in nodes {
match node.node_type {
PlaylistFolderNodeType::Folder => {
let folder_id = *next_folder_id;
*next_folder_id += 1;
let name = node.name.as_deref().unwrap_or("Unnamed Folder").to_string();
items.push(PlaylistFolderItem::Folder(PlaylistFolder {
name: name.clone(),
current_id: current_folder_id,
target_id: folder_id,
}));
items.push(PlaylistFolderItem::Folder(PlaylistFolder {
name: format!("\u{2190} {}", name),
current_id: folder_id,
target_id: current_folder_id,
}));
walk(
&node.children,
folder_id,
items,
next_folder_id,
playlist_map,
used_playlist_indices,
);
}
PlaylistFolderNodeType::Playlist => {
let playlist_id = node
.uri
.strip_prefix("spotify:playlist:")
.unwrap_or(&node.uri);
if let Some(&idx) = playlist_map.get(playlist_id) {
items.push(PlaylistFolderItem::Playlist {
index: idx,
current_id: current_folder_id,
});
used_playlist_indices.insert(idx);
}
}
}
}
}
walk(
nodes,
0,
&mut items,
&mut next_folder_id,
&playlist_map,
&mut used_playlist_indices,
);
for (idx, _) in playlists.iter().enumerate() {
if !used_playlist_indices.contains(&idx) {
items.push(PlaylistFolderItem::Playlist {
index: idx,
current_id: 0,
});
}
}
items
}
#[cfg(test)]
mod tests {
use super::Network;
use anyhow::Result;
use rspotify::model::idtypes::PlaylistId;
use serde_json::json;
#[test]
fn playlist_items_path_uses_items_endpoint() {
let playlist_id = PlaylistId::from_id("37i9dQZF1DXcBWIGoYBM5M").expect("valid playlist id");
let path = Network::playlist_items_path(&playlist_id);
assert_eq!(path, "playlists/37i9dQZF1DXcBWIGoYBM5M/items");
}
#[test]
fn playlist_uris_payload_uses_uris_shape() {
let payload = Network::playlist_uris_payload(&[
"spotify:track:6rqhFgbbKwnb9MLmUQDhG6".to_string(),
"spotify:episode:4rOoJ6Egrf8K2IrywzwOMk".to_string(),
]);
assert_eq!(
payload,
json!({
"uris": [
"spotify:track:6rqhFgbbKwnb9MLmUQDhG6",
"spotify:episode:4rOoJ6Egrf8K2IrywzwOMk"
]
})
);
}
#[test]
fn remove_playlist_item_uri_at_position_keeps_other_duplicates() -> Result<()> {
let uris = vec![
"spotify:track:A".to_string(),
"spotify:track:B".to_string(),
"spotify:track:A".to_string(),
"spotify:track:C".to_string(),
];
let updated = Network::remove_playlist_item_uri_at_position(uris, 2)?;
assert_eq!(
updated,
vec![
"spotify:track:A".to_string(),
"spotify:track:B".to_string(),
"spotify:track:C".to_string(),
]
);
Ok(())
}
#[test]
fn remove_playlist_item_uri_at_position_errors_when_out_of_bounds() {
let err = Network::remove_playlist_item_uri_at_position(vec!["spotify:track:A".to_string()], 1)
.expect_err("position beyond playlist length should fail");
assert!(err.to_string().contains("Cannot resolve track position"));
}
}