From fd1692594b553b744a53f1e124743b9355c06896 Mon Sep 17 00:00:00 2001 From: Sahil Gupte Date: Thu, 19 Dec 2024 02:10:50 -0500 Subject: [PATCH] Fetch album and artist content (#4) --- .zed/settings.json | 2 +- src-tauri/src/lib.rs | 10 +- src-tauri/src/providers/extension.rs | 18 +- src-tauri/src/providers/handler.rs | 20 ++- src-tauri/src/providers/spotify.rs | 169 ++++++++++++++++++- src-tauri/src/providers/youtube.rs | 202 ++++++++++++++++++----- src-tauri/types/src/providers/generic.rs | 13 +- src-tauri/youtube/src/youtube.rs | 2 +- src/components/songlist.rs | 36 +++- src/components/songview.rs | 12 +- src/pages/albums.rs | 38 +++++ src/pages/artists.rs | 34 ++++ src/pages/search.rs | 7 +- src/store/provider_store.rs | 16 +- src/utils/common.rs | 1 + 15 files changed, 524 insertions(+), 56 deletions(-) diff --git a/.zed/settings.json b/.zed/settings.json index 4a12e10c..94e26035 100644 --- a/.zed/settings.json +++ b/.zed/settings.json @@ -7,7 +7,7 @@ "Rust": { "formatter": { "external": { - "command": "leptosfmt", + "command": "/home/ovenoboyo/.cargo/bin/leptosfmt", "arguments": ["--stdin", "--rustfmt"] } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0a58d62a..742ab573 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -31,10 +31,10 @@ use extensions::{ install_extension, remove_extension, send_extra_event, }; use providers::handler::{ - fetch_playback_url, fetch_playlist_content, fetch_user_playlists, get_all_status, - get_provider_key_by_id, get_provider_keys, get_suggestions, initialize_all_providers, - match_url, playlist_from_url, provider_authorize, provider_login, provider_search, - provider_signout, song_from_url, + fetch_playback_url, fetch_playlist_content, fetch_user_playlists, get_album_content, + get_all_status, get_artist_content, get_provider_key_by_id, get_provider_keys, get_suggestions, + initialize_all_providers, match_url, playlist_from_url, provider_authorize, provider_login, + provider_search, provider_signout, song_from_url, }; use scanner::{get_scanner_state, start_scan, ScanTask}; use tauri::{Listener, Manager, State}; @@ -204,6 +204,8 @@ pub fn run() { playlist_from_url, song_from_url, get_suggestions, + get_album_content, + get_artist_content, // Rodio player rodio_get_volume, rodio_load, diff --git a/src-tauri/src/providers/extension.rs b/src-tauri/src/providers/extension.rs index a78283d9..f2814e2e 100644 --- a/src-tauri/src/providers/extension.rs +++ b/src-tauri/src/providers/extension.rs @@ -5,7 +5,7 @@ use futures::{channel::mpsc::UnboundedSender, SinkExt}; use serde_json::Value; use tauri::AppHandle; use types::{ - entities::{QueryablePlaylist, SearchResult}, + entities::{QueryableAlbum, QueryableArtist, QueryablePlaylist, SearchResult}, errors::Result, extensions::{ AccountLoginArgs, CustomRequestReturnType, ExtensionDetail, ExtensionExtraEvent, @@ -322,4 +322,20 @@ impl GenericProvider for ExtensionProvider { Ok(res.songs) } + + async fn get_album_content( + &self, + album: QueryableAlbum, + pagination: Pagination, + ) -> Result<(Vec, Pagination)> { + todo!() + } + + async fn get_artist_content( + &self, + artist: QueryableArtist, + pagination: Pagination, + ) -> Result<(Vec, Pagination)> { + todo!() + } } diff --git a/src-tauri/src/providers/handler.rs b/src-tauri/src/providers/handler.rs index 182000e1..f5f6e326 100644 --- a/src-tauri/src/providers/handler.rs +++ b/src-tauri/src/providers/handler.rs @@ -14,7 +14,7 @@ use tauri::{ AppHandle, Emitter, State, }; use types::{ - entities::{QueryablePlaylist, SearchResult}, + entities::{QueryableAlbum, QueryableArtist, QueryablePlaylist, SearchResult}, errors::{MoosyncError, Result}, providers::generic::{GenericProvider, Pagination, ProviderStatus}, songs::Song, @@ -347,6 +347,22 @@ impl ProviderHandler { result_type: Vec, method_name: get_suggestions, }, + get_album_content { + args: { + album: QueryableAlbum, + pagination: Pagination + }, + result_type: (Vec, Pagination), + method_name: get_album_content, + }, + get_artist_content { + args: { + artist: QueryableArtist, + pagination: Pagination + }, + result_type: (Vec, Pagination), + method_name: get_artist_content, + }, ); } @@ -370,3 +386,5 @@ generate_command_async_cached!(playlist_from_url, ProviderHandler, QueryablePlay generate_command_async_cached!(song_from_url, ProviderHandler, Song, key: String, url: String); generate_command_async_cached!(match_url, ProviderHandler, bool, key: String, url: String); generate_command_async_cached!(get_suggestions, ProviderHandler, Vec, key: String); +generate_command_async_cached!(get_artist_content, ProviderHandler, (Vec, Pagination), key: String, artist: QueryableArtist, pagination: Pagination); +generate_command_async_cached!(get_album_content, ProviderHandler, (Vec, Pagination), key: String, album: QueryableAlbum, pagination: Pagination); diff --git a/src-tauri/src/providers/spotify.rs b/src-tauri/src/providers/spotify.rs index 7e499f55..4bcd4911 100644 --- a/src-tauri/src/providers/spotify.rs +++ b/src-tauri/src/providers/spotify.rs @@ -15,8 +15,9 @@ use regex::Regex; use rspotify::{ clients::{BaseClient, OAuthClient}, model::{ - FullArtist, FullTrack, Id, PlaylistId, PlaylistTracksRef, SearchType, SimplifiedAlbum, - SimplifiedArtist, SimplifiedPlaylist, TrackId, + AlbumId, AlbumType, ArtistId, FullAlbum, FullArtist, FullTrack, Id, PlaylistId, + PlaylistTracksRef, SearchType, SimplifiedAlbum, SimplifiedArtist, SimplifiedPlaylist, + SimplifiedTrack, TrackId, }, AuthCodePkceSpotify, Token, }; @@ -213,7 +214,11 @@ impl SpotifyProvider { provider_extension: Some(self.key()), ..Default::default() }, - album: Some(self.parse_album(item.album)), + album: if item.album.id.is_some() { + Some(self.parse_album(item.album)) + } else { + None + }, artists: Some( item.artists .into_iter() @@ -244,6 +249,47 @@ impl SpotifyProvider { Err("API client not initialized".into()) } + + fn get_full_track(&self, track: SimplifiedTrack) -> FullTrack { + FullTrack { + album: track.album.unwrap_or_default(), + artists: track.artists, + available_markets: track.available_markets.unwrap_or_default(), + disc_number: track.disc_number, + duration: track.duration, + explicit: track.explicit, + external_ids: HashMap::new(), + external_urls: track.external_urls, + href: track.href, + id: track.id, + is_local: track.is_local, + is_playable: track.is_playable, + linked_from: track.linked_from, + restrictions: track.restrictions, + name: track.name, + popularity: 0, + preview_url: track.preview_url, + track_number: track.track_number, + } + } + + fn get_simple_album(&self, album: FullAlbum) -> SimplifiedAlbum { + let album_type: &'static str = album.album_type.into(); + SimplifiedAlbum { + album_group: None, + album_type: Some(album_type.to_string()), + artists: album.artists, + available_markets: album.available_markets.unwrap_or_default(), + external_urls: album.external_urls, + href: Some(album.href), + id: Some(album.id), + images: album.images, + name: album.name, + release_date: Some(album.release_date), + release_date_precision: None, + restrictions: None, + } + } } #[async_trait] @@ -672,4 +718,121 @@ impl GenericProvider for SpotifyProvider { } Err("API Client not initialized".into()) } + + async fn get_album_content( + &self, + album: QueryableAlbum, + pagination: Pagination, + ) -> Result<(Vec, Pagination)> { + if let Some(api_client) = &self.api_client { + let mut raw_id = album.album_id; + if let Some(id) = &raw_id { + if !self.match_id(id.clone()) { + if let Some(album_name) = album.album_name { + let res = self.search(album_name).await?; + if let Some(album) = res.albums.first() { + raw_id = album.album_id.clone(); + } else { + raw_id = None; + } + } else { + raw_id = None; + } + } + } + + if let Some(id) = &raw_id { + tracing::debug!("Got album id: {}", id); + let id = id.replace("spotify-album:", ""); + let id = AlbumId::from_id_or_uri(id.as_str())?; + let album = api_client.album(id.clone(), None).await?; + let album_tracks = api_client + .album_track_manual(id, None, Some(pagination.limit), Some(pagination.offset)) + .await?; + let mut items = album_tracks.items.clone(); + let songs = items + .iter_mut() + .map(|t| { + t.album = Some(self.get_simple_album(album.clone())); + self.parse_playlist_item(self.get_full_track(t.clone())) + }) + .collect::>(); + + return Ok((songs, pagination.next_page())); + } + } + Err("API Client not initialized".into()) + } + + async fn get_artist_content( + &self, + artist: QueryableArtist, + pagination: Pagination, + ) -> Result<(Vec, Pagination)> { + if let Some(api_client) = &self.api_client { + if let Some(next_page_token) = &pagination.token { + // TODO: Fetch next pages + let tokens = next_page_token.split(";").collect::>(); + return Ok((vec![], pagination.next_page_wtoken(None))); + } + + let mut raw_id = artist.artist_id; + if let Some(id) = &raw_id { + if !self.match_id(id.clone()) { + if let Some(artist_name) = artist.artist_name { + let res = self.search(artist_name).await?; + if let Some(artist) = res.artists.first() { + raw_id = artist.artist_id.clone(); + } else { + raw_id = None; + } + } else { + raw_id = None; + } + } + } + + if let Some(id) = &raw_id { + tracing::debug!("Got artist id: {}", id); + let mut songs = vec![]; + let mut next_page_tokens = vec![]; + let id = id.replace("spotify-artist:", ""); + let albums = + api_client.artist_albums(ArtistId::from_id_or_uri(id.as_str())?, [], None); + + let album_ids = albums.filter_map(|a| async { + if let Ok(a) = a { + if let Some(id) = a.id { + return Some(id); + } + } + None + }); + + let album_ids = album_ids.collect::>().await; + + for chunk in album_ids.chunks(20) { + let albums = api_client.albums(chunk.to_vec(), None).await?; + tracing::debug!("Got albums {:?}", albums); + for a in albums { + let mut tracks = a.tracks.items.clone(); + let parsed = tracks.iter_mut().map(|t| { + t.album = Some(self.get_simple_album(a.clone())); + self.parse_playlist_item(self.get_full_track(t.clone())) + }); + + songs.extend(parsed); + + if let Some(next) = a.tracks.next { + next_page_tokens.push(next); + } + } + } + + let next_page_token = next_page_tokens.join(";"); + return Ok((songs, pagination.next_page_wtoken(Some(next_page_token)))); + } + } + Err("API Client not initialized".into()) + } } diff --git a/src-tauri/src/providers/youtube.rs b/src-tauri/src/providers/youtube.rs index 19048591..5752c1b5 100644 --- a/src-tauri/src/providers/youtube.rs +++ b/src-tauri/src/providers/youtube.rs @@ -305,6 +305,100 @@ impl YoutubeProvider { Err("API client not initialized".into()) } + + async fn search_playlists(&self, term: &str) -> Result> { + if let Some(api_client) = &self.api_client { + return Ok(search_and_parse!(api_client, term, "playlist", |item| { + item.id.as_ref().and_then(|id| { + id.playlist_id.as_ref().map(|playlist_id| { + let snippet = item.snippet.as_ref().unwrap(); + let playlist = Playlist { + id: Some(playlist_id.clone()), + snippet: Some(PlaylistSnippet { + description: snippet.description.clone(), + thumbnails: snippet.thumbnails.clone(), + title: snippet.title.clone(), + ..Default::default() + }), + ..Default::default() + }; + self.parse_playlist(playlist) + }) + }) + })); + } + + let youtube_scraper: State = self.app.state(); + let search_res = youtube_scraper.search_yt(term).await?; + + Ok(search_res.playlists) + } + + async fn search_artists(&self, term: &str) -> Result> { + if let Some(api_client) = &self.api_client { + return Ok(search_and_parse!(api_client, &term, "channel", |item| { + item.id.as_ref().and_then(|id| { + id.channel_id.as_ref().map(|channel_id| { + let snippet = item.snippet.as_ref().unwrap(); + let channel = Channel { + id: Some(channel_id.clone()), + snippet: Some(ChannelSnippet { + description: snippet.description.clone(), + thumbnails: snippet.thumbnails.clone(), + title: snippet.title.clone(), + ..Default::default() + }), + ..Default::default() + }; + self.parse_channel(channel) + }) + }) + })); + } + + let youtube_scraper: State = self.app.state(); + let search_res = youtube_scraper.search_yt(term).await?; + + Ok(search_res.artists) + } + + async fn fetch_artist_content( + &self, + artist_id: &str, + pagination: Pagination, + ) -> Result<(Vec, Pagination)> { + if let Some(api_client) = &self.api_client { + let mut builder = api_client + .channels() + .list(&vec!["contentDetails".into()]) + .max_results(50) + .add_id(artist_id.replace("youtube-artist:", "").as_str()); + + if let Some(next_page) = pagination.token.clone() { + builder = builder.page_token(next_page.as_str()); + } + + let (_, resp) = builder.doit().await?; + if let Some(items) = resp.items { + if let Some(items) = items.first() { + if let Some(content_details) = &items.content_details { + if let Some(related_playlists) = &content_details.related_playlists { + if let Some(playlist_id) = &related_playlists.uploads { + return self + .get_playlist_content(playlist_id.to_string(), pagination) + .await; + } + } + } + } + }; + } + + let youtube_scraper: State = self.app.state(); + let search_res = youtube_scraper.search_yt(artist_id).await?; + + Ok((search_res.songs, pagination.next_page())) + } } #[async_trait] @@ -570,43 +664,8 @@ impl GenericProvider for YoutubeProvider { songs.extend(self.fetch_song_details(song_details).await?); } - let playlists = search_and_parse!(api_client, &term, "playlist", |item| { - item.id.as_ref().and_then(|id| { - id.playlist_id.as_ref().map(|playlist_id| { - let snippet = item.snippet.as_ref().unwrap(); - let playlist = Playlist { - id: Some(playlist_id.clone()), - snippet: Some(PlaylistSnippet { - description: snippet.description.clone(), - thumbnails: snippet.thumbnails.clone(), - title: snippet.title.clone(), - ..Default::default() - }), - ..Default::default() - }; - self.parse_playlist(playlist) - }) - }) - }); - - let artists = search_and_parse!(api_client, &term, "channel", |item| { - item.id.as_ref().and_then(|id| { - id.channel_id.as_ref().map(|channel_id| { - let snippet = item.snippet.as_ref().unwrap(); - let channel = Channel { - id: Some(channel_id.clone()), - snippet: Some(ChannelSnippet { - description: snippet.description.clone(), - thumbnails: snippet.thumbnails.clone(), - title: snippet.title.clone(), - ..Default::default() - }), - ..Default::default() - }; - self.parse_channel(channel) - }) - }) - }); + let playlists = self.search_playlists(&term).await?; + let artists = self.search_artists(&term).await?; return Ok(SearchResult { songs, @@ -719,4 +778,73 @@ impl GenericProvider for YoutubeProvider { Err("Api Client not initialized".into()) } + + async fn get_album_content( + &self, + album: QueryableAlbum, + pagination: Pagination, + ) -> Result<(Vec, Pagination)> { + let mut id_raw = album.album_id; + if let Some(id) = &id_raw { + if !self.match_id(id.clone()) { + if let Some(album_name) = album.album_name { + if let Some(playlist) = self.search_playlists(&album_name).await?.first() { + if let Some(id) = &playlist.playlist_id { + id_raw = Some(id.clone()); + } else { + id_raw = None; + } + } else { + id_raw = None; + } + } else { + id_raw = None; + } + } + } + + if let Some(id) = id_raw { + return self + .get_playlist_content( + id.replace("youtube-album:", "youtube-playlist:"), + pagination, + ) + .await; + } else { + return Err("No album found".into()); + } + } + + async fn get_artist_content( + &self, + artist: QueryableArtist, + pagination: Pagination, + ) -> Result<(Vec, Pagination)> { + let mut id_raw = artist.artist_id; + if let Some(id) = &id_raw { + if !self.match_id(id.clone()) { + tracing::info!("ID doesn't match, searching for new ID"); + if let Some(artist_name) = artist.artist_name { + if let Some(artist) = self.search_artists(&artist_name).await?.first() { + if let Some(id) = &artist.artist_id { + id_raw = Some(id.clone()); + } else { + id_raw = None; + } + } else { + id_raw = None; + } + } else { + id_raw = None; + } + } + } + + if let Some(id) = id_raw { + tracing::info!("Found artist id {}. Now fetching contents", id); + return self.fetch_artist_content(id.as_str(), pagination).await; + } else { + return Err("No artist found".into()); + } + } } diff --git a/src-tauri/types/src/providers/generic.rs b/src-tauri/types/src/providers/generic.rs index 3961786d..fbfe52bf 100644 --- a/src-tauri/types/src/providers/generic.rs +++ b/src-tauri/types/src/providers/generic.rs @@ -1,5 +1,5 @@ use crate::{ - entities::{QueryablePlaylist, SearchResult}, + entities::{QueryableAlbum, QueryableArtist, QueryablePlaylist, SearchResult}, errors::Result, songs::Song, }; @@ -94,4 +94,15 @@ pub trait GenericProvider: std::fmt::Debug + Send { async fn playlist_from_url(&self, url: String) -> Result; async fn song_from_url(&self, url: String) -> Result; async fn get_suggestions(&self) -> Result>; + + async fn get_album_content( + &self, + album: QueryableAlbum, + pagination: Pagination, + ) -> Result<(Vec, Pagination)>; + async fn get_artist_content( + &self, + artist: QueryableArtist, + pagination: Pagination, + ) -> Result<(Vec, Pagination)>; } diff --git a/src-tauri/youtube/src/youtube.rs b/src-tauri/youtube/src/youtube.rs index 735e7d10..cfbff824 100644 --- a/src-tauri/youtube/src/youtube.rs +++ b/src-tauri/youtube/src/youtube.rs @@ -144,7 +144,7 @@ impl YoutubeScraper { } #[tracing::instrument(level = "trace", skip(self, query))] - pub async fn search_yt(&self, query: String) -> Result { + pub async fn search_yt(&self, query: impl Into) -> Result { let res = self .youtube .search( diff --git a/src/components/songlist.rs b/src/components/songlist.rs index ff9273fe..5663cc21 100644 --- a/src/components/songlist.rs +++ b/src/components/songlist.rs @@ -19,8 +19,10 @@ use crate::{ add_to_queue_icon::AddToQueueIcon, ellipsis_icon::EllipsisIcon, search_icon::SearchIcon, sort_icon::SortIcon, }, + pages::search::TabCarousel, store::{ player_store::PlayerStore, + provider_store::ProviderStore, ui_store::{SongSortByColumns, UiStore}, }, utils::{ @@ -104,13 +106,23 @@ pub fn SongListItem( } } -#[tracing::instrument(level = "trace", skip(song_list, selected_songs_sig, filtered_selected, hide_search_bar))] +#[derive(Debug, Default, Copy, Clone)] +pub struct ShowProvidersArgs { + pub show_providers: bool, + pub selected_providers: RwSignal>, +} + +#[tracing::instrument( + level = "trace", + skip(song_list, selected_songs_sig, filtered_selected, hide_search_bar) +)] #[component()] pub fn SongList( #[prop()] song_list: ReadSignal>, #[prop()] selected_songs_sig: RwSignal>, #[prop()] filtered_selected: RwSignal>, #[prop(default = false)] hide_search_bar: bool, + #[prop(optional, default = ShowProvidersArgs::default())] providers: ShowProvidersArgs, ) -> impl IntoView { let is_ctrl_pressed = create_rw_signal(false); let is_shift_pressed = create_rw_signal(false); @@ -118,6 +130,8 @@ pub fn SongList( let show_searchbar = create_rw_signal(false); let searchbar_ref = create_node_ref(); + let provider_store = expect_context::>(); + let filter = create_rw_signal(None::); let playlists = create_rw_signal(vec![]); @@ -262,6 +276,7 @@ pub fn SongList( let song_context_menu = Rc::new(ContextMenu::new(context_menu_data)); let sort_context_menu = Rc::new(ContextMenu::new(SortContextMenu {})); + view! {
@@ -274,9 +289,24 @@ pub fn SongList(