From 8905db457ad8f32bc53a7f8391a20e8b4c3ec65b Mon Sep 17 00:00:00 2001 From: Henrik Friedrichsen Date: Sat, 20 Aug 2022 22:27:23 +0200 Subject: [PATCH] Implement category playlist browsing in library Fixes #187 --- src/config.rs | 1 + src/model/category.rs | 66 +++++++++++++++++++++++++++++++++++++++++++ src/model/mod.rs | 1 + src/spotify_api.rs | 50 ++++++++++++++++++++++++++++++++ src/ui/browse.rs | 56 ++++++++++++++++++++++++++++++++++++ src/ui/contextmenu.rs | 5 +--- src/ui/library.rs | 4 +++ src/ui/mod.rs | 1 + src/ui/pagination.rs | 5 ++++ 9 files changed, 185 insertions(+), 4 deletions(-) create mode 100644 src/model/category.rs create mode 100644 src/ui/browse.rs diff --git a/src/config.rs b/src/config.rs index eeb7fe2..31bf418 100644 --- a/src/config.rs +++ b/src/config.rs @@ -31,6 +31,7 @@ pub enum LibraryTab { Artists, Playlists, Podcasts, + Browse, } #[derive(Serialize, Deserialize, Debug, Default, Clone)] diff --git a/src/model/category.rs b/src/model/category.rs new file mode 100644 index 0000000..26338ed --- /dev/null +++ b/src/model/category.rs @@ -0,0 +1,66 @@ +use std::sync::Arc; + +use crate::{ + traits::{IntoBoxedViewExt, ListItem}, + ui::listview::ListView, +}; + +#[derive(Clone, Deserialize, Serialize)] +pub struct Category { + pub id: String, + pub name: String, +} + +impl From<&rspotify::model::Category> for Category { + fn from(c: &rspotify::model::Category) -> Self { + Category { + id: c.id.clone(), + name: c.name.clone(), + } + } +} + +impl ListItem for Category { + fn is_playing(&self, _queue: Arc) -> bool { + false + } + + fn display_left(&self, _library: Arc) -> String { + self.name.clone() + } + + fn display_right(&self, _library: Arc) -> String { + "".to_string() + } + + fn play(&mut self, _queue: Arc) {} + + fn play_next(&mut self, _queue: Arc) {} + + fn queue(&mut self, _queue: Arc) {} + + fn toggle_saved(&mut self, _library: Arc) {} + + fn save(&mut self, _library: Arc) {} + + fn unsave(&mut self, _library: Arc) {} + + fn open( + &self, + queue: Arc, + library: Arc, + ) -> Option> { + let playlists = queue.get_spotify().api.category_playlists(&self.id); + let view = ListView::new(playlists.items.clone(), queue, library).with_title(&self.name); + playlists.apply_pagination(view.get_pagination()); + Some(view.into_boxed_view_ext()) + } + + fn share_url(&self) -> Option { + Some(format!("https://open.spotify.com/genre/{}", self.id)) + } + + fn as_listitem(&self) -> Box { + Box::new(self.clone()) + } +} diff --git a/src/model/mod.rs b/src/model/mod.rs index ee6cb84..a235a32 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -1,5 +1,6 @@ pub mod album; pub mod artist; +pub mod category; pub mod episode; pub mod playable; pub mod playlist; diff --git a/src/spotify_api.rs b/src/spotify_api.rs index 83e2e78..50d437c 100644 --- a/src/spotify_api.rs +++ b/src/spotify_api.rs @@ -1,5 +1,6 @@ use crate::model::album::Album; use crate::model::artist::Artist; +use crate::model::category::Category; use crate::model::episode::Episode; use crate::model::playable::Playable; use crate::model::playlist::Playlist; @@ -625,6 +626,55 @@ impl WebApi { .map(|fa| fa.iter().map(|a| a.into()).collect()) } + pub fn categories(&self) -> ApiResult { + const MAX_LIMIT: u32 = 50; + let spotify = self.clone(); + let fetch_page = move |offset: u32| { + debug!("fetching categories, offset: {}", offset); + spotify.api_with_retry(|api| { + match api.categories_manual( + None, + Some(&Market::FromToken), + Some(MAX_LIMIT), + Some(offset), + ) { + Ok(page) => Ok(ApiPage { + offset: page.offset, + total: page.total, + items: page.items.iter().map(|cat| cat.into()).collect(), + }), + Err(e) => Err(e), + } + }) + }; + ApiResult::new(MAX_LIMIT, Arc::new(fetch_page)) + } + + pub fn category_playlists(&self, category_id: &str) -> ApiResult { + const MAX_LIMIT: u32 = 50; + let spotify = self.clone(); + let category_id = category_id.to_string(); + let fetch_page = move |offset: u32| { + debug!("fetching category playlists, offset: {}", offset); + spotify.api_with_retry(|api| { + match api.category_playlists_manual( + &category_id, + Some(&Market::FromToken), + Some(MAX_LIMIT), + Some(offset), + ) { + Ok(page) => Ok(ApiPage { + offset: page.offset, + total: page.total, + items: page.items.iter().map(|sp| sp.into()).collect(), + }), + Err(e) => Err(e), + } + }) + }; + ApiResult::new(MAX_LIMIT, Arc::new(fetch_page)) + } + pub fn current_user(&self) -> Option { self.api_with_retry(|api| api.current_user()) } diff --git a/src/ui/browse.rs b/src/ui/browse.rs new file mode 100644 index 0000000..563ecb8 --- /dev/null +++ b/src/ui/browse.rs @@ -0,0 +1,56 @@ +use std::sync::{Arc, RwLock}; + +use cursive::view::ViewWrapper; +use cursive::Cursive; + +use crate::command::Command; +use crate::commands::CommandResult; +use crate::library::Library; +use crate::model::category::Category; +use crate::queue::Queue; +use crate::traits::ViewExt; + +use crate::ui::listview::ListView; + +pub struct BrowseView { + list: ListView, +} + +impl BrowseView { + pub fn new(queue: Arc, library: Arc) -> Self { + let items = Arc::new(RwLock::new(Vec::new())); + let list = ListView::new(items.clone(), queue.clone(), library); + + let pagination = list.get_pagination().clone(); + std::thread::spawn(move || { + let categories = queue.get_spotify().api.categories(); + items + .write() + .expect("could not writelock category items") + .extend( + categories + .items + .read() + .expect("could not readlock fetched categories") + .clone(), + ); + categories.apply_pagination(&pagination); + }); + + Self { list } + } +} + +impl ViewWrapper for BrowseView { + wrap_impl!(self.list: ListView); +} + +impl ViewExt for BrowseView { + fn title(&self) -> String { + "Browse".to_string() + } + + fn on_command(&mut self, s: &mut Cursive, cmd: &Command) -> Result { + self.list.on_command(s, cmd) + } +} diff --git a/src/ui/contextmenu.rs b/src/ui/contextmenu.rs index acbe885..4075703 100644 --- a/src/ui/contextmenu.rs +++ b/src/ui/contextmenu.rs @@ -18,10 +18,7 @@ use crate::sharing::write_share; use crate::traits::{ListItem, ViewExt}; use crate::ui::layout::Layout; use crate::ui::modal::Modal; -use crate::{ - command::{Command, MoveAmount, MoveMode}, - spotify::Spotify, -}; +use crate::{command::Command, spotify::Spotify}; use cursive::traits::{Finder, Nameable}; pub struct ContextMenu { diff --git a/src/ui/library.rs b/src/ui/library.rs index 250e7b6..5cdf2a1 100644 --- a/src/ui/library.rs +++ b/src/ui/library.rs @@ -10,6 +10,7 @@ use crate::config::LibraryTab; use crate::library::Library; use crate::queue::Queue; use crate::traits::ViewExt; +use crate::ui::browse::BrowseView; use crate::ui::listview::ListView; use crate::ui::playlists::PlaylistsView; use crate::ui::tabview::TabView; @@ -55,6 +56,9 @@ impl LibraryView { ListView::new(library.shows.clone(), queue.clone(), library.clone()) .with_title("Podcasts"), ), + LibraryTab::Browse => { + tabview.add_tab("browse", BrowseView::new(queue.clone(), library.clone())) + } } } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index d531f86..7b9be56 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,5 +1,6 @@ pub mod album; pub mod artist; +pub mod browse; pub mod contextmenu; pub mod help; pub mod layout; diff --git a/src/ui/pagination.rs b/src/ui/pagination.rs index 36f2ca6..3205e86 100644 --- a/src/ui/pagination.rs +++ b/src/ui/pagination.rs @@ -21,6 +21,11 @@ impl ApiResult { pub fn new(limit: u32, fetch_page: Arc>) -> ApiResult { let items = Arc::new(RwLock::new(Vec::new())); if let Some(first_page) = fetch_page(0) { + debug!( + "fetched first page, items: {}, total: {}", + first_page.items.len(), + first_page.total + ); items.write().unwrap().extend(first_page.items); ApiResult { offset: Arc::new(RwLock::new(first_page.offset)),