From 3a29cd2c25beeb3b490b8e7197fbd38747eb58c5 Mon Sep 17 00:00:00 2001 From: Paul Le Date: Sat, 14 Feb 2026 16:22:04 -0500 Subject: [PATCH 1/3] feat: add and remove song from playlist --- src/app.rs | 78 +++++++++++- src/handlers/dialog.rs | 142 ++++++++++++++++++++-- src/handlers/empty.rs | 18 +++ src/handlers/mod.rs | 38 ++++++ src/handlers/playbar.rs | 35 ++++++ src/handlers/track_table.rs | 96 ++++++++++++++- src/network.rs | 189 ++++++++++++++++++++++++----- src/ui/help.rs | 20 ++++ src/ui/mod.rs | 233 ++++++++++++++++++++++++++---------- 9 files changed, 744 insertions(+), 105 deletions(-) diff --git a/src/app.rs b/src/app.rs index 169dd09b..0f36379c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -11,7 +11,7 @@ use rspotify::{ artist::FullArtist, context::CurrentPlaybackContext, device::DevicePayload, - idtypes::{ArtistId, ShowId, TrackId}, + idtypes::{ArtistId, PlaylistId, ShowId, TrackId}, page::{CursorBasedPage, Page}, playing::PlayHistory, playlist::{PlaylistItem, SimplifiedPlaylist}, @@ -29,7 +29,7 @@ use std::sync::Arc; use std::{ cmp::{max, min}, collections::HashSet, - time::{Instant, SystemTime}, + time::{Duration, Instant, SystemTime}, }; use arboard::Clipboard; @@ -120,6 +120,8 @@ pub enum ArtistBlock { pub enum DialogContext { PlaylistWindow, PlaylistSearch, + AddTrackToPlaylistPicker, + RemoveTrackFromPlaylistConfirm, } #[derive(Clone, Copy, PartialEq, Debug)] @@ -272,6 +274,21 @@ pub struct TrackTable { pub context: Option, } +#[derive(Clone)] +pub struct PendingPlaylistTrackAdd { + pub track_id: TrackId<'static>, + pub track_name: String, +} + +#[derive(Clone)] +pub struct PendingPlaylistTrackRemoval { + pub playlist_id: PlaylistId<'static>, + pub playlist_name: String, + pub track_id: TrackId<'static>, + pub track_name: String, + pub position: usize, +} + #[derive(Clone)] pub struct SelectedShow { pub show: SimplifiedShow, @@ -590,6 +607,15 @@ pub struct App { pub status_message_expires_at: Option, /// Pending track table selection to apply when new page loads pub pending_track_table_selection: Option, + /// Maps visible track table rows to source playlist item positions. + /// Used to remove a single selected playlist occurrence safely. + pub playlist_track_positions: Option>, + /// Selected playlist index in the add-to-playlist picker dialog + pub playlist_picker_selected_index: usize, + /// Pending track to add in add-to-playlist dialog flow + pub pending_playlist_track_add: Option, + /// Pending track removal info in remove-from-playlist confirmation flow + pub pending_playlist_track_removal: Option, /// Full flat list of all user playlists (all pages combined) pub all_playlists: Vec, /// Folder tree from rootlist (None if not fetched or streaming disabled) @@ -738,6 +764,10 @@ impl Default for App { status_message: None, status_message_expires_at: None, pending_track_table_selection: None, + playlist_track_positions: None, + playlist_picker_selected_index: 0, + pending_playlist_track_add: None, + pending_playlist_track_removal: None, all_playlists: Vec::new(), playlist_folder_nodes: None, playlist_folder_items: Vec::new(), @@ -783,6 +813,50 @@ impl App { self.io_tx = None; } + pub fn clear_playlist_track_dialog_state(&mut self) { + self.pending_playlist_track_add = None; + self.pending_playlist_track_removal = None; + self.playlist_picker_selected_index = 0; + } + + pub fn set_status_message(&mut self, message: impl Into, ttl_secs: u64) { + self.status_message = Some(message.into()); + self.status_message_expires_at = Some(Instant::now() + Duration::from_secs(ttl_secs)); + } + + pub fn begin_add_track_to_playlist_flow( + &mut self, + track_id: Option>, + track_name: String, + ) { + let Some(track_id) = track_id else { + self.set_status_message("Track cannot be edited in playlist".to_string(), 4); + return; + }; + + if self.all_playlists.is_empty() { + if self.playlists.is_none() { + self.dispatch(IoEvent::GetPlaylists); + self.set_status_message("Playlists loading, try again".to_string(), 4); + } else { + self.set_status_message("No playlists available".to_string(), 4); + } + return; + } + + self.dialog = None; + self.confirm = false; + self.clear_playlist_track_dialog_state(); + self.pending_playlist_track_add = Some(PendingPlaylistTrackAdd { + track_id, + track_name, + }); + self.push_navigation_stack( + RouteId::Dialog, + ActiveBlock::Dialog(DialogContext::AddTrackToPlaylistPicker), + ); + } + pub fn is_playlist_item_visible_in_current_folder(&self, item: &PlaylistFolderItem) -> bool { match item { PlaylistFolderItem::Folder(f) => f.current_id == self.current_playlist_folder_id, diff --git a/src/handlers/dialog.rs b/src/handlers/dialog.rs index 6d35ff82..abca1132 100644 --- a/src/handlers/dialog.rs +++ b/src/handlers/dialog.rs @@ -1,25 +1,106 @@ -use super::super::app::{ActiveBlock, App, DialogContext}; +use super::{ + super::app::{ActiveBlock, App, DialogContext}, + common_key_events, +}; use crate::event::Key; +use crate::network::IoEvent; pub fn handler(key: Key, app: &mut App) { + let dialog_context = match app.get_current_route().active_block { + ActiveBlock::Dialog(context) => context, + _ => return, + }; + + match dialog_context { + DialogContext::AddTrackToPlaylistPicker => handle_add_to_playlist_picker(key, app), + DialogContext::PlaylistWindow + | DialogContext::PlaylistSearch + | DialogContext::RemoveTrackFromPlaylistConfirm => { + handle_confirmation_dialog(key, app, dialog_context) + } + } +} + +fn handle_confirmation_dialog(key: Key, app: &mut App, dialog_context: DialogContext) { match key { Key::Enter => { - if let Some(route) = app.pop_navigation_stack() { - if app.confirm { - if let ActiveBlock::Dialog(d) = route.active_block { - match d { - DialogContext::PlaylistWindow => handle_playlist_dialog(app), - DialogContext::PlaylistSearch => handle_playlist_search_dialog(app), - } + if app.confirm { + match dialog_context { + DialogContext::PlaylistWindow => handle_playlist_dialog(app), + DialogContext::PlaylistSearch => handle_playlist_search_dialog(app), + DialogContext::RemoveTrackFromPlaylistConfirm => { + handle_remove_track_from_playlist_confirm(app); } + DialogContext::AddTrackToPlaylistPicker => {} } } + close_dialog(app); } Key::Char('q') => { - app.pop_navigation_stack(); + close_dialog(app); + } + k if common_key_events::right_event(k) => app.confirm = !app.confirm, + k if common_key_events::left_event(k) => app.confirm = !app.confirm, + _ => {} + } +} + +fn handle_add_to_playlist_picker(key: Key, app: &mut App) { + let playlist_count = app.all_playlists.len(); + match key { + k if common_key_events::down_event(k) => { + if playlist_count > 0 { + let next = common_key_events::on_down_press_handler( + &app.all_playlists, + Some(app.playlist_picker_selected_index), + ); + app.playlist_picker_selected_index = next; + } + } + k if common_key_events::up_event(k) => { + if playlist_count > 0 { + let next = common_key_events::on_up_press_handler( + &app.all_playlists, + Some(app.playlist_picker_selected_index), + ); + app.playlist_picker_selected_index = next; + } + } + k if common_key_events::high_event(k) => { + if playlist_count > 0 { + app.playlist_picker_selected_index = common_key_events::on_high_press_handler(); + } + } + k if common_key_events::middle_event(k) => { + if playlist_count > 0 { + app.playlist_picker_selected_index = + common_key_events::on_middle_press_handler(&app.all_playlists); + } + } + k if common_key_events::low_event(k) => { + if playlist_count > 0 { + app.playlist_picker_selected_index = + common_key_events::on_low_press_handler(&app.all_playlists); + } + } + Key::Enter => { + if let Some(pending_add) = app.pending_playlist_track_add.clone() { + if let Some(playlist) = app.all_playlists.get( + app + .playlist_picker_selected_index + .min(playlist_count.saturating_sub(1)), + ) { + app.dispatch(IoEvent::AddTrackToPlaylist( + playlist.id.clone().into_static(), + pending_add.track_id, + )); + } + } + close_dialog(app); + } + Key::Char('q') => { + close_dialog(app); } - Key::Right => app.confirm = !app.confirm, - Key::Left => app.confirm = !app.confirm, _ => {} } } @@ -31,3 +112,42 @@ fn handle_playlist_dialog(app: &mut App) { fn handle_playlist_search_dialog(app: &mut App) { app.user_unfollow_playlist_search_result() } + +fn handle_remove_track_from_playlist_confirm(app: &mut App) { + if let Some(pending_remove) = app.pending_playlist_track_removal.clone() { + app.dispatch(IoEvent::RemoveTrackFromPlaylistAtPosition( + pending_remove.playlist_id, + pending_remove.track_id, + pending_remove.position, + )); + } +} + +fn close_dialog(app: &mut App) { + app.pop_navigation_stack(); + app.dialog = None; + app.confirm = false; + app.clear_playlist_track_dialog_state(); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::RouteId; + + #[test] + fn confirmation_dialog_toggles_with_vim_hl() { + let mut app = App::default(); + app.push_navigation_stack( + RouteId::Dialog, + ActiveBlock::Dialog(DialogContext::RemoveTrackFromPlaylistConfirm), + ); + app.confirm = false; + + handler(Key::Char('l'), &mut app); + assert!(app.confirm); + + handler(Key::Char('h'), &mut app); + assert!(!app.confirm); + } +} diff --git a/src/handlers/empty.rs b/src/handlers/empty.rs index 36f010c5..c9fa86b8 100644 --- a/src/handlers/empty.rs +++ b/src/handlers/empty.rs @@ -55,6 +55,11 @@ pub fn handler(key: Key, app: &mut App) { _ => {} }, k if common_key_events::right_event(k) => common_key_events::handle_right_event(app), + Key::Char('w') => { + if app.get_current_route().hovered_block == ActiveBlock::PlayBar { + super::playbar::handler(key, app); + } + } _ => (), }; } @@ -168,4 +173,17 @@ mod tests { assert_eq!(current_route.active_block, ActiveBlock::Home); assert_eq!(current_route.hovered_block, ActiveBlock::Home); } + + #[test] + fn on_w_press_over_playbar_adds_current_track() { + let mut app = App::default(); + app.set_current_route_state(Some(ActiveBlock::Empty), Some(ActiveBlock::PlayBar)); + + handler(Key::Char('w'), &mut app); + + assert_eq!( + app.status_message.as_deref(), + Some("No track currently playing") + ); + } } diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index c328aecb..233ee380 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -102,6 +102,12 @@ pub fn handle_app(key: Key, app: &mut App) { app.load_settings_for_category(); app.push_navigation_stack(RouteId::Settings, ActiveBlock::Settings); } + Key::Char('W') => match app.get_current_route().active_block { + ActiveBlock::Input | ActiveBlock::Dialog(_) | ActiveBlock::UpdatePrompt => { + handle_block_events(key, app); + } + _ => playbar::add_currently_playing_track_to_playlist(app), + }, _ => handle_block_events(key, app), } } @@ -203,6 +209,9 @@ fn handle_escape(app: &mut App) { } ActiveBlock::Dialog(_) => { app.pop_navigation_stack(); + app.dialog = None; + app.confirm = false; + app.clear_playlist_track_dialog_state(); } ActiveBlock::HelpMenu => { app.pop_navigation_stack(); @@ -276,3 +285,32 @@ fn handle_jump_to_artist_album(app: &mut App) { } }; } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn global_shift_w_adds_current_track_from_anywhere() { + let mut app = App::default(); + app.set_current_route_state(Some(ActiveBlock::Empty), Some(ActiveBlock::Library)); + + handle_app(Key::Char('W'), &mut app); + + assert_eq!( + app.status_message.as_deref(), + Some("No track currently playing") + ); + } + + #[test] + fn global_shift_w_is_not_intercepted_in_input_mode() { + let mut app = App::default(); + app.set_current_route_state(Some(ActiveBlock::Input), Some(ActiveBlock::Input)); + + handle_app(Key::Char('W'), &mut app); + + assert_eq!(app.input, vec!['W']); + assert!(app.status_message.is_none()); + } +} diff --git a/src/handlers/playbar.rs b/src/handlers/playbar.rs index e00d4850..926ec498 100644 --- a/src/handlers/playbar.rs +++ b/src/handlers/playbar.rs @@ -32,10 +32,32 @@ pub fn handler(key: Key, app: &mut App) { }; }; } + Key::Char('w') => { + add_currently_playing_track_to_playlist(app); + } _ => {} }; } +pub(crate) fn add_currently_playing_track_to_playlist(app: &mut App) { + if let Some(CurrentPlaybackContext { + item: Some(item), .. + }) = app.current_playback_context.to_owned() + { + match item { + PlayableItem::Track(track) => { + let track_id = track.id.map(|id| id.into_static()); + app.begin_add_track_to_playlist_flow(track_id, track.name); + } + PlayableItem::Episode(_) => { + app.set_status_message("Only tracks can be added to playlists".to_string(), 4); + } + }; + } else { + app.set_status_message("No track currently playing".to_string(), 4); + } +} + #[cfg(test)] mod tests { use super::*; @@ -50,4 +72,17 @@ mod tests { assert_eq!(current_route.active_block, ActiveBlock::Empty); assert_eq!(current_route.hovered_block, ActiveBlock::MyPlaylists); } + + #[test] + fn on_add_current_track_without_playback_sets_status_message() { + let mut app = App::default(); + app.set_current_route_state(Some(ActiveBlock::PlayBar), Some(ActiveBlock::PlayBar)); + + handler(Key::Char('w'), &mut app); + + assert_eq!( + app.status_message.as_deref(), + Some("No track currently playing") + ); + } } diff --git a/src/handlers/track_table.rs b/src/handlers/track_table.rs index 21cf41b2..16f0beda 100644 --- a/src/handlers/track_table.rs +++ b/src/handlers/track_table.rs @@ -1,5 +1,8 @@ use super::{ - super::app::{App, PendingTrackSelection, RecommendationsContext, TrackTable, TrackTableContext}, + super::app::{ + ActiveBlock, App, DialogContext, PendingPlaylistTrackRemoval, PendingTrackSelection, + RecommendationsContext, RouteId, TrackTable, TrackTableContext, + }, common_key_events, }; use crate::event::Key; @@ -160,6 +163,8 @@ pub fn handler(key: Key, app: &mut App) { } }; } + Key::Char('w') => open_add_to_playlist_dialog(app), + Key::Char('x') => open_remove_from_playlist_dialog(app), Key::Char('s') => handle_save_track_event(app), Key::Char('S') => play_random_song(app), k if k == app.user_config.keys.jump_to_end => jump_to_end(app), @@ -177,6 +182,72 @@ pub fn handler(key: Key, app: &mut App) { } } +fn open_add_to_playlist_dialog(app: &mut App) { + let track = match app.track_table.tracks.get(app.track_table.selected_index) { + Some(track) => track, + None => return, + }; + + let track_id = track.id.clone().map(|id| id.into_static()); + let track_name = track.name.clone(); + app.begin_add_track_to_playlist_flow(track_id, track_name); +} + +fn open_remove_from_playlist_dialog(app: &mut App) { + let playlist_context = match active_playlist_target_for_track_table_context(app) { + Some(context) => context, + None => { + app.set_status_message( + "Remove only works in selected playlist views".to_string(), + 4, + ); + return; + } + }; + + let track = match app.track_table.tracks.get(app.track_table.selected_index) { + Some(track) => track, + None => return, + }; + + let track_id = match track.id.clone() { + Some(id) => id.into_static(), + None => { + app.set_status_message("Track cannot be edited in playlist".to_string(), 4); + return; + } + }; + let track_name = track.name.clone(); + + let position = match app + .playlist_track_positions + .as_ref() + .and_then(|positions| positions.get(app.track_table.selected_index)) + .copied() + { + Some(position) => position, + None => { + app.set_status_message("Cannot resolve track position for removal".to_string(), 4); + return; + } + }; + + app.dialog = None; + app.confirm = false; + app.clear_playlist_track_dialog_state(); + app.pending_playlist_track_removal = Some(PendingPlaylistTrackRemoval { + playlist_id: playlist_context.0, + playlist_name: playlist_context.1, + track_id, + track_name, + position, + }); + app.push_navigation_stack( + RouteId::Dialog, + ActiveBlock::Dialog(DialogContext::RemoveTrackFromPlaylistConfirm), + ); +} + fn play_random_song(app: &mut App) { if let Some(context) = &app.track_table.context { match context { @@ -526,6 +597,29 @@ fn active_playlist_id_static(app: &App) -> Option> { .map(|playlist| playlist.id.clone().into_static()) } +fn active_playlist_target_for_track_table_context( + app: &App, +) -> Option<(PlaylistId<'static>, String)> { + match app.track_table.context { + Some(TrackTableContext::MyPlaylists) => app + .active_playlist_index + .and_then(|idx| app.all_playlists.get(idx)) + .map(|playlist| (playlist.id.clone().into_static(), playlist.name.clone())), + Some(TrackTableContext::PlaylistSearch) => app + .search_results + .selected_playlists_index + .and_then(|idx| { + app + .search_results + .playlists + .as_ref() + .and_then(|playlists| playlists.items.get(idx)) + }) + .map(|playlist| (playlist.id.clone().into_static(), playlist.name.clone())), + _ => None, + } +} + fn active_playlist_context_id(app: &App) -> Option> { app .active_playlist_index diff --git a/src/network.rs b/src/network.rs index d13aaf78..6064e08e 100644 --- a/src/network.rs +++ b/src/network.rs @@ -81,6 +81,8 @@ pub enum IoEvent { UserFollowArtists(Vec>), UserFollowPlaylist(UserId<'static>, PlaylistId<'static>, Option), UserUnfollowPlaylist(UserId<'static>, PlaylistId<'static>), + AddTrackToPlaylist(PlaylistId<'static>, TrackId<'static>), + RemoveTrackFromPlaylistAtPosition(PlaylistId<'static>, TrackId<'static>, usize), GetUser, ToggleSaveTrack(PlayableId<'static>), GetRecommendationsForTrackId(TrackId<'static>, Option), @@ -341,6 +343,14 @@ impl Network { 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; @@ -1047,17 +1057,20 @@ impl Network { } async fn set_playlist_tracks_to_table(&mut self, playlist_track_page: &Page) { - let tracks = playlist_track_page - .items - .clone() - .into_iter() - .filter_map(|item| item.track) - .filter_map(|track| match track { - PlayableItem::Track(full_track) => Some(full_track), - PlayableItem::Episode(_) => None, - }) - .collect::>(); + let mut tracks: Vec = Vec::new(); + let mut positions: Vec = 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) { @@ -1068,6 +1081,7 @@ impl Network { .collect(); let mut app = self.app.lock().await; + app.playlist_track_positions = None; // Apply pending selection if set let track_count = tracks.len(); @@ -1909,8 +1923,9 @@ impl Network { return; } - // Collect all playlist items - let mut all_items: Vec = Vec::new(); + // Collect all playlist items in source order with original positions. + let mut all_items_with_positions: Vec<(usize, rspotify::model::playlist::PlaylistItem)> = + Vec::new(); let mut offset = 0; while offset < total { @@ -1924,7 +1939,9 @@ impl Network { .await { Ok(page) => { - all_items.extend(page.items); + for (idx, item) in page.items.into_iter().enumerate() { + all_items_with_positions.push((offset as usize + idx, item)); + } } Err(_e) => { break; @@ -1934,12 +1951,12 @@ impl Network { } // Sort all items - all_items.sort_by(|a, b| { - let track_a = a.track.as_ref().and_then(|t| match t { + 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.track.as_ref().and_then(|t| match t { + let track_b = b.1.track.as_ref().and_then(|t| match t { PlayableItem::Track(track) => Some(track), PlayableItem::Episode(_) => None, }); @@ -1967,7 +1984,7 @@ impl Network { .to_lowercase() .cmp(&tb.album.name.to_lowercase()), crate::sort::SortField::Duration => ta.duration.cmp(&tb.duration), - crate::sort::SortField::DateAdded => a.added_at.cmp(&b.added_at), + 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, @@ -1981,28 +1998,31 @@ impl Network { } }); - // Extract tracks and update app state - let sorted_tracks: Vec = all_items - .iter() - .filter_map(|item| item.track.as_ref()) - .filter_map(|track| match track { - PlayableItem::Track(full_track) => Some(full_track.clone()), - PlayableItem::Episode(_) => None, - }) - .collect(); + // Extract sorted playlist items, table tracks, and row->position mapping. + let mut sorted_playlist_items: Vec = Vec::new(); + let mut sorted_tracks: Vec = Vec::new(); + let mut sorted_positions: Vec = 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); + } // Update app state with sorted data let mut app = self.app.lock().await; // Update playlist_tracks with sorted items if let Some(ref mut playlist_tracks) = app.playlist_tracks { - playlist_tracks.items = all_items; + playlist_tracks.items = sorted_playlist_items; playlist_tracks.total = total; } // Update track table with sorted tracks 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) { @@ -2904,6 +2924,121 @@ impl Network { } } + 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) + } + + 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 = format!("playlists/{}/tracks", playlist_id.id()); + let track_uri = format!("spotify:track:{}", track_id.id()); + + match Self::spotify_api_request_json_for( + &self.spotify, + Method::POST, + &path, + &[], + Some(json!({ "uris": [track_uri] })), + ) + .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 path = format!("playlists/{}/tracks", playlist_id.id()); + let track_uri = format!("spotify:track:{}", track_id.id()); + + match Self::spotify_api_request_json_for( + &self.spotify, + Method::DELETE, + &path, + &[], + Some(json!({ + "tracks": [ + { + "uri": track_uri, + "positions": [position] + } + ] + })), + ) + .await + { + 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) { // Step 1: Fetch ONLY the first page (single API call, fast) let first_query = vec![("limit", self.large_search_limit.to_string())]; diff --git a/src/ui/help.rs b/src/ui/help.rs index 98c3cced..42870d49 100644 --- a/src/ui/help.rs +++ b/src/ui/help.rs @@ -177,6 +177,26 @@ pub fn get_help_docs(key_bindings: &KeyBindings) -> Vec> { String::from("s"), String::from("Selected block"), ], + vec![ + String::from("Add selected track to playlist"), + String::from("w"), + String::from("Track table"), + ], + vec![ + String::from("Add currently playing track to playlist"), + String::from("w"), + String::from("Playbar"), + ], + vec![ + String::from("Quick-add currently playing track to playlist"), + String::from("W"), + String::from("Global"), + ], + vec![ + String::from("Remove selected track from current playlist"), + String::from("x"), + String::from("Track table (playlist views)"), + ], vec![ String::from("Start playback or enter album/artist/playlist"), key_bindings.submit.to_string(), diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 23a5e3d1..94821b4d 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -4,8 +4,8 @@ pub mod settings; pub mod util; use super::{ app::{ - ActiveBlock, AlbumTableContext, App, ArtistBlock, EpisodeTableContext, RecommendationsContext, - RouteId, SearchResultBlock, LIBRARY_OPTIONS, + ActiveBlock, AlbumTableContext, App, ArtistBlock, DialogContext, EpisodeTableContext, + RecommendationsContext, RouteId, SearchResultBlock, LIBRARY_OPTIONS, }, banner::BANNER, }; @@ -2341,79 +2341,184 @@ fn draw_selectable_list( } fn draw_dialog(f: &mut Frame<'_>, app: &App) { - if let ActiveBlock::Dialog(_) = app.get_current_route().active_block { - if let Some(playlist) = app.dialog.as_ref() { - let bounds = f.area(); - // maybe do this better - let width = std::cmp::min(bounds.width - 2, 45); - let height = 8; - let left = (bounds.width - width) / 2; - let top = bounds.height / 4; - - let rect = Rect::new(left, top, width, height); + let dialog_context = match app.get_current_route().active_block { + ActiveBlock::Dialog(context) => context, + _ => return, + }; - f.render_widget(Clear, rect); + match dialog_context { + DialogContext::PlaylistWindow | DialogContext::PlaylistSearch => { + if let Some(playlist) = app.dialog.as_ref() { + let text = vec![ + Line::from(Span::raw("Are you sure you want to delete the playlist: ")), + Line::from(Span::styled( + playlist.as_str(), + Style::default().add_modifier(Modifier::BOLD), + )), + Line::from(Span::raw("?")), + ]; + draw_confirmation_dialog(f, app, "Confirm", text, 45); + } + } + DialogContext::RemoveTrackFromPlaylistConfirm => { + if let Some(pending_remove) = app.pending_playlist_track_removal.as_ref() { + let text = vec![ + Line::from(Span::raw("Remove this track from playlist?")), + Line::from(Span::styled( + format!("Track: {}", pending_remove.track_name), + Style::default().add_modifier(Modifier::BOLD), + )), + Line::from(Span::styled( + format!("Playlist: {}", pending_remove.playlist_name), + Style::default().add_modifier(Modifier::BOLD), + )), + ]; + draw_confirmation_dialog(f, app, "Remove Track", text, 60); + } + } + DialogContext::AddTrackToPlaylistPicker => { + draw_add_track_to_playlist_picker_dialog(f, app); + } + } +} - let block = Block::default() - .borders(Borders::ALL) - .style(app.user_config.theme.base_style()) - .border_style(Style::default().fg(app.user_config.theme.inactive)); +fn centered_modal_rect(bounds: Rect, requested_width: u16, requested_height: u16) -> Rect { + let width = requested_width.min(bounds.width.saturating_sub(2).max(1)); + let height = requested_height.min(bounds.height.saturating_sub(2).max(1)); + let left = bounds.x + bounds.width.saturating_sub(width) / 2; + let top = bounds.y + bounds.height.saturating_sub(height) / 3; + Rect::new(left, top, width, height) +} - f.render_widget(block, rect); - - let vchunks = Layout::default() - .direction(Direction::Vertical) - .margin(2) - .constraints([Constraint::Min(3), Constraint::Length(3)].as_ref()) - .split(rect); - - // suggestion: possibly put this as part of - // app.dialog, but would have to introduce lifetime - let text = vec![ - Line::from(Span::raw("Are you sure you want to delete the playlist: ")), - Line::from(Span::styled( - playlist.as_str(), - Style::default().add_modifier(Modifier::BOLD), - )), - Line::from(Span::raw("?")), - ]; +fn draw_confirmation_dialog( + f: &mut Frame<'_>, + app: &App, + title: &str, + text: Vec>, + requested_width: u16, +) { + let rect = centered_modal_rect(f.area(), requested_width, 10); + f.render_widget(Clear, rect); - let text = Paragraph::new(text) - .wrap(Wrap { trim: true }) - .style(app.user_config.theme.base_style()) - .alignment(Alignment::Center); + let block = Block::default() + .title(Span::styled( + title, + Style::default() + .fg(app.user_config.theme.header) + .add_modifier(Modifier::BOLD), + )) + .borders(Borders::ALL) + .style(app.user_config.theme.base_style()) + .border_style(Style::default().fg(app.user_config.theme.inactive)); + f.render_widget(block, rect); - f.render_widget(text, vchunks[0]); + let vchunks = Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints([Constraint::Min(3), Constraint::Length(3)].as_ref()) + .split(rect); - let hchunks = Layout::default() - .direction(Direction::Horizontal) - .horizontal_margin(3) - .constraints([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)].as_ref()) - .split(vchunks[1]); + let text = Paragraph::new(text) + .wrap(Wrap { trim: true }) + .style(app.user_config.theme.base_style()) + .alignment(Alignment::Center); + f.render_widget(text, vchunks[0]); + + let hchunks = Layout::default() + .direction(Direction::Horizontal) + .horizontal_margin(3) + .constraints([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)].as_ref()) + .split(vchunks[1]); + + let ok = Paragraph::new(Span::raw("Ok")) + .style(Style::default().fg(if app.confirm { + app.user_config.theme.hovered + } else { + app.user_config.theme.inactive + })) + .alignment(Alignment::Center); + f.render_widget(ok, hchunks[0]); + + let cancel = Paragraph::new(Span::raw("Cancel")) + .style(Style::default().fg(if app.confirm { + app.user_config.theme.inactive + } else { + app.user_config.theme.hovered + })) + .alignment(Alignment::Center); + f.render_widget(cancel, hchunks[1]); +} - let ok_text = Span::raw("Ok"); - let ok = Paragraph::new(ok_text) - .style(Style::default().fg(if app.confirm { - app.user_config.theme.hovered - } else { - app.user_config.theme.inactive - })) - .alignment(Alignment::Center); +fn draw_add_track_to_playlist_picker_dialog(f: &mut Frame<'_>, app: &App) { + let rect = centered_modal_rect(f.area(), 70, 20); + f.render_widget(Clear, rect); - f.render_widget(ok, hchunks[0]); + let block = Block::default() + .title(Span::styled( + "Add Track To Playlist", + Style::default() + .fg(app.user_config.theme.header) + .add_modifier(Modifier::BOLD), + )) + .borders(Borders::ALL) + .style(app.user_config.theme.base_style()) + .border_style(Style::default().fg(app.user_config.theme.inactive)); + f.render_widget(block, rect); - let cancel_text = Span::raw("Cancel"); - let cancel = Paragraph::new(cancel_text) - .style(Style::default().fg(if app.confirm { - app.user_config.theme.inactive - } else { - app.user_config.theme.hovered - })) - .alignment(Alignment::Center); + let vchunks = Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints([ + Constraint::Length(2), + Constraint::Min(3), + Constraint::Length(1), + ]) + .split(rect); + + let track_name = app + .pending_playlist_track_add + .as_ref() + .map(|p| p.track_name.as_str()) + .unwrap_or("Selected track"); + + let header = Paragraph::new(Line::from(Span::raw(format!( + "Choose a playlist for: {}", + track_name + )))) + .wrap(Wrap { trim: true }) + .style(app.user_config.theme.base_style()); + f.render_widget(header, vchunks[0]); + + let mut list_state = ListState::default(); + + if app.all_playlists.is_empty() { + let empty_text = Paragraph::new("No playlists available") + .style(Style::default().fg(app.user_config.theme.inactive)) + .alignment(Alignment::Center); + f.render_widget(empty_text, vchunks[1]); + } else { + let items: Vec = app + .all_playlists + .iter() + .map(|playlist| ListItem::new(Span::raw(playlist.name.as_str()))) + .collect(); + let selected = app + .playlist_picker_selected_index + .min(app.all_playlists.len() - 1); + list_state.select(Some(selected)); + + let list = List::new(items) + .style(app.user_config.theme.base_style()) + .highlight_style(Style::default().fg(app.user_config.theme.hovered)) + .highlight_symbol("▶ "); - f.render_widget(cancel, hchunks[1]); - } + f.render_stateful_widget(list, vchunks[1], &mut list_state); } + + let footer = Paragraph::new("Enter add | q cancel | j/k or arrows move | H/M/L jump") + .style(Style::default().fg(app.user_config.theme.inactive)) + .alignment(Alignment::Center); + f.render_widget(footer, vchunks[2]); } fn draw_table( From cebf870143efa9d6e109ac57193c7f4d0b055181 Mon Sep 17 00:00:00 2001 From: Paul Le Date: Sat, 14 Feb 2026 16:41:28 -0500 Subject: [PATCH 2/3] feat: remove only a single song from the playlist --- src/network.rs | 207 ++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 181 insertions(+), 26 deletions(-) diff --git a/src/network.rs b/src/network.rs index 6064e08e..e3c81172 100644 --- a/src/network.rs +++ b/src/network.rs @@ -2938,6 +2938,22 @@ impl Network { format!("Could not {}: {}", action, msg) } + fn playlist_items_path(playlist_id: &PlaylistId<'_>) -> String { + format!("playlists/{}/items", playlist_id.id()) + } + + fn add_track_to_playlist_payload(track_uri: &str) -> Value { + json!({ + "uris": [track_uri] + }) + } + + fn replace_playlist_items_payload(uris: &[String]) -> Value { + json!({ + "uris": uris + }) + } + 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; @@ -2968,17 +2984,12 @@ impl Network { } async fn add_track_to_playlist(&mut self, playlist_id: PlaylistId<'_>, track_id: TrackId<'_>) { - let path = format!("playlists/{}/tracks", playlist_id.id()); + let path = Self::playlist_items_path(&playlist_id); let track_uri = format!("spotify:track:{}", track_id.id()); + let payload = Self::add_track_to_playlist_payload(&track_uri); - match Self::spotify_api_request_json_for( - &self.spotify, - Method::POST, - &path, - &[], - Some(json!({ "uris": [track_uri] })), - ) - .await + match Self::spotify_api_request_json_for(&self.spotify, Method::POST, &path, &[], Some(payload)) + .await { Ok(_) => { self @@ -3003,25 +3014,40 @@ impl Network { track_id: TrackId<'_>, position: usize, ) { - let path = format!("playlists/{}/tracks", playlist_id.id()); let track_uri = format!("spotify:track:{}", track_id.id()); + let mut uris = match self.fetch_all_playlist_item_uris(&playlist_id).await { + Ok(uris) => uris, + Err(e) => { + self + .show_status_message( + Self::playlist_mutation_error_message(&e, "load playlist items for removal"), + 5, + ) + .await; + return; + } + }; - match Self::spotify_api_request_json_for( - &self.spotify, - Method::DELETE, - &path, - &[], - Some(json!({ - "tracks": [ - { - "uri": track_uri, - "positions": [position] - } - ] - })), - ) - .await - { + if position >= uris.len() { + self + .show_status_message("Cannot resolve track position for removal".to_string(), 5) + .await; + return; + } + + if uris[position] != track_uri { + self + .show_status_message( + "Selected playlist row is out of sync; refresh and retry".to_string(), + 5, + ) + .await; + return; + } + + uris.remove(position); + + match self.replace_playlist_with_uris(&playlist_id, uris).await { Ok(_) => { self .show_status_message("Track removed from playlist".to_string(), 4) @@ -3039,6 +3065,93 @@ impl Network { } } + async fn fetch_all_playlist_item_uris( + &self, + playlist_id: &PlaylistId<'_>, + ) -> anyhow::Result> { + let mut offset: u32 = 0; + let limit: u32 = 100; + let path = Self::playlist_items_path(playlist_id); + let mut uris: Vec = Vec::new(); + + loop { + let page = self + .spotify_get_typed_compat::>( + &path, + &[("limit", limit.to_string()), ("offset", offset.to_string())], + ) + .await?; + + for item in page.items { + match item.track { + Some(PlayableItem::Track(track)) => { + let track_id = track + .id + .ok_or_else(|| anyhow!("Playlist contains local/unavailable track"))?; + uris.push(format!("spotify:track:{}", track_id.id())); + } + Some(PlayableItem::Episode(episode)) => { + uris.push(format!("spotify:episode:{}", episode.id.id())); + } + None => { + return Err(anyhow!("Playlist contains removed/unavailable item")); + } + } + } + + offset += limit; + if offset >= page.total { + break; + } + } + + Ok(uris) + } + + async fn replace_playlist_with_uris( + &self, + playlist_id: &PlaylistId<'_>, + uris: Vec, + ) -> anyhow::Result<()> { + let replace_path = format!("playlists/{}/tracks", playlist_id.id()); + let add_path = Self::playlist_items_path(playlist_id); + let chunks: Vec<&[String]> = uris.chunks(100).collect(); + + if chunks.is_empty() { + Self::spotify_api_request_json_for( + &self.spotify, + Method::PUT, + &replace_path, + &[], + Some(Self::replace_playlist_items_payload(&[])), + ) + .await?; + return Ok(()); + } + + Self::spotify_api_request_json_for( + &self.spotify, + Method::PUT, + &replace_path, + &[], + Some(Self::replace_playlist_items_payload(chunks[0])), + ) + .await?; + + for chunk in chunks.iter().skip(1) { + Self::spotify_api_request_json_for( + &self.spotify, + Method::POST, + &add_path, + &[], + Some(Self::replace_playlist_items_payload(chunk)), + ) + .await?; + } + + Ok(()) + } + async fn get_current_user_playlists(&mut self) { // Step 1: Fetch ONLY the first page (single API call, fast) let first_query = vec![("limit", self.large_search_limit.to_string())]; @@ -4241,3 +4354,45 @@ fn structurize_playlist_folders( items } + +#[cfg(test)] +mod tests { + use super::Network; + 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 add_track_payload_uses_items_shape() { + let payload = Network::add_track_to_playlist_payload("spotify:track:6rqhFgbbKwnb9MLmUQDhG6"); + assert_eq!( + payload, + json!({ + "uris": ["spotify:track:6rqhFgbbKwnb9MLmUQDhG6"] + }) + ); + } + + #[test] + fn replace_playlist_payload_uses_uris_shape() { + let payload = Network::replace_playlist_items_payload(&[ + "spotify:track:6rqhFgbbKwnb9MLmUQDhG6".to_string(), + "spotify:episode:2nq8xYdQfIo2ynsY9nGbvh".to_string(), + ]); + assert_eq!( + payload, + json!({ + "uris": [ + "spotify:track:6rqhFgbbKwnb9MLmUQDhG6", + "spotify:episode:2nq8xYdQfIo2ynsY9nGbvh" + ] + }) + ); + } +} From 5ce7d19c24b128e0c5fb228a1f847949d63c18b9 Mon Sep 17 00:00:00 2001 From: Paul Le Date: Sat, 14 Feb 2026 17:50:42 -0500 Subject: [PATCH 3/3] fix: make playlist remove deterministic for duplicates --- src/network.rs | 286 +++++++++++++++++++++++++------------------------ 1 file changed, 146 insertions(+), 140 deletions(-) diff --git a/src/network.rs b/src/network.rs index e3c81172..43fe9571 100644 --- a/src/network.rs +++ b/src/network.rs @@ -2942,16 +2942,105 @@ impl Network { format!("playlists/{}/items", playlist_id.id()) } - fn add_track_to_playlist_payload(track_uri: &str) -> Value { + fn playlist_uris_payload(uris: &[String]) -> Value { json!({ - "uris": [track_uri] + "uris": uris }) } - fn replace_playlist_items_payload(uris: &[String]) -> Value { - json!({ - "uris": uris - }) + fn remove_playlist_item_uri_at_position( + mut uris: Vec, + position: usize, + ) -> anyhow::Result> { + 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 { + 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> { + let path = Self::playlist_items_path(playlist_id); + let mut uris: Vec = Vec::new(); + let mut offset: u32 = 0; + let limit: u32 = 100; + + loop { + let page = self + .spotify_get_typed_compat::>( + &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 = 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<'_>) { @@ -2986,7 +3075,7 @@ impl Network { 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::add_track_to_playlist_payload(&track_uri); + let payload = Self::playlist_uris_payload(&[track_uri]); match Self::spotify_api_request_json_for(&self.spotify, Method::POST, &path, &[], Some(payload)) .await @@ -3014,41 +3103,26 @@ impl Network { track_id: TrackId<'_>, position: usize, ) { - let track_uri = format!("spotify:track:{}", track_id.id()); - let mut uris = match self.fetch_all_playlist_item_uris(&playlist_id).await { - Ok(uris) => uris, - Err(e) => { - self - .show_status_message( - Self::playlist_mutation_error_message(&e, "load playlist items for removal"), - 5, - ) - .await; - return; + 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" + )); } - }; - - if position >= uris.len() { - self - .show_status_message("Cannot resolve track position for removal".to_string(), 5) - .await; - return; - } - - if uris[position] != track_uri { + let uris_after_removal = Self::remove_playlist_item_uri_at_position(uris, position)?; self - .show_status_message( - "Selected playlist row is out of sync; refresh and retry".to_string(), - 5, - ) - .await; - return; + .replace_playlist_items_with_uris(&playlist_id, &uris_after_removal) + .await } + .await; - uris.remove(position); - - match self.replace_playlist_with_uris(&playlist_id, uris).await { - Ok(_) => { + match result { + Ok(()) => { self .show_status_message("Track removed from playlist".to_string(), 4) .await; @@ -3065,93 +3139,6 @@ impl Network { } } - async fn fetch_all_playlist_item_uris( - &self, - playlist_id: &PlaylistId<'_>, - ) -> anyhow::Result> { - let mut offset: u32 = 0; - let limit: u32 = 100; - let path = Self::playlist_items_path(playlist_id); - let mut uris: Vec = Vec::new(); - - loop { - let page = self - .spotify_get_typed_compat::>( - &path, - &[("limit", limit.to_string()), ("offset", offset.to_string())], - ) - .await?; - - for item in page.items { - match item.track { - Some(PlayableItem::Track(track)) => { - let track_id = track - .id - .ok_or_else(|| anyhow!("Playlist contains local/unavailable track"))?; - uris.push(format!("spotify:track:{}", track_id.id())); - } - Some(PlayableItem::Episode(episode)) => { - uris.push(format!("spotify:episode:{}", episode.id.id())); - } - None => { - return Err(anyhow!("Playlist contains removed/unavailable item")); - } - } - } - - offset += limit; - if offset >= page.total { - break; - } - } - - Ok(uris) - } - - async fn replace_playlist_with_uris( - &self, - playlist_id: &PlaylistId<'_>, - uris: Vec, - ) -> anyhow::Result<()> { - let replace_path = format!("playlists/{}/tracks", playlist_id.id()); - let add_path = Self::playlist_items_path(playlist_id); - let chunks: Vec<&[String]> = uris.chunks(100).collect(); - - if chunks.is_empty() { - Self::spotify_api_request_json_for( - &self.spotify, - Method::PUT, - &replace_path, - &[], - Some(Self::replace_playlist_items_payload(&[])), - ) - .await?; - return Ok(()); - } - - Self::spotify_api_request_json_for( - &self.spotify, - Method::PUT, - &replace_path, - &[], - Some(Self::replace_playlist_items_payload(chunks[0])), - ) - .await?; - - for chunk in chunks.iter().skip(1) { - Self::spotify_api_request_json_for( - &self.spotify, - Method::POST, - &add_path, - &[], - Some(Self::replace_playlist_items_payload(chunk)), - ) - .await?; - } - - Ok(()) - } - async fn get_current_user_playlists(&mut self) { // Step 1: Fetch ONLY the first page (single API call, fast) let first_query = vec![("limit", self.large_search_limit.to_string())]; @@ -4358,6 +4345,7 @@ fn structurize_playlist_folders( #[cfg(test)] mod tests { use super::Network; + use anyhow::Result; use rspotify::model::idtypes::PlaylistId; use serde_json::json; @@ -4369,30 +4357,48 @@ mod tests { } #[test] - fn add_track_payload_uses_items_shape() { - let payload = Network::add_track_to_playlist_payload("spotify:track:6rqhFgbbKwnb9MLmUQDhG6"); - assert_eq!( - payload, - json!({ - "uris": ["spotify:track:6rqhFgbbKwnb9MLmUQDhG6"] - }) - ); - } - - #[test] - fn replace_playlist_payload_uses_uris_shape() { - let payload = Network::replace_playlist_items_payload(&[ + fn playlist_uris_payload_uses_uris_shape() { + let payload = Network::playlist_uris_payload(&[ "spotify:track:6rqhFgbbKwnb9MLmUQDhG6".to_string(), - "spotify:episode:2nq8xYdQfIo2ynsY9nGbvh".to_string(), + "spotify:episode:4rOoJ6Egrf8K2IrywzwOMk".to_string(), ]); assert_eq!( payload, json!({ "uris": [ "spotify:track:6rqhFgbbKwnb9MLmUQDhG6", - "spotify:episode:2nq8xYdQfIo2ynsY9nGbvh" + "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")); + } }