From 210c7d9f4e678d4905ed8bb14a7e3bbd42ced02d Mon Sep 17 00:00:00 2001 From: KoffeinFlummi Date: Tue, 16 Apr 2019 19:52:22 +0200 Subject: [PATCH] Implement saved tracks, albums, and artists --- src/album.rs | 19 +- src/artist.rs | 32 +++- src/commands.rs | 11 +- src/library.rs | 445 ++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 31 +-- src/playlist.rs | 47 +++++ src/playlists.rs | 262 -------------------------- src/spotify.rs | 19 +- src/track.rs | 27 ++- src/ui/library.rs | 79 ++++++++ src/ui/mod.rs | 2 +- src/ui/playlists.rs | 74 -------- src/ui/queue.rs | 24 +-- src/ui/search.rs | 7 +- 14 files changed, 688 insertions(+), 391 deletions(-) create mode 100644 src/library.rs create mode 100644 src/playlist.rs delete mode 100644 src/playlists.rs create mode 100644 src/ui/library.rs delete mode 100644 src/ui/playlists.rs diff --git a/src/album.rs b/src/album.rs index 1bd0e0b..54d7124 100644 --- a/src/album.rs +++ b/src/album.rs @@ -1,7 +1,8 @@ use std::fmt; use std::sync::Arc; -use rspotify::spotify::model::album::{FullAlbum, SimplifiedAlbum}; +use chrono::{DateTime, Utc}; +use rspotify::spotify::model::album::{FullAlbum, SavedAlbum, SimplifiedAlbum}; use queue::Queue; use spotify::Spotify; @@ -13,14 +14,16 @@ pub struct Album { pub id: String, pub title: String, pub artists: Vec, + pub artist_ids: Vec, pub year: String, pub cover_url: Option, pub url: String, pub tracks: Option>, + pub added_at: Option> } impl Album { - fn load_tracks(&mut self, spotify: Arc) { + pub fn load_tracks(&mut self, spotify: Arc) { if self.tracks.is_some() { return; } @@ -43,10 +46,12 @@ impl From<&SimplifiedAlbum> for Album { id: sa.id.clone(), title: sa.name.clone(), artists: sa.artists.iter().map(|sa| sa.name.clone()).collect(), + artist_ids: sa.artists.iter().map(|sa| sa.id.clone()).collect(), year: sa.release_date.split('-').next().unwrap().into(), cover_url: sa.images.get(0).map(|i| i.url.clone()), url: sa.uri.clone(), tracks: None, + added_at: None, } } } @@ -65,14 +70,24 @@ impl From<&FullAlbum> for Album { id: fa.id.clone(), title: fa.name.clone(), artists: fa.artists.iter().map(|sa| sa.name.clone()).collect(), + artist_ids: fa.artists.iter().map(|sa| sa.id.clone()).collect(), year: fa.release_date.split('-').next().unwrap().into(), cover_url: fa.images.get(0).map(|i| i.url.clone()), url: fa.uri.clone(), tracks, + added_at: None, } } } +impl From<&SavedAlbum> for Album { + fn from(sa: &SavedAlbum) -> Self { + let mut album: Self = (&sa.album).into(); + album.added_at = Some(sa.added_at); + album + } +} + impl fmt::Display for Album { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "{} - {}", self.artists.join(", "), self.title) diff --git a/src/artist.rs b/src/artist.rs index f39685b..4fe16b0 100644 --- a/src/artist.rs +++ b/src/artist.rs @@ -1,7 +1,7 @@ use std::fmt; use std::sync::Arc; -use rspotify::spotify::model::artist::FullArtist; +use rspotify::spotify::model::artist::{FullArtist, SimplifiedArtist}; use album::Album; use queue::Queue; @@ -15,11 +15,15 @@ pub struct Artist { pub name: String, pub url: String, pub albums: Option>, + pub tracks: Option>, } impl Artist { fn load_albums(&mut self, spotify: Arc) { - if self.albums.is_some() { + if let Some(albums) = self.albums.as_mut() { + for album in albums { + album.load_tracks(spotify.clone()); + } return; } @@ -41,7 +45,9 @@ impl Artist { } fn tracks(&self) -> Option> { - if let Some(albums) = self.albums.as_ref() { + if let Some(tracks) = self.tracks.as_ref() { + Some(tracks.iter().collect()) + } else if let Some(albums) = self.albums.as_ref() { Some( albums .iter() @@ -55,6 +61,18 @@ impl Artist { } } +impl From<&SimplifiedArtist> for Artist { + fn from(sa: &SimplifiedArtist) -> Self { + Self { + id: sa.id.clone(), + name: sa.name.clone(), + url: sa.uri.clone(), + albums: None, + tracks: None, + } + } +} + impl From<&FullArtist> for Artist { fn from(fa: &FullArtist) -> Self { Self { @@ -62,6 +80,7 @@ impl From<&FullArtist> for Artist { name: fa.name.clone(), url: fa.uri.clone(), albums: None, + tracks: None, } } } @@ -100,7 +119,12 @@ impl ListItem for Artist { } fn display_right(&self) -> String { - "".into() + // TODO: indicate following status + if let Some(tracks) = self.tracks.as_ref() { + format!("{} saved tracks", tracks.len()) + } else { + "".into() + } } fn play(&mut self, queue: Arc) { diff --git a/src/commands.rs b/src/commands.rs index 3b6ca18..3b02055 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -5,7 +5,7 @@ use cursive::event::{Event, Key}; use cursive::views::ViewRef; use cursive::Cursive; -use playlists::Playlists; +use library::Library; use queue::{Queue, RepeatSetting}; use spotify::Spotify; use traits::ViewExt; @@ -47,7 +47,7 @@ impl CommandManager { &mut self, spotify: Arc, queue: Arc, - playlists: Arc, + library: Arc, ) { self.register_aliases("quit", vec!["q", "x"]); self.register_aliases("playpause", vec!["pause", "toggleplay", "toggleplayback"]); @@ -113,14 +113,13 @@ impl CommandManager { } { - let playlists = playlists.clone(); + let library = library.clone(); self.register_command( "playlists", Some(Box::new(move |_s, args| { if let Some(arg) = args.get(0) { if arg == "update" { - playlists.fetch_playlists(); - playlists.save_cache(); + library.update_playlists(); } } Ok(None) @@ -303,7 +302,7 @@ impl CommandManager { kb.insert("F1".into(), "focus queue".into()); kb.insert("F2".into(), "focus search".into()); - kb.insert("F3".into(), "focus playlists".into()); + kb.insert("F3".into(), "focus library".into()); kb.insert("Up".into(), "move up".into()); kb.insert("Down".into(), "move down".into()); diff --git a/src/library.rs b/src/library.rs new file mode 100644 index 0000000..03320cf --- /dev/null +++ b/src/library.rs @@ -0,0 +1,445 @@ +use std::collections::HashMap; +use std::iter::Iterator; +use std::ops::Deref; +use std::path::PathBuf; +use std::sync::{Arc, RwLock, RwLockReadGuard}; +use std::thread; + +use rspotify::spotify::model::artist::SimplifiedArtist; +use rspotify::spotify::model::playlist::{FullPlaylist, SimplifiedPlaylist}; +use serde::de::DeserializeOwned; +use serde::Serialize; + +use album::Album; +use artist::Artist; +use config; +use events::EventManager; +use playlist::Playlist; +use spotify::Spotify; +use track::Track; + +const CACHE_TRACKS: &str = "tracks.db"; +const CACHE_ALBUMS: &str = "albums.db"; +const CACHE_ARTISTS: &str = "artists.db"; +const CACHE_PLAYLISTS: &str = "playlists.db"; + +#[derive(Clone)] +pub struct Library { + pub tracks: Arc>>, + pub albums: Arc>>, + pub artists: Arc>>, + pub playlists: Arc>>, + ev: EventManager, + spotify: Arc, +} + +impl Library { + pub fn new(ev: &EventManager, spotify: Arc) -> Self { + let library = Self { + tracks: Arc::new(RwLock::new(Vec::new())), + albums: Arc::new(RwLock::new(Vec::new())), + artists: Arc::new(RwLock::new(Vec::new())), + playlists: Arc::new(RwLock::new(Vec::new())), + ev: ev.clone(), + spotify, + }; + + { + // download playlists via web api in a background thread + let library = library.clone(); + thread::spawn(move || { + // load cache (if existing) + library.load_caches(); + + library.fetch_artists(); + library.fetch_tracks(); + library.fetch_albums(); + library.fetch_playlists(); + + library.populate_artists(); + + // re-cache for next startup + library.save_caches(); + }); + } + + library + } + + pub fn items(&self) -> RwLockReadGuard> { + self.playlists + .read() + .expect("could not readlock listview content") + } + + fn load_cache(&self, cache_path: PathBuf, store: Arc>>) { + if let Ok(contents) = std::fs::read_to_string(&cache_path) { + debug!("loading cache from {}", cache_path.display()); + let parsed: Result, _> = serde_json::from_str(&contents); + match parsed { + Ok(cache) => { + debug!("cache from {} loaded ({} lists)", cache_path.display(), cache.len()); + let mut store = store.write().expect("can't writelock store"); + store.clear(); + store.extend(cache); + + // force refresh of UI (if visible) + self.ev.trigger(); + } + Err(e) => { + error!("can't parse cache: {}", e); + } + } + } + } + + fn load_caches(&self) { + self.load_cache(config::cache_path(CACHE_TRACKS), self.tracks.clone()); + self.load_cache(config::cache_path(CACHE_ALBUMS), self.albums.clone()); + self.load_cache(config::cache_path(CACHE_ARTISTS), self.artists.clone()); + self.load_cache(config::cache_path(CACHE_PLAYLISTS), self.playlists.clone()); + } + + fn save_cache(&self, cache_path: PathBuf, store: Arc>>) { + match serde_json::to_string(&store.deref()) { + Ok(contents) => std::fs::write(cache_path, contents).unwrap(), + Err(e) => error!("could not write cache: {:?}", e), + } + } + + fn save_caches(&self) { + self.save_cache(config::cache_path(CACHE_TRACKS), self.tracks.clone()); + self.save_cache(config::cache_path(CACHE_ALBUMS), self.albums.clone()); + self.save_cache(config::cache_path(CACHE_ARTISTS), self.artists.clone()); + self.save_cache(config::cache_path(CACHE_PLAYLISTS), self.playlists.clone()); + } + + pub fn process_simplified_playlist(list: &SimplifiedPlaylist, spotify: &Spotify) -> Playlist { + Self::_process_playlist( + list.id.clone(), + list.name.clone(), + list.snapshot_id.clone(), + spotify, + ) + } + + pub fn process_full_playlist(list: &FullPlaylist, spotify: &Spotify) -> Playlist { + Self::_process_playlist( + list.id.clone(), + list.name.clone(), + list.snapshot_id.clone(), + spotify, + ) + } + + fn _process_playlist( + id: String, + name: String, + snapshot_id: String, + spotify: &Spotify, + ) -> Playlist { + let mut collected_tracks = Vec::new(); + + let mut tracks_result = spotify.user_playlist_tracks(&id, 100, 0); + while let Some(ref tracks) = tracks_result.clone() { + for listtrack in &tracks.items { + collected_tracks.push((&listtrack.track).into()); + } + debug!("got {} tracks", tracks.items.len()); + + // load next batch if necessary + tracks_result = match tracks.next { + Some(_) => { + debug!("requesting tracks again.."); + spotify.user_playlist_tracks( + &id, + 100, + tracks.offset + tracks.items.len() as u32, + ) + } + None => None, + } + } + Playlist { + id: id.clone(), + name: name.clone(), + snapshot_id: snapshot_id.clone(), + tracks: collected_tracks, + } + } + + fn needs_download(&self, remote: &SimplifiedPlaylist) -> bool { + for local in self.playlists.read().expect("can't readlock playlists").iter() { + if local.id == remote.id { + return local.snapshot_id != remote.snapshot_id; + } + } + true + } + + fn append_or_update(&self, updated: &Playlist) -> usize { + let mut store = self.playlists.write().expect("can't writelock playlists"); + for (index, mut local) in store.iter_mut().enumerate() { + if local.id == updated.id { + *local = updated.clone(); + return index; + } + } + store.push(updated.clone()); + store.len() - 1 + } + + pub fn delete_playlist(&self, id: &str) { + let mut store = self.playlists.write().expect("can't writelock playlists"); + if let Some(position) = store.iter().position(|ref i| i.id == id) { + if self.spotify.delete_playlist(id) { + store.remove(position); + self.save_cache(config::cache_path(CACHE_PLAYLISTS), self.playlists.clone()); + } + } + } + + pub fn overwrite_playlist(&self, id: &str, tracks: &[Track]) { + debug!("saving {} tracks to {}", tracks.len(), id); + self.spotify.overwrite_playlist(id, &tracks); + + self.update_playlists(); + } + + pub fn save_playlist(&self, name: &str, tracks: &[Track]) { + debug!("saving {} tracks to new list {}", tracks.len(), name); + match self.spotify.create_playlist(name, None, None) { + Some(id) => self.overwrite_playlist(&id, &tracks), + None => error!("could not create new playlist.."), + } + } + + pub fn update_playlists(&self) { + self.fetch_playlists(); + self.save_cache(config::cache_path(CACHE_PLAYLISTS), self.playlists.clone()); + } + + fn fetch_playlists(&self) { + debug!("loading playlists"); + let mut stale_lists = self.playlists.read().unwrap().clone(); + + let mut lists_result = self.spotify.current_user_playlist(50, 0); + while let Some(ref lists) = lists_result.clone() { + for remote in &lists.items { + // remove from stale playlists so we won't prune it later on + if let Some(index) = stale_lists.iter().position(|x| x.id == remote.id) { + stale_lists.remove(index); + } + + if self.needs_download(remote) { + info!("updating playlist {}", remote.name); + let playlist = Self::process_simplified_playlist(remote, &self.spotify); + self.append_or_update(&playlist); + // trigger redraw + self.ev.trigger(); + } + } + + // load next batch if necessary + lists_result = match lists.next { + Some(_) => { + debug!("requesting playlists again.."); + self.spotify + .current_user_playlist(50, lists.offset + lists.items.len() as u32) + } + None => None, + } + } + + // remove stale playlists + for stale in stale_lists { + let index = self + .playlists + .read() + .unwrap() + .iter() + .position(|x| x.id == stale.id); + if let Some(index) = index { + debug!("removing stale list: {:?}", stale.name); + self.playlists.write().unwrap().remove(index); + } + } + // trigger redraw + self.ev.trigger(); + } + + fn fetch_artists(&self) { + let mut artists: Vec = Vec::new(); + let mut last: Option = None; + + let mut i: u32 = 0; + + loop { + let page = self.spotify.current_user_followed_artists(last); + debug!("artists page: {}", i); + i += 1; + if page.is_none() { + error!("Failed to fetch artists."); + return; + } + let page = page.unwrap(); + + artists.extend(page.items.iter().map(|fa| fa.into())); + + if page.next.is_some() { + last = Some(artists.last().unwrap().id.clone()); + } else { + break; + } + } + + for artist in artists.iter_mut() { + // Only play saved tracks + artist.albums = Some(Vec::new()); + artist.tracks = Some(Vec::new()); + } + + *(self.artists.write().unwrap()) = artists; + } + + fn insert_artist(&self, artist: &SimplifiedArtist) { + let mut artists = self.artists.write().unwrap(); + if artists.iter().any(|a| a.id == artist.id) { + return; + } + + artists.push(artist.into()); + } + + fn fetch_albums(&self) { + let mut albums: Vec = Vec::new(); + + let mut i: u32 = 0; + + loop { + let page = self.spotify.current_user_saved_albums(albums.len() as u32); + debug!("albums page: {}", i); + i += 1; + if page.is_none() { + error!("Failed to fetch albums."); + return; + } + let page = page.unwrap(); + + albums.extend(page.items.iter().map(|a| a.into())); + + if page.next.is_none() { + break; + } + } + + *(self.albums.write().unwrap()) = albums; + } + + fn fetch_tracks(&self) { + let mut tracks: Vec = Vec::new(); + + let mut i: u32 = 0; + + loop { + let page = self.spotify.current_user_saved_tracks(tracks.len() as u32); + + debug!("tracks page: {}", i); + i += 1; + + if page.is_none() { + error!("Failed to fetch tracks."); + return; + } + let page = page.unwrap(); + + if page.offset == 0 { + // If first page matches the first items in store and total is + // identical, assume list is unchanged. + + let store = self.tracks.read().unwrap(); + + if page.total as usize == store.len() && + !page.items + .iter() + .enumerate() + .any(|(i, t)| &t.track.id != &store[i].id) + { + return; + } + } + + for track in page.items.iter() { + for artist in track.track.artists.iter() { + self.insert_artist(artist); + } + tracks.push(track.into()); + } + + if page.next.is_none() { + break; + } + } + + *(self.tracks.write().unwrap()) = tracks; + } + + fn populate_artists(&self) { + let mut artists = self.artists.write().unwrap(); + let mut lookup: HashMap> = HashMap::new(); + + artists.sort_unstable_by(|a, b| a.name.partial_cmp(&b.name).unwrap()); + + { + let albums = self.albums.read().unwrap(); + for album in albums.iter() { + for artist_id in &album.artist_ids { + let index = if let Some(i) = lookup.get(artist_id).cloned() { + i + } else { + let i = artists.iter().position(|a| &a.id == artist_id); + lookup.insert(artist_id.clone(), i); + i + }; + + if let Some(i) = index { + let mut artist = artists.get_mut(i).unwrap(); + if artist.albums.is_none() { + artist.albums = Some(Vec::new()); + } + + if let Some(albums) = artist.albums.as_mut() { + albums.push(album.clone()); + } + } + } + } + } + + { + let tracks = self.tracks.read().unwrap(); + for track in tracks.iter() { + for artist_id in &track.artist_ids { + let index = if let Some(i) = lookup.get(artist_id).cloned() { + i + } else { + let i = artists.iter().position(|a| &a.id == artist_id); + lookup.insert(artist_id.clone(), i); + i + }; + + if let Some(i) = index { + let mut artist = artists.get_mut(i).unwrap(); + if artist.tracks.is_none() { + artist.tracks = Some(Vec::new()); + } + + if let Some(tracks) = artist.tracks.as_mut() { + tracks.push(track.clone()); + } + } + } + } + } + } +} diff --git a/src/main.rs b/src/main.rs index 1145f3d..41a7322 100644 --- a/src/main.rs +++ b/src/main.rs @@ -31,7 +31,6 @@ extern crate rand; use std::fs; use std::process; use std::sync::Arc; -use std::thread; use clap::{App, Arg}; use cursive::traits::Identifiable; @@ -45,7 +44,8 @@ mod authentication; mod commands; mod config; mod events; -mod playlists; +mod library; +mod playlist; mod queue; mod spotify; mod theme; @@ -58,7 +58,7 @@ mod mpris; use commands::CommandManager; use events::{Event, EventManager}; -use playlists::Playlists; +use library::Library; use spotify::PlayerEvent; fn setup_logging(filename: &str) -> Result<(), fern::InitError> { @@ -156,25 +156,10 @@ fn main() { #[cfg(feature = "mpris")] let mpris_manager = Arc::new(mpris::MprisManager::new(spotify.clone(), queue.clone())); - let playlists = Arc::new(Playlists::new(&event_manager, &spotify)); - - { - // download playlists via web api in a background thread - let playlists = playlists.clone(); - thread::spawn(move || { - // load cache (if existing) - playlists.load_cache(); - - // fetch or update cached playlists - playlists.fetch_playlists(); - - // re-cache for next startup - playlists.save_cache(); - }); - } + let library = Arc::new(Library::new(&event_manager, spotify.clone())); let mut cmd_manager = CommandManager::new(); - cmd_manager.register_all(spotify.clone(), queue.clone(), playlists.clone()); + cmd_manager.register_all(spotify.clone(), queue.clone(), library.clone()); let cmd_manager = Arc::new(cmd_manager); CommandManager::register_keybindings( @@ -185,9 +170,9 @@ fn main() { let search = ui::search::SearchView::new(event_manager.clone(), spotify.clone(), queue.clone()); - let playlistsview = ui::playlists::PlaylistView::new(&playlists, queue.clone()); + let libraryview = ui::library::LibraryView::new(queue.clone(), library.clone()); - let queueview = ui::queue::QueueView::new(queue.clone(), playlists.clone()); + let queueview = ui::queue::QueueView::new(queue.clone(), library.clone()); let status = ui::statusbar::StatusBar::new( queue.clone(), @@ -197,7 +182,7 @@ fn main() { let mut layout = ui::layout::Layout::new(status, &event_manager, theme) .view("search", search.with_id("search"), "Search") - .view("playlists", playlistsview.with_id("playlists"), "Playlists") + .view("library", libraryview.with_id("library"), "Library") .view("queue", queueview, "Queue"); // initial view is queue diff --git a/src/playlist.rs b/src/playlist.rs new file mode 100644 index 0000000..e17b762 --- /dev/null +++ b/src/playlist.rs @@ -0,0 +1,47 @@ +use std::iter::Iterator; +use std::sync::Arc; + +use queue::Queue; +use track::Track; +use traits::ListItem; + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Playlist { + pub id: String, + pub name: String, + pub snapshot_id: String, + pub tracks: Vec, +} + +impl ListItem for Playlist { + fn is_playing(&self, queue: Arc) -> bool { + let playing: Vec = queue + .queue + .read() + .unwrap() + .iter() + .map(|t| t.id.clone()) + .collect(); + let ids: Vec = self.tracks.iter().map(|t| t.id.clone()).collect(); + !ids.is_empty() && playing == ids + } + + fn display_left(&self) -> String { + self.name.clone() + } + + fn display_right(&self) -> String { + format!("{} tracks", self.tracks.len()) + } + + fn play(&mut self, queue: Arc) { + let index = queue.append_next(self.tracks.iter().collect()); + queue.play(index, true); + } + + fn queue(&mut self, queue: Arc) { + for track in self.tracks.iter() { + queue.append(track); + } + } +} diff --git a/src/playlists.rs b/src/playlists.rs deleted file mode 100644 index 05e9c49..0000000 --- a/src/playlists.rs +++ /dev/null @@ -1,262 +0,0 @@ -use std::iter::Iterator; -use std::ops::Deref; -use std::path::PathBuf; -use std::sync::{Arc, RwLock, RwLockReadGuard}; - -use rspotify::spotify::model::playlist::{FullPlaylist, SimplifiedPlaylist}; - -use config; -use events::EventManager; -use queue::Queue; -use spotify::Spotify; -use track::Track; -use traits::ListItem; - -const CACHE_FILE: &str = "playlists.db"; - -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct Playlist { - pub id: String, - pub name: String, - pub snapshot_id: String, - pub tracks: Vec, -} - -#[derive(Clone)] -pub struct Playlists { - pub store: Arc>>, - ev: EventManager, - spotify: Arc, - cache_path: PathBuf, -} - -impl ListItem for Playlist { - fn is_playing(&self, queue: Arc) -> bool { - let playing: Vec = queue - .queue - .read() - .unwrap() - .iter() - .map(|t| t.id.clone()) - .collect(); - let ids: Vec = self.tracks.iter().map(|t| t.id.clone()).collect(); - !ids.is_empty() && playing == ids - } - - fn display_left(&self) -> String { - self.name.clone() - } - - fn display_right(&self) -> String { - format!("{} tracks", self.tracks.len()) - } - - fn play(&mut self, queue: Arc) { - let index = queue.append_next(self.tracks.iter().collect()); - queue.play(index, true); - } - - fn queue(&mut self, queue: Arc) { - for track in self.tracks.iter() { - queue.append(track); - } - } -} - -impl Playlists { - pub fn new(ev: &EventManager, spotify: &Arc) -> Playlists { - Playlists { - store: Arc::new(RwLock::new(Vec::new())), - ev: ev.clone(), - spotify: spotify.clone(), - cache_path: config::cache_path(CACHE_FILE), - } - } - - pub fn items(&self) -> RwLockReadGuard> { - self.store - .read() - .expect("could not readlock listview content") - } - - pub fn load_cache(&self) { - if let Ok(contents) = std::fs::read_to_string(&self.cache_path) { - debug!( - "loading playlist cache from {}", - self.cache_path.to_str().unwrap() - ); - let parsed: Result, _> = serde_json::from_str(&contents); - match parsed { - Ok(cache) => { - debug!("playlist cache loaded ({} lists)", cache.len()); - let mut store = self.store.write().expect("can't writelock playlist store"); - store.clear(); - store.extend(cache); - - // force refresh of UI (if visible) - self.ev.trigger(); - } - Err(e) => { - error!("can't parse playlist cache: {}", e); - } - } - } - } - - pub fn save_cache(&self) { - match serde_json::to_string(&self.store.deref()) { - Ok(contents) => std::fs::write(&self.cache_path, contents).unwrap(), - Err(e) => error!("could not write playlist cache: {:?}", e), - } - } - - pub fn process_simplified_playlist(list: &SimplifiedPlaylist, spotify: &Spotify) -> Playlist { - Playlists::_process_playlist( - list.id.clone(), - list.name.clone(), - list.snapshot_id.clone(), - spotify, - ) - } - - pub fn process_full_playlist(list: &FullPlaylist, spotify: &Spotify) -> Playlist { - Playlists::_process_playlist( - list.id.clone(), - list.name.clone(), - list.snapshot_id.clone(), - spotify, - ) - } - - pub fn _process_playlist( - id: String, - name: String, - snapshot_id: String, - spotify: &Spotify, - ) -> Playlist { - let mut collected_tracks = Vec::new(); - - let mut tracks_result = spotify.user_playlist_tracks(&id, 100, 0); - while let Some(ref tracks) = tracks_result.clone() { - for listtrack in &tracks.items { - collected_tracks.push((&listtrack.track).into()); - } - debug!("got {} tracks", tracks.items.len()); - - // load next batch if necessary - tracks_result = match tracks.next { - Some(_) => { - debug!("requesting tracks again.."); - spotify.user_playlist_tracks( - &id, - 100, - tracks.offset + tracks.items.len() as u32, - ) - } - None => None, - } - } - Playlist { - id: id.clone(), - name: name.clone(), - snapshot_id: snapshot_id.clone(), - tracks: collected_tracks, - } - } - - fn needs_download(&self, remote: &SimplifiedPlaylist) -> bool { - for local in self.store.read().expect("can't readlock playlists").iter() { - if local.id == remote.id { - return local.snapshot_id != remote.snapshot_id; - } - } - true - } - - fn append_or_update(&self, updated: &Playlist) -> usize { - let mut store = self.store.write().expect("can't writelock playlists"); - for (index, mut local) in store.iter_mut().enumerate() { - if local.id == updated.id { - *local = updated.clone(); - return index; - } - } - store.push(updated.clone()); - store.len() - 1 - } - - pub fn delete_playlist(&self, id: &str) { - let mut store = self.store.write().expect("can't writelock playlists"); - if let Some(position) = store.iter().position(|ref i| i.id == id) { - if self.spotify.delete_playlist(id) { - store.remove(position); - self.save_cache(); - } - } - } - - pub fn overwrite_playlist(&self, id: &str, tracks: &[Track]) { - debug!("saving {} tracks to {}", tracks.len(), id); - self.spotify.overwrite_playlist(id, &tracks); - - self.fetch_playlists(); - self.save_cache(); - } - - pub fn save_playlist(&self, name: &str, tracks: &[Track]) { - debug!("saving {} tracks to new list {}", tracks.len(), name); - match self.spotify.create_playlist(name, None, None) { - Some(id) => self.overwrite_playlist(&id, &tracks), - None => error!("could not create new playlist.."), - } - } - - pub fn fetch_playlists(&self) { - debug!("loading playlists"); - let mut stale_lists = self.store.read().unwrap().clone(); - - let mut lists_result = self.spotify.current_user_playlist(50, 0); - while let Some(ref lists) = lists_result.clone() { - for remote in &lists.items { - // remove from stale playlists so we won't prune it later on - if let Some(index) = stale_lists.iter().position(|x| x.id == remote.id) { - stale_lists.remove(index); - } - - if self.needs_download(remote) { - info!("updating playlist {}", remote.name); - let playlist = Self::process_simplified_playlist(remote, &self.spotify); - self.append_or_update(&playlist); - // trigger redraw - self.ev.trigger(); - } - } - - // load next batch if necessary - lists_result = match lists.next { - Some(_) => { - debug!("requesting playlists again.."); - self.spotify - .current_user_playlist(50, lists.offset + lists.items.len() as u32) - } - None => None, - } - } - - // remove stale playlists - for stale in stale_lists { - let index = self - .store - .read() - .unwrap() - .iter() - .position(|x| x.id == stale.id); - if let Some(index) = index { - debug!("removing stale list: {:?}", stale.name); - self.store.write().unwrap().remove(index); - } - } - // trigger redraw - self.ev.trigger(); - } -} diff --git a/src/spotify.rs b/src/spotify.rs index d08efe4..a72aa3c 100644 --- a/src/spotify.rs +++ b/src/spotify.rs @@ -13,14 +13,14 @@ use librespot::playback::player::Player; use rspotify::spotify::client::ApiError; use rspotify::spotify::client::Spotify as SpotifyAPI; -use rspotify::spotify::model::album::{FullAlbum, SimplifiedAlbum}; +use rspotify::spotify::model::album::{FullAlbum, SavedAlbum, SimplifiedAlbum}; use rspotify::spotify::model::artist::FullArtist; -use rspotify::spotify::model::page::Page; +use rspotify::spotify::model::page::{CursorBasedPage, Page}; use rspotify::spotify::model::playlist::{FullPlaylist, PlaylistTrack, SimplifiedPlaylist}; use rspotify::spotify::model::search::{ SearchAlbums, SearchArtists, SearchPlaylists, SearchTracks, }; -use rspotify::spotify::model::track::FullTrack; +use rspotify::spotify::model::track::{FullTrack, SavedTrack}; use failure::Error; @@ -527,6 +527,19 @@ impl Spotify { }) } + pub fn current_user_followed_artists(&self, last: Option) -> Option> { + self.api_with_retry(|api| api.current_user_followed_artists(50, last.clone())) + .map(|cp| cp.artists) + } + + pub fn current_user_saved_albums(&self, offset: u32) -> Option> { + self.api_with_retry(|api| api.current_user_saved_albums(50, offset)) + } + + pub fn current_user_saved_tracks(&self, offset: u32) -> Option> { + self.api_with_retry(|api| api.current_user_saved_tracks(50, offset)) + } + pub fn load(&self, track: &Track) { info!("loading track: {:?}", track); self.channel diff --git a/src/track.rs b/src/track.rs index 1a04ba1..85c202f 100644 --- a/src/track.rs +++ b/src/track.rs @@ -1,8 +1,9 @@ use std::fmt; use std::sync::Arc; +use chrono::{DateTime, Utc}; use rspotify::spotify::model::album::FullAlbum; -use rspotify::spotify::model::track::{FullTrack, SimplifiedTrack}; +use rspotify::spotify::model::track::{FullTrack, SavedTrack, SimplifiedTrack}; use queue::Queue; use traits::ListItem; @@ -15,10 +16,12 @@ pub struct Track { pub disc_number: i32, pub duration: u32, pub artists: Vec, + pub artist_ids: Vec, pub album: String, pub album_artists: Vec, pub cover_url: String, pub url: String, + pub added_at: Option> } impl Track { @@ -28,6 +31,11 @@ impl Track { .iter() .map(|ref artist| artist.name.clone()) .collect::>(); + let artist_ids = track + .artists + .iter() + .map(|ref artist| artist.id.clone()) + .collect::>(); let album_artists = album .artists .iter() @@ -46,10 +54,12 @@ impl Track { disc_number: track.disc_number, duration: track.duration_ms, artists, + artist_ids, album: album.name.clone(), album_artists, cover_url, url: track.uri.clone(), + added_at: None, } } @@ -67,6 +77,11 @@ impl From<&FullTrack> for Track { .iter() .map(|ref artist| artist.name.clone()) .collect::>(); + let artist_ids = track + .artists + .iter() + .map(|ref artist| artist.id.clone()) + .collect::>(); let album_artists = track .album .artists @@ -86,14 +101,24 @@ impl From<&FullTrack> for Track { disc_number: track.disc_number, duration: track.duration_ms, artists, + artist_ids, album: track.album.name.clone(), album_artists, cover_url, url: track.uri.clone(), + added_at: None, } } } +impl From<&SavedTrack> for Track { + fn from(st: &SavedTrack) -> Self { + let mut track: Self = (&st.track).into(); + track.added_at = Some(st.added_at); + track + } +} + impl fmt::Display for Track { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "{} - {}", self.artists.join(", "), self.title) diff --git a/src/ui/library.rs b/src/ui/library.rs new file mode 100644 index 0000000..dd68b75 --- /dev/null +++ b/src/ui/library.rs @@ -0,0 +1,79 @@ +use std::sync::Arc; + +use cursive::view::ViewWrapper; +use cursive::views::Dialog; +use cursive::Cursive; + +use commands::CommandResult; +use library::Library; +use queue::Queue; +use traits::ViewExt; +use ui::listview::ListView; +use ui::modal::Modal; +use ui::tabview::TabView; + +pub struct LibraryView { + list: TabView, + library: Arc, +} + +impl LibraryView { + pub fn new(queue: Arc, library: Arc) -> Self { + let tabs = TabView::new() + .tab("tracks", "Tracks", ListView::new(library.tracks.clone(), queue.clone())) + .tab("albums", "Albums", ListView::new(library.albums.clone(), queue.clone())) + .tab("artists", "Artists", ListView::new(library.artists.clone(), queue.clone())) + .tab("playlists", "Playlists", ListView::new(library.playlists.clone(), queue.clone())); + + Self { + list: tabs, + library, + } + } + + pub fn delete_dialog(&mut self) -> Option> { + return None; + + // TODO + //let store = self.library.items(); + //let current = store.get(self.list.get_selected_index()); + + //if let Some(playlist) = current { + // let library = self.library.clone(); + // let id = playlist.id.clone(); + // let dialog = Dialog::text("Are you sure you want to delete this playlist?") + // .padding((1, 1, 1, 0)) + // .title("Delete playlist") + // .dismiss_button("No") + // .button("Yes", move |s: &mut Cursive| { + // library.delete_playlist(&id); + // s.pop_layer(); + // }); + // Some(Modal::new(dialog)) + //} else { + // None + //} + } +} + +impl ViewWrapper for LibraryView { + wrap_impl!(self.list: TabView); +} + +impl ViewExt for LibraryView { + fn on_command( + &mut self, + s: &mut Cursive, + cmd: &str, + args: &[String], + ) -> Result { + if cmd == "delete" { + if let Some(dialog) = self.delete_dialog() { + s.add_layer(dialog); + } + return Ok(CommandResult::Consumed(None)); + } + + self.list.on_command(s, cmd, args) + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 61c0b77..e0f3563 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,7 +1,7 @@ pub mod layout; +pub mod library; pub mod listview; pub mod modal; -pub mod playlists; pub mod queue; pub mod search; pub mod statusbar; diff --git a/src/ui/playlists.rs b/src/ui/playlists.rs deleted file mode 100644 index a53055c..0000000 --- a/src/ui/playlists.rs +++ /dev/null @@ -1,74 +0,0 @@ -use std::sync::Arc; - -use cursive::traits::Identifiable; -use cursive::view::ViewWrapper; -use cursive::views::{Dialog, IdView}; -use cursive::Cursive; - -use commands::CommandResult; -use playlists::{Playlist, Playlists}; -use queue::Queue; -use traits::ViewExt; -use ui::listview::ListView; -use ui::modal::Modal; - -pub struct PlaylistView { - list: IdView>, - playlists: Playlists, -} - -pub const LIST_ID: &str = "playlist_list"; -impl PlaylistView { - pub fn new(playlists: &Playlists, queue: Arc) -> PlaylistView { - let list = ListView::new(playlists.store.clone(), queue).with_id(LIST_ID); - - PlaylistView { - list, - playlists: playlists.clone(), - } - } - - pub fn delete_dialog(&mut self) -> Option> { - let list = self.list.get_mut(); - let store = self.playlists.items(); - let current = store.get(list.get_selected_index()); - - if let Some(playlist) = current { - let playlists = self.playlists.clone(); - let id = playlist.id.clone(); - let dialog = Dialog::text("Are you sure you want to delete this playlist?") - .padding((1, 1, 1, 0)) - .title("Delete playlist") - .dismiss_button("No") - .button("Yes", move |s: &mut Cursive| { - playlists.delete_playlist(&id); - s.pop_layer(); - }); - Some(Modal::new(dialog)) - } else { - None - } - } -} - -impl ViewWrapper for PlaylistView { - wrap_impl!(self.list: IdView>); -} - -impl ViewExt for PlaylistView { - fn on_command( - &mut self, - s: &mut Cursive, - cmd: &str, - args: &[String], - ) -> Result { - if cmd == "delete" { - if let Some(dialog) = self.delete_dialog() { - s.add_layer(dialog); - } - return Ok(CommandResult::Consumed(None)); - } - - self.list.on_command(s, cmd, args) - } -} diff --git a/src/ui/queue.rs b/src/ui/queue.rs index ab753ff..352949b 100644 --- a/src/ui/queue.rs +++ b/src/ui/queue.rs @@ -8,7 +8,7 @@ use std::cmp::min; use std::sync::Arc; use commands::CommandResult; -use playlists::Playlists; +use library::Library; use queue::Queue; use track::Track; use traits::ViewExt; @@ -17,17 +17,17 @@ use ui::modal::Modal; pub struct QueueView { list: ListView, - playlists: Arc, + library: Arc, queue: Arc, } impl QueueView { - pub fn new(queue: Arc, playlists: Arc) -> QueueView { + pub fn new(queue: Arc, library: Arc) -> QueueView { let list = ListView::new(queue.queue.clone(), queue.clone()); QueueView { list, - playlists, + library, queue, } } @@ -35,20 +35,20 @@ impl QueueView { fn save_dialog_cb( s: &mut Cursive, queue: Arc, - playlists: Arc, + library: Arc, id: Option, ) { let tracks = queue.queue.read().unwrap().clone(); match id { Some(id) => { - playlists.overwrite_playlist(&id, &tracks); + library.overwrite_playlist(&id, &tracks); s.pop_layer(); } None => { s.pop_layer(); let edit = EditView::new() .on_submit(move |s: &mut Cursive, name| { - playlists.save_playlist(name, &tracks); + library.save_playlist(name, &tracks); s.pop_layer(); }) .with_id("name") @@ -63,16 +63,16 @@ impl QueueView { } } - fn save_dialog(queue: Arc, playlists: Arc) -> Modal { + fn save_dialog(queue: Arc, library: Arc) -> Modal { let mut list_select: SelectView> = SelectView::new().autojump(); list_select.add_item("[Create new]", None); - for list in playlists.items().iter() { + for list in library.items().iter() { list_select.add_item(list.name.clone(), Some(list.id.clone())); } list_select.set_on_submit(move |s, selected| { - Self::save_dialog_cb(s, queue.clone(), playlists.clone(), selected.clone()) + Self::save_dialog_cb(s, queue.clone(), library.clone(), selected.clone()) }); let dialog = Dialog::new() @@ -92,9 +92,9 @@ impl ViewWrapper for QueueView { Event::Char('s') => { debug!("save list"); let queue = self.queue.clone(); - let playlists = self.playlists.clone(); + let library = self.library.clone(); let cb = move |s: &mut Cursive| { - let dialog = Self::save_dialog(queue.clone(), playlists.clone()); + let dialog = Self::save_dialog(queue.clone(), library.clone()); s.add_layer(dialog) }; EventResult::Consumed(Some(Callback::from_fn(cb))) diff --git a/src/ui/search.rs b/src/ui/search.rs index 0748083..0a76013 100644 --- a/src/ui/search.rs +++ b/src/ui/search.rs @@ -13,7 +13,8 @@ use album::Album; use artist::Artist; use commands::CommandResult; use events::EventManager; -use playlists::{Playlist, Playlists}; +use library::Library; +use playlist::Playlist; use queue::Queue; use spotify::{Spotify, URIType}; use track::Track; @@ -218,7 +219,7 @@ impl SearchView { _append: bool, ) -> u32 { if let Some(results) = spotify.playlist(&query) { - let pls = vec![Playlists::process_full_playlist(&results, &&spotify)]; + let pls = vec![Library::process_full_playlist(&results, &&spotify)]; let mut r = playlists.write().unwrap(); *r = pls; return 1; @@ -238,7 +239,7 @@ impl SearchView { .playlists .items .iter() - .map(|sp| Playlists::process_simplified_playlist(sp, &&spotify)) + .map(|sp| Library::process_simplified_playlist(sp, &&spotify)) .collect(); let mut r = playlists.write().unwrap();