From f0cc05df35ef259a20b68422a822f65a60c3e58d Mon Sep 17 00:00:00 2001 From: so9010 Date: Mon, 24 Nov 2025 16:47:07 +0000 Subject: [PATCH 01/19] Inital commit add notes --- psst-gui/src/webapi/client.rs | 61 +++++++++++++++++++++++------------ 1 file changed, 40 insertions(+), 21 deletions(-) diff --git a/psst-gui/src/webapi/client.rs b/psst-gui/src/webapi/client.rs index ff85b4db..c319c5f4 100644 --- a/psst-gui/src/webapi/client.rs +++ b/psst-gui/src/webapi/client.rs @@ -38,8 +38,7 @@ use crate::{ }; use super::{cache::WebApiCache, local::LocalTrackManager}; -use sanitize_html::rules::predefined::DEFAULT; -use sanitize_html::sanitize_str; +use sanitize_html::{rules::predefined::DEFAULT, sanitize_str}; pub struct WebApi { session: SessionService, @@ -211,7 +210,8 @@ impl WebApi { } } - /// Very similar to `for_all_pages`, but only returns a certain number of results + /// Very similar to `for_all_pages`, but only returns a certain number of + /// results fn for_some_pages( &self, request: &RequestBuilder, @@ -270,7 +270,8 @@ impl WebApi { Ok(results) } - /// Does a similar thing as `load_all_pages`, but limiting the number of results + /// Does a similar thing as `load_all_pages`, but limiting the number of + /// results fn load_some_pages( &self, request: &RequestBuilder, @@ -671,6 +672,26 @@ impl WebApi { } } +/// Public user endpoints. +impl WebApi { + // User profile + // https://spclient.wg.spotify.com/user-profile-view/v3/profile/florence.flossie.morrison?playlist_limit=10&artist_limit=10&episode_limit=10&market=from_token + // Get public playlists + // https://spclient.wg.spotify.com/user-profile-view/v3/profile/florence.flossie.morrison/playlists?offset=0&limit=200&market=from_token + // Recenlty played + // https://spclient.wg.spotify.com/recently-played/v3/user/27062006so9010sami/recently-played?format=json&offset=0&limit=50&filter=default%2Ccollection-new-episodes&market=from_token + // Followers + // https://spclient.wg.spotify.com/user-profile-view/v3/profile/florence.flossie.morrison/followers?market=from_token + // Following + // https://spclient.wg.spotify.com/user-profile-view/v3/profile/florence.flossie.morrison/following?market=from_token + // Follow/unfollow + // https://api-partner.spotify.com/pathfinder/v2/query + /* + {"variables":{"usernames":["florence.flossie.morrison"]},"operationName":"followUsers","extensions":{"persistedQuery":{"version":1,"sha256Hash":"c00e0cb6c7766e7230fc256cf4fe07aec63b53d1160a323940fce7b664e95596"}}} + */ + pub fn get_public_user_profile(&self, user: PublicUser) {} +} + /// User endpoints. impl WebApi { // https://developer.spotify.com/documentation/web-api/reference/get-users-profile @@ -731,10 +752,12 @@ impl WebApi { for album in result { match album.album_type { - // Spotify is labeling albums and singles that should be labeled `appears_on` as `album` or `single`. - // They are still ordered properly though, with the most recent first, then 'appears_on'. - // So we just wait until they are no longer descending, then start putting them in the 'appears_on' Vec. - // NOTE: This will break if an artist has released 'appears_on' albums/singles before their first actual album/single. + // Spotify is labeling albums and singles that should be labeled `appears_on` as + // `album` or `single`. They are still ordered properly though, with + // the most recent first, then 'appears_on'. So we just wait until + // they are no longer descending, then start putting them in the 'appears_on' Vec. + // NOTE: This will break if an artist has released 'appears_on' albums/singles + // before their first actual album/single. AlbumType::Album => { if album.release_year_int() > last_album_release_year { artist_albums.appears_on.push_back(album) @@ -1218,11 +1241,8 @@ impl WebApi { } pub fn unfollow_playlist(&self, id: &str) -> Result<(), Error> { - let request = &RequestBuilder::new( - format!("v1/playlists/{id}/followers"), - Method::Delete, - None, - ); + let request = + &RequestBuilder::new(format!("v1/playlists/{id}/followers"), Method::Delete, None); self.request(request)?; Ok(()) } @@ -1251,10 +1271,9 @@ impl WebApi { Json(serde_json::Value), } - let request = - &RequestBuilder::new(format!("v1/playlists/{id}/tracks"), Method::Get, None) - .query("marker", "from_token") - .query("additional_types", "track"); + let request = &RequestBuilder::new(format!("v1/playlists/{id}/tracks"), Method::Get, None) + .query("marker", "from_token") + .query("additional_types", "track"); let result: Vector = self.load_all_pages(request)?; @@ -1275,9 +1294,8 @@ impl WebApi { } pub fn change_playlist_details(&self, id: &str, name: &str) -> Result<(), Error> { - let request = - &RequestBuilder::new(format!("v1/playlists/{id}/tracks"), Method::Get, None) - .set_body(Some(json!({ "name": name }))); + let request = &RequestBuilder::new(format!("v1/playlists/{id}/tracks"), Method::Get, None) + .set_body(Some(json!({ "name": name }))); self.request(request)?; Ok(()) } @@ -1530,7 +1548,8 @@ enum Method { Get, } -// Creating a new URI builder so aid in the creation of uris with extendable queries. +// Creating a new URI builder so aid in the creation of uris with extendable +// queries. #[derive(Debug, Clone)] struct RequestBuilder { protocol: String, From 88c1abdd368bfd2fea630786f4828891999f80da Mon Sep 17 00:00:00 2001 From: so9010 Date: Tue, 25 Nov 2025 00:12:41 +0000 Subject: [PATCH 02/19] Add api end points and start to add UI. Somewhat of a working order now... --- psst-gui/src/controller/nav.rs | 18 +++- psst-gui/src/data/mod.rs | 7 +- psst-gui/src/data/nav.rs | 8 +- psst-gui/src/data/public_user.rs | 69 ++++++++++++++ psst-gui/src/data/user.rs | 4 +- psst-gui/src/ui/mod.rs | 31 +++--- psst-gui/src/ui/playlist.rs | 5 +- psst-gui/src/ui/public_user.rs | 159 +++++++++++++++++++++++++++++++ psst-gui/src/webapi/client.rs | 114 ++++++++++++++++++++-- 9 files changed, 383 insertions(+), 32 deletions(-) create mode 100644 psst-gui/src/data/public_user.rs create mode 100644 psst-gui/src/ui/public_user.rs diff --git a/psst-gui/src/controller/nav.rs b/psst-gui/src/controller/nav.rs index bb026042..f1b2cab4 100644 --- a/psst-gui/src/controller/nav.rs +++ b/psst-gui/src/controller/nav.rs @@ -1,10 +1,12 @@ use crate::{ cmd, data::{AppState, Nav, SpotifyUrl}, - ui::{album, artist, library, lyrics, playlist, recommend, search, show}, + ui::{album, artist, library, lyrics, playlist, public_user, recommend, search, show}, +}; +use druid::{ + widget::{prelude::*, Controller}, + Code, }; -use druid::widget::{prelude::*, Controller}; -use druid::Code; pub struct NavController; @@ -52,6 +54,13 @@ impl NavController { ); } } + Nav::PublicUserDetail(public_user) => { + if !data.public_user_detail.info.contains(public_user) { + ctx.submit_command( + public_user::LOAD_DETAIL.with((public_user.to_owned(), data.to_owned())), + ); + } + } Nav::ShowDetail(link) => { if !data.show_detail.show.contains(link) { ctx.submit_command(show::LOAD_DETAIL.with(link.to_owned())); @@ -136,7 +145,8 @@ where env: &Env, ) { if let LifeCycle::WidgetAdded = event { - // Loads the library's saved tracks without the user needing to click on the tab. + // Loads the library's saved tracks without the user needing to click on the + // tab. ctx.submit_command(cmd::NAVIGATE.with(Nav::SavedTracks)); // Load the last route, or the default. ctx.submit_command( diff --git a/psst-gui/src/data/mod.rs b/psst-gui/src/data/mod.rs index 047e43a9..3e1098b5 100644 --- a/psst-gui/src/data/mod.rs +++ b/psst-gui/src/data/mod.rs @@ -8,6 +8,7 @@ mod nav; mod playback; mod playlist; mod promise; +pub mod public_user; mod recommend; mod search; mod show; @@ -61,7 +62,7 @@ pub use crate::data::{ user::{PublicUser, UserProfile}, utils::{Cached, Float64, Image, Page}, }; -use crate::ui::credits::TrackCredits; +use crate::{data::public_user::PublicUserDetail, ui::credits::TrackCredits}; pub const ALERT_DURATION: Duration = Duration::from_secs(5); @@ -79,6 +80,7 @@ pub struct AppState { pub album_detail: AlbumDetail, pub artist_detail: ArtistDetail, pub playlist_detail: PlaylistDetail, + pub public_user_detail: PublicUserDetail, pub show_detail: ShowDetail, pub library: Arc, pub common_ctx: Arc, @@ -170,6 +172,9 @@ impl AppState { finder: Finder::new(), lyrics: Promise::Empty, credits: None, + public_user_detail: PublicUserDetail { + info: Promise::Empty, + }, } } } diff --git a/psst-gui/src/data/nav.rs b/psst-gui/src/data/nav.rs index 2d7f0db5..b72f4fcc 100644 --- a/psst-gui/src/data/nav.rs +++ b/psst-gui/src/data/nav.rs @@ -4,8 +4,7 @@ use druid::Data; use serde::{Deserialize, Serialize}; use url::Url; -use crate::data::track::TrackId; -use crate::data::{AlbumLink, ArtistLink, PlaylistLink, ShowLink}; +use crate::data::{track::TrackId, AlbumLink, ArtistLink, PlaylistLink, PublicUser, ShowLink}; use super::RecommendationsRequest; @@ -16,6 +15,7 @@ pub enum Route { SavedTracks, SavedAlbums, Shows, + PublicUser, SearchResults, ArtistDetail, AlbumDetail, @@ -36,6 +36,7 @@ pub enum Nav { AlbumDetail(AlbumLink, Option), ArtistDetail(ArtistLink), PlaylistDetail(PlaylistLink), + PublicUserDetail(PublicUser), ShowDetail(ShowLink), Recommendations(Arc), } @@ -49,6 +50,7 @@ impl Nav { Nav::SavedAlbums => Route::SavedAlbums, Nav::Shows => Route::Shows, Nav::SearchResults(_) => Route::SearchResults, + Nav::PublicUserDetail(_) => Route::PublicUser, Nav::AlbumDetail(_, _) => Route::AlbumDetail, Nav::ArtistDetail(_) => Route::ArtistDetail, Nav::PlaylistDetail(_) => Route::PlaylistDetail, @@ -65,6 +67,7 @@ impl Nav { Nav::SavedAlbums => "Saved Albums".to_string(), Nav::Shows => "Podcasts".to_string(), Nav::SearchResults(query) => query.to_string(), + Nav::PublicUserDetail(usr) => usr.display_name.to_string(), Nav::AlbumDetail(link, _) => link.name.to_string(), Nav::ArtistDetail(link) => link.name.to_string(), Nav::PlaylistDetail(link) => link.name.to_string(), @@ -80,6 +83,7 @@ impl Nav { Nav::SavedTracks => "Saved Tracks".to_string(), Nav::SavedAlbums => "Saved Albums".to_string(), Nav::Shows => "Saved Shows".to_string(), + Nav::PublicUserDetail(usr) => format!("User \"{}\"", usr.display_name), Nav::SearchResults(query) => format!("Search \"{query}\""), Nav::AlbumDetail(link, _) => format!("Album \"{}\"", link.name), Nav::ArtistDetail(link) => format!("Artist \"{}\"", link.name), diff --git a/psst-gui/src/data/public_user.rs b/psst-gui/src/data/public_user.rs new file mode 100644 index 00000000..7e541799 --- /dev/null +++ b/psst-gui/src/data/public_user.rs @@ -0,0 +1,69 @@ +use std::sync::Arc; + +use druid::{im::Vector, Data, Lens}; +use serde::Deserialize; + +use crate::data::{Cached, Promise, PublicUser}; + +#[derive(Clone, Data, Lens)] +pub struct PublicUserDetail { + pub info: Promise>, PublicUser>, +} + +// Specialized structures for public user profile API response +#[derive(Clone, Data, Lens, Deserialize)] +pub struct PublicUserArtist { + #[serde(default)] + pub followers_count: i64, + pub image_url: String, + #[serde(default)] + pub is_following: bool, + pub name: String, + pub uri: String, +} + +#[derive(Clone, Data, Lens, Deserialize)] +pub struct PublicUserPlaylist { + pub image_url: String, + pub name: String, + pub owner_name: String, + pub owner_uri: String, + pub uri: String, + #[serde(default)] + pub followers_count: Option, + #[serde(default)] + pub is_following: Option, +} + +#[derive(Clone, Data, Lens, Deserialize)] +pub struct PublicUserInformation { + #[serde(default)] + pub uri: String, + pub name: String, + #[serde(default)] + pub image_url: Option, + #[serde(default)] + pub followers_count: i64, + #[serde(default)] + pub following_count: i64, + #[serde(default)] + pub is_following: Option, + #[serde(default)] + pub is_current_user: Option, + #[serde(default)] + pub recently_played_artists: Vector, + #[serde(default)] + pub public_playlists: Vector, + #[serde(default)] + pub total_public_playlists_count: i64, + #[serde(default)] + pub has_spotify_name: bool, + #[serde(default)] + pub has_spotify_image: bool, + #[serde(default)] + pub color: i64, + #[serde(default)] + pub allow_follows: bool, + #[serde(default)] + pub show_follows: bool, +} diff --git a/psst-gui/src/data/user.rs b/psst-gui/src/data/user.rs index e9dfc34b..8fd8c96f 100644 --- a/psst-gui/src/data/user.rs +++ b/psst-gui/src/data/user.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use druid::{Data, Lens}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; #[derive(Clone, Data, Lens, Deserialize)] pub struct UserProfile { @@ -10,7 +10,7 @@ pub struct UserProfile { pub id: Arc, } -#[derive(Clone, Data, Lens, Deserialize, Debug)] +#[derive(Clone, Data, Lens, Serialize, Deserialize, Debug, Eq, PartialEq)] pub struct PublicUser { pub display_name: Arc, pub id: Arc, diff --git a/psst-gui/src/ui/mod.rs b/psst-gui/src/ui/mod.rs index 6e13e016..71a5fe9c 100644 --- a/psst-gui/src/ui/mod.rs +++ b/psst-gui/src/ui/mod.rs @@ -1,15 +1,13 @@ -use crate::data::config::SortCriteria; -use crate::data::Track; -use crate::error::Error; use crate::{ cmd, controller::{ AfterDelay, AlertCleanupController, NavController, SessionController, SortController, }, data::{ - config::SortOrder, Alert, AlertStyle, AppState, Config, Nav, Playable, Playback, Route, - ALERT_DURATION, + config::{SortCriteria, SortOrder}, + Alert, AlertStyle, AppState, Config, Nav, Playable, Playback, Route, Track, ALERT_DURATION, }, + error::Error, webapi::WebApi, widget::{ icons, icons::SvgIcon, Border, Empty, MyWidgetExt, Overlay, RemoteImage, ThemeScope, @@ -17,16 +15,17 @@ use crate::{ }, }; use credits::TrackCredits; -use druid::widget::Controller; -use druid::KbKey; use druid::{ im::Vector, - widget::{CrossAxisAlignment, Either, Flex, Label, List, Scroll, Slider, Split, ViewSwitcher}, - Color, Env, Insets, Key, LensExt, Menu, MenuItem, Selector, Widget, WidgetExt, WindowDesc, + widget::{ + Controller, CrossAxisAlignment, Either, Flex, Label, List, Scroll, Slider, Split, + ViewSwitcher, + }, + Color, Env, Insets, KbKey, Key, LensExt, Menu, MenuItem, Selector, Widget, WidgetExt, + WindowDesc, }; use druid_shell::Cursor; -use std::sync::Arc; -use std::time::Duration; +use std::{sync::Arc, time::Duration}; pub mod album; pub mod artist; @@ -41,6 +40,7 @@ pub mod playable; pub mod playback; pub mod playlist; pub mod preferences; +pub mod public_user; pub mod recommend; pub mod search; pub mod show; @@ -106,7 +106,8 @@ pub fn account_setup_window() -> WindowDesc { pub fn artwork_window() -> WindowDesc { let win_size = (theme::grid(50.0), theme::grid(50.0)); - // On Windows, the window size includes the titlebar, so we need to account for it + // On Windows, the window size includes the titlebar, so we need to account for + // it let win_size = if cfg!(target_os = "windows") { const WINDOWS_TITLEBAR_OFFSET: f64 = 24.0; // Standard Windows titlebar height (win_size.0, win_size.1 + WINDOWS_TITLEBAR_OFFSET) @@ -355,6 +356,11 @@ fn route_widget() -> impl Widget { .vertical() .boxed() } + Route::PublicUser => { + Scroll::new(public_user::detail_widget().padding(theme::grid(1.0))) + .vertical() + .boxed() + } Route::Shows => Scroll::new(library::saved_shows_widget().padding(theme::grid(1.0))) .vertical() .boxed(), @@ -595,6 +601,7 @@ fn route_icon_widget() -> impl Widget