Add command to show recommendations. (#593)
* Add command to show recommendations. This adds a command `similar selected|current` which enables searching for track recommendations for playlists, albums as well as single tracks. * Make sure to only send 5 seed items in total. * Add docs for recommendation bindings to the README
This commit is contained in:
@@ -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
|
* `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 album view for the selected item
|
||||||
* `A` will open the artist 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
|
* `Ctrl-v` will open the context menu for a Spotify link in your clipboard
|
||||||
* `Backspace` closes the current view
|
* `Backspace` closes the current view
|
||||||
* `Shift-p` toggles playback of a track (play/pause)
|
* `Shift-p` toggles playback of a track (play/pause)
|
||||||
|
|||||||
49
src/album.rs
49
src/album.rs
@@ -1,5 +1,6 @@
|
|||||||
|
use rand::{seq::IteratorRandom, thread_rng};
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::sync::Arc;
|
use std::sync::{Arc, RwLock};
|
||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use log::debug;
|
use log::debug;
|
||||||
@@ -12,7 +13,7 @@ use crate::queue::Queue;
|
|||||||
use crate::spotify::Spotify;
|
use crate::spotify::Spotify;
|
||||||
use crate::track::Track;
|
use crate::track::Track;
|
||||||
use crate::traits::{IntoBoxedViewExt, ListItem, ViewExt};
|
use crate::traits::{IntoBoxedViewExt, ListItem, ViewExt};
|
||||||
use crate::ui::album::AlbumView;
|
use crate::ui::{album::AlbumView, listview::ListView};
|
||||||
|
|
||||||
#[derive(Clone, Deserialize, Serialize)]
|
#[derive(Clone, Deserialize, Serialize)]
|
||||||
pub struct Album {
|
pub struct Album {
|
||||||
@@ -229,6 +230,50 @@ impl ListItem for Album {
|
|||||||
Some(AlbumView::new(queue, library, self).into_boxed_view_ext())
|
Some(AlbumView::new(queue, library, self).into_boxed_view_ext())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn open_recommendations(
|
||||||
|
&mut self,
|
||||||
|
queue: Arc<Queue>,
|
||||||
|
library: Arc<Library>,
|
||||||
|
) -> Option<Box<dyn ViewExt>> {
|
||||||
|
self.load_all_tracks(queue.get_spotify());
|
||||||
|
const MAX_SEEDS: usize = 5;
|
||||||
|
let track_ids: Vec<String> = 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<String> = 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<Vec<Track>> = 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<String> {
|
fn share_url(&self) -> Option<String> {
|
||||||
self.id
|
self.id
|
||||||
.clone()
|
.clone()
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::sync::Arc;
|
use std::sync::{Arc, RwLock};
|
||||||
|
|
||||||
use rspotify::model::artist::{FullArtist, SimplifiedArtist};
|
use rspotify::model::artist::{FullArtist, SimplifiedArtist};
|
||||||
|
|
||||||
@@ -9,7 +9,7 @@ use crate::queue::Queue;
|
|||||||
use crate::spotify::Spotify;
|
use crate::spotify::Spotify;
|
||||||
use crate::track::Track;
|
use crate::track::Track;
|
||||||
use crate::traits::{IntoBoxedViewExt, ListItem, ViewExt};
|
use crate::traits::{IntoBoxedViewExt, ListItem, ViewExt};
|
||||||
use crate::ui::artist::ArtistView;
|
use crate::ui::{artist::ArtistView, listview::ListView};
|
||||||
|
|
||||||
#[derive(Clone, Deserialize, Serialize)]
|
#[derive(Clone, Deserialize, Serialize)]
|
||||||
pub struct Artist {
|
pub struct Artist {
|
||||||
@@ -174,6 +174,31 @@ impl ListItem for Artist {
|
|||||||
Some(ArtistView::new(queue, library, self).into_boxed_view_ext())
|
Some(ArtistView::new(queue, library, self).into_boxed_view_ext())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn open_recommendations(
|
||||||
|
&mut self,
|
||||||
|
queue: Arc<Queue>,
|
||||||
|
library: Arc<Library>,
|
||||||
|
) -> Option<Box<dyn ViewExt>> {
|
||||||
|
let id = self.id.as_ref()?.to_string();
|
||||||
|
|
||||||
|
let spotify = queue.get_spotify();
|
||||||
|
let recommendations: Option<Vec<Track>> = 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<String> {
|
fn share_url(&self) -> Option<String> {
|
||||||
self.id
|
self.id
|
||||||
.clone()
|
.clone()
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ pub enum Command {
|
|||||||
NewPlaylist(String),
|
NewPlaylist(String),
|
||||||
Sort(SortKey, SortDirection),
|
Sort(SortKey, SortDirection),
|
||||||
Logout,
|
Logout,
|
||||||
|
ShowRecommendations(TargetMode),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Display for Command {
|
impl fmt::Display for Command {
|
||||||
@@ -193,6 +194,7 @@ impl fmt::Display for Command {
|
|||||||
Command::NewPlaylist(name) => format!("new playlist {}", name),
|
Command::NewPlaylist(name) => format!("new playlist {}", name),
|
||||||
Command::Sort(key, direction) => format!("sort {} {}", key, direction),
|
Command::Sort(key, direction) => format!("sort {} {}", key, direction),
|
||||||
Command::Logout => "logout".to_string(),
|
Command::Logout => "logout".to_string(),
|
||||||
|
Command::ShowRecommendations(mode) => format!("similar {}", mode),
|
||||||
};
|
};
|
||||||
write!(f, "{}", repr)
|
write!(f, "{}", repr)
|
||||||
}
|
}
|
||||||
@@ -422,6 +424,14 @@ pub fn parse(input: &str) -> Option<Command> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
"logout" => Some(Command::Logout),
|
"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),
|
"noop" => Some(Command::Noop),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -258,6 +258,7 @@ impl CommandManager {
|
|||||||
| Command::Delete
|
| Command::Delete
|
||||||
| Command::Back
|
| Command::Back
|
||||||
| Command::Open(_)
|
| Command::Open(_)
|
||||||
|
| Command::ShowRecommendations(_)
|
||||||
| Command::Insert(_)
|
| Command::Insert(_)
|
||||||
| Command::Goto(_) => Ok(None),
|
| Command::Goto(_) => Ok(None),
|
||||||
_ => Err("Unknown Command".into()),
|
_ => 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::Album));
|
||||||
kb.insert("A".into(), Command::Goto(GotoMode::Artist));
|
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("Up".into(), Command::Move(MoveMode::Up, Default::default()));
|
||||||
kb.insert(
|
kb.insert(
|
||||||
"p".into(),
|
"p".into(),
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
use std::sync::Arc;
|
use std::collections::HashSet;
|
||||||
|
use std::sync::{Arc, RwLock};
|
||||||
use std::{cmp::Ordering, iter::Iterator};
|
use std::{cmp::Ordering, iter::Iterator};
|
||||||
|
|
||||||
|
use rand::{seq::IteratorRandom, thread_rng};
|
||||||
|
|
||||||
use log::debug;
|
use log::debug;
|
||||||
use rspotify::model::playlist::{FullPlaylist, SimplifiedPlaylist};
|
use rspotify::model::playlist::{FullPlaylist, SimplifiedPlaylist};
|
||||||
|
|
||||||
@@ -9,7 +12,7 @@ use crate::queue::Queue;
|
|||||||
use crate::spotify::Spotify;
|
use crate::spotify::Spotify;
|
||||||
use crate::track::Track;
|
use crate::track::Track;
|
||||||
use crate::traits::{IntoBoxedViewExt, ListItem, ViewExt};
|
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};
|
use crate::{command::SortDirection, command::SortKey, library::Library};
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
@@ -280,6 +283,47 @@ impl ListItem for Playlist {
|
|||||||
Some(PlaylistView::new(queue, library, self).into_boxed_view_ext())
|
Some(PlaylistView::new(queue, library, self).into_boxed_view_ext())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn open_recommendations(
|
||||||
|
&mut self,
|
||||||
|
queue: Arc<Queue>,
|
||||||
|
library: Arc<Library>,
|
||||||
|
) -> Option<Box<dyn ViewExt>> {
|
||||||
|
self.load_tracks(queue.get_spotify());
|
||||||
|
const MAX_SEEDS: usize = 5;
|
||||||
|
let track_ids: Vec<String> = self
|
||||||
|
.tracks
|
||||||
|
.as_ref()?
|
||||||
|
.iter()
|
||||||
|
.map(|t| t.id.clone())
|
||||||
|
.flatten()
|
||||||
|
// only select unique tracks
|
||||||
|
.collect::<HashSet<_>>()
|
||||||
|
.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<Vec<Track>> = 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<String> {
|
fn share_url(&self) -> Option<String> {
|
||||||
Some(format!(
|
Some(format!(
|
||||||
"https://open.spotify.com/user/{}/playlist/{}",
|
"https://open.spotify.com/user/{}/playlist/{}",
|
||||||
|
|||||||
@@ -243,7 +243,7 @@ impl ListItem for Track {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn open_recommendations(
|
fn open_recommendations(
|
||||||
&self,
|
&mut self,
|
||||||
queue: Arc<Queue>,
|
queue: Arc<Queue>,
|
||||||
library: Arc<Library>,
|
library: Arc<Library>,
|
||||||
) -> Option<Box<dyn ViewExt>> {
|
) -> Option<Box<dyn ViewExt>> {
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ pub trait ListItem: Sync + Send + 'static {
|
|||||||
fn unsave(&mut self, library: Arc<Library>);
|
fn unsave(&mut self, library: Arc<Library>);
|
||||||
fn open(&self, queue: Arc<Queue>, library: Arc<Library>) -> Option<Box<dyn ViewExt>>;
|
fn open(&self, queue: Arc<Queue>, library: Arc<Library>) -> Option<Box<dyn ViewExt>>;
|
||||||
fn open_recommendations(
|
fn open_recommendations(
|
||||||
&self,
|
&mut self,
|
||||||
_queue: Arc<Queue>,
|
_queue: Arc<Queue>,
|
||||||
_library: Arc<Library>,
|
_library: Arc<Library>,
|
||||||
) -> Option<Box<dyn ViewExt>> {
|
) -> Option<Box<dyn ViewExt>> {
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ enum ContextMenuAction {
|
|||||||
SelectArtist(Vec<Artist>),
|
SelectArtist(Vec<Artist>),
|
||||||
ShareUrl(String),
|
ShareUrl(String),
|
||||||
AddToPlaylist(Box<Track>),
|
AddToPlaylist(Box<Track>),
|
||||||
ShowRecommendations(Box<dyn ListItem>),
|
ShowRecommendations(Track),
|
||||||
ToggleTrackSavedStatus(Box<Track>),
|
ToggleTrackSavedStatus(Box<Track>),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,7 +162,7 @@ impl ContextMenu {
|
|||||||
);
|
);
|
||||||
content.add_item(
|
content.add_item(
|
||||||
"Similar tracks",
|
"Similar tracks",
|
||||||
ContextMenuAction::ShowRecommendations(Box::new(t.clone())),
|
ContextMenuAction::ShowRecommendations(t.clone()),
|
||||||
);
|
);
|
||||||
content.add_item(
|
content.add_item(
|
||||||
match library.is_saved_track(&Playable::Track(t.clone())) {
|
match library.is_saved_track(&Playable::Track(t.clone())) {
|
||||||
@@ -195,7 +195,7 @@ impl ContextMenu {
|
|||||||
s.add_layer(dialog);
|
s.add_layer(dialog);
|
||||||
}
|
}
|
||||||
ContextMenuAction::ShowRecommendations(item) => {
|
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));
|
s.call_on_name("main", move |v: &mut Layout| v.push_view(view));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -576,6 +576,25 @@ impl<I: ListItem + Clone> ViewExt for ListView<I> {
|
|||||||
|
|
||||||
return Ok(CommandResult::Consumed(None));
|
return Ok(CommandResult::Consumed(None));
|
||||||
}
|
}
|
||||||
|
Command::ShowRecommendations(mode) => {
|
||||||
|
let queue = self.queue.clone();
|
||||||
|
let library = self.library.clone();
|
||||||
|
let target: Option<Box<dyn ListItem>> = 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)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user