diff --git a/README.md b/README.md index 0978b9b..54ebc60 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,8 @@ depending on your desktop environment settings. Have a look at the * `Shift-o` will open a context menu for the currently playing track * `a` will open the album view for the selected item * `A` will open the artist view for the selected item +* `m` will open a view with recommendations based on the selected item +* `M` will open a view with recommendations based on the currently playing track * `Ctrl-v` will open the context menu for a Spotify link in your clipboard * `Backspace` closes the current view * `Shift-p` toggles playback of a track (play/pause) diff --git a/src/album.rs b/src/album.rs index a07c449..1e23145 100644 --- a/src/album.rs +++ b/src/album.rs @@ -1,5 +1,6 @@ +use rand::{seq::IteratorRandom, thread_rng}; use std::fmt; -use std::sync::Arc; +use std::sync::{Arc, RwLock}; use chrono::{DateTime, Utc}; use log::debug; @@ -12,7 +13,7 @@ use crate::queue::Queue; use crate::spotify::Spotify; use crate::track::Track; use crate::traits::{IntoBoxedViewExt, ListItem, ViewExt}; -use crate::ui::album::AlbumView; +use crate::ui::{album::AlbumView, listview::ListView}; #[derive(Clone, Deserialize, Serialize)] pub struct Album { @@ -229,6 +230,50 @@ impl ListItem for Album { Some(AlbumView::new(queue, library, self).into_boxed_view_ext()) } + fn open_recommendations( + &mut self, + queue: Arc, + library: Arc, + ) -> Option> { + self.load_all_tracks(queue.get_spotify()); + const MAX_SEEDS: usize = 5; + let track_ids: Vec = self + .tracks + .as_ref()? + .iter() + .map(|t| t.id.clone()) + .flatten() + // spotify allows at max 5 seed items, so choose 4 random tracks... + .choose_multiple(&mut thread_rng(), MAX_SEEDS - 1); + + let artist_id: Option = self + .artist_ids + .iter() + .map(|aid| aid.clone()) + // ...and one artist + .choose(&mut thread_rng()); + + if track_ids.is_empty() && artist_id.is_some() { + return None; + } + + let spotify = queue.get_spotify(); + let recommendations: Option> = spotify + .api + .recommendations(artist_id.map(|aid| vec![aid]), None, Some(track_ids)) + .map(|r| r.tracks) + .map(|tracks| tracks.iter().map(Track::from).collect()); + recommendations.map(|tracks| { + ListView::new( + Arc::new(RwLock::new(tracks)), + queue.clone(), + library.clone(), + ) + .set_title(format!("Similar to Album \"{}\"", self.title)) + .into_boxed_view_ext() + }) + } + fn share_url(&self) -> Option { self.id .clone() diff --git a/src/artist.rs b/src/artist.rs index 466e0b0..7091e04 100644 --- a/src/artist.rs +++ b/src/artist.rs @@ -1,5 +1,5 @@ use std::fmt; -use std::sync::Arc; +use std::sync::{Arc, RwLock}; use rspotify::model::artist::{FullArtist, SimplifiedArtist}; @@ -9,7 +9,7 @@ use crate::queue::Queue; use crate::spotify::Spotify; use crate::track::Track; use crate::traits::{IntoBoxedViewExt, ListItem, ViewExt}; -use crate::ui::artist::ArtistView; +use crate::ui::{artist::ArtistView, listview::ListView}; #[derive(Clone, Deserialize, Serialize)] pub struct Artist { @@ -174,6 +174,31 @@ impl ListItem for Artist { Some(ArtistView::new(queue, library, self).into_boxed_view_ext()) } + fn open_recommendations( + &mut self, + queue: Arc, + library: Arc, + ) -> Option> { + let id = self.id.as_ref()?.to_string(); + + let spotify = queue.get_spotify(); + let recommendations: Option> = spotify + .api + .recommendations(Some(vec![id]), None, None) + .map(|r| r.tracks) + .map(|tracks| tracks.iter().map(Track::from).collect()); + + recommendations.map(|tracks| { + ListView::new( + Arc::new(RwLock::new(tracks)), + queue.clone(), + library.clone(), + ) + .set_title(format!("Similar to Artist \"{}\"", self.name,)) + .into_boxed_view_ext() + }) + } + fn share_url(&self) -> Option { self.id .clone() diff --git a/src/command.rs b/src/command.rs index 7adb5a7..4677c91 100644 --- a/src/command.rs +++ b/src/command.rs @@ -134,6 +134,7 @@ pub enum Command { NewPlaylist(String), Sort(SortKey, SortDirection), Logout, + ShowRecommendations(TargetMode), } impl fmt::Display for Command { @@ -193,6 +194,7 @@ impl fmt::Display for Command { Command::NewPlaylist(name) => format!("new playlist {}", name), Command::Sort(key, direction) => format!("sort {} {}", key, direction), Command::Logout => "logout".to_string(), + Command::ShowRecommendations(mode) => format!("similar {}", mode), }; write!(f, "{}", repr) } @@ -422,6 +424,14 @@ pub fn parse(input: &str) -> Option { } } "logout" => Some(Command::Logout), + "similar" => args + .get(0) + .and_then(|target| match *target { + "selected" => Some(TargetMode::Selected), + "current" => Some(TargetMode::Current), + _ => None, + }) + .map(Command::ShowRecommendations), "noop" => Some(Command::Noop), _ => None, } diff --git a/src/commands.rs b/src/commands.rs index 0a38302..f18d573 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -258,6 +258,7 @@ impl CommandManager { | Command::Delete | Command::Back | Command::Open(_) + | Command::ShowRecommendations(_) | Command::Insert(_) | Command::Goto(_) => Ok(None), _ => Err("Unknown Command".into()), @@ -390,6 +391,15 @@ impl CommandManager { kb.insert("a".into(), Command::Goto(GotoMode::Album)); kb.insert("A".into(), Command::Goto(GotoMode::Artist)); + kb.insert( + "m".into(), + Command::ShowRecommendations(TargetMode::Selected), + ); + kb.insert( + "M".into(), + Command::ShowRecommendations(TargetMode::Current), + ); + kb.insert("Up".into(), Command::Move(MoveMode::Up, Default::default())); kb.insert( "p".into(), diff --git a/src/playlist.rs b/src/playlist.rs index 86c5377..6c5691d 100644 --- a/src/playlist.rs +++ b/src/playlist.rs @@ -1,6 +1,9 @@ -use std::sync::Arc; +use std::collections::HashSet; +use std::sync::{Arc, RwLock}; use std::{cmp::Ordering, iter::Iterator}; +use rand::{seq::IteratorRandom, thread_rng}; + use log::debug; use rspotify::model::playlist::{FullPlaylist, SimplifiedPlaylist}; @@ -9,7 +12,7 @@ use crate::queue::Queue; use crate::spotify::Spotify; use crate::track::Track; use crate::traits::{IntoBoxedViewExt, ListItem, ViewExt}; -use crate::ui::playlist::PlaylistView; +use crate::ui::{listview::ListView, playlist::PlaylistView}; use crate::{command::SortDirection, command::SortKey, library::Library}; #[derive(Clone, Debug, Deserialize, Serialize)] @@ -280,6 +283,47 @@ impl ListItem for Playlist { Some(PlaylistView::new(queue, library, self).into_boxed_view_ext()) } + fn open_recommendations( + &mut self, + queue: Arc, + library: Arc, + ) -> Option> { + self.load_tracks(queue.get_spotify()); + const MAX_SEEDS: usize = 5; + let track_ids: Vec = self + .tracks + .as_ref()? + .iter() + .map(|t| t.id.clone()) + .flatten() + // only select unique tracks + .collect::>() + .into_iter() + // spotify allows at max 5 seed items, so choose them at random + .choose_multiple(&mut thread_rng(), MAX_SEEDS); + + if track_ids.is_empty() { + return None; + } + + let spotify = queue.get_spotify(); + let recommendations: Option> = spotify + .api + .recommendations(None, None, Some(track_ids)) + .map(|r| r.tracks) + .map(|tracks| tracks.iter().map(Track::from).collect()); + + recommendations.map(|tracks| { + ListView::new( + Arc::new(RwLock::new(tracks)), + queue.clone(), + library.clone(), + ) + .set_title(format!("Similar to Tracks in \"{}\"", self.name,)) + .into_boxed_view_ext() + }) + } + fn share_url(&self) -> Option { Some(format!( "https://open.spotify.com/user/{}/playlist/{}", diff --git a/src/track.rs b/src/track.rs index bc9c9fd..6d5257a 100644 --- a/src/track.rs +++ b/src/track.rs @@ -243,7 +243,7 @@ impl ListItem for Track { } fn open_recommendations( - &self, + &mut self, queue: Arc, library: Arc, ) -> Option> { diff --git a/src/traits.rs b/src/traits.rs index b31c5e2..829e394 100644 --- a/src/traits.rs +++ b/src/traits.rs @@ -27,7 +27,7 @@ pub trait ListItem: Sync + Send + 'static { fn unsave(&mut self, library: Arc); fn open(&self, queue: Arc, library: Arc) -> Option>; fn open_recommendations( - &self, + &mut self, _queue: Arc, _library: Arc, ) -> Option> { diff --git a/src/ui/contextmenu.rs b/src/ui/contextmenu.rs index dd65bb2..07a194d 100644 --- a/src/ui/contextmenu.rs +++ b/src/ui/contextmenu.rs @@ -38,7 +38,7 @@ enum ContextMenuAction { SelectArtist(Vec), ShareUrl(String), AddToPlaylist(Box), - ShowRecommendations(Box), + ShowRecommendations(Track), ToggleTrackSavedStatus(Box), } @@ -162,7 +162,7 @@ impl ContextMenu { ); content.add_item( "Similar tracks", - ContextMenuAction::ShowRecommendations(Box::new(t.clone())), + ContextMenuAction::ShowRecommendations(t.clone()), ); content.add_item( match library.is_saved_track(&Playable::Track(t.clone())) { @@ -195,7 +195,7 @@ impl ContextMenu { s.add_layer(dialog); } ContextMenuAction::ShowRecommendations(item) => { - if let Some(view) = item.open_recommendations(queue, library) { + if let Some(view) = item.to_owned().open_recommendations(queue, library) { s.call_on_name("main", move |v: &mut Layout| v.push_view(view)); } } diff --git a/src/ui/listview.rs b/src/ui/listview.rs index bcf130a..a3a0422 100644 --- a/src/ui/listview.rs +++ b/src/ui/listview.rs @@ -576,6 +576,25 @@ impl ViewExt for ListView { return Ok(CommandResult::Consumed(None)); } + Command::ShowRecommendations(mode) => { + let queue = self.queue.clone(); + let library = self.library.clone(); + let target: Option> = match mode { + TargetMode::Current => self.queue.get_current().map(|t| t.as_listitem()), + TargetMode::Selected => { + let content = self.content.read().unwrap(); + content.get(self.selected).map(|t| t.as_listitem()) + } + }; + + if let Some(mut target) = target { + let view = target.open_recommendations(queue.clone(), library.clone()); + return match view { + Some(view) => Ok(CommandResult::View(view)), + None => Ok(CommandResult::Consumed(None)), + }; + } + } _ => {} };