Repository: aome510/spotify-player (language: rust)

Issue:
cache liked tracked playlist
**Is your feature request related to a problem? Please describe.**
Cache liked tracks playlist to skip delay when playing.

**Describe the solution you'd like**
My liked tracks playlist contains more than 4000 songs. Loading this playlist to play a song usually takes around 60 seconds. I suggest caching this playlist locally and checking for updates periodically to prevent this delay.


Discussion / hints:

Fix patch:
diff --git a/spotify_player/src/client/mod.rs b/spotify_player/src/client/mod.rs
index fc2da57e..96d18dc3 100644
--- a/spotify_player/src/client/mod.rs
+++ b/spotify_player/src/client/mod.rs
@@ -1,4 +1,4 @@
-use std::{borrow::Cow, io::Write, sync::Arc};
+use std::{borrow::Cow, collections::HashMap, io::Write, sync::Arc};
 
 #[cfg(feature = "streaming")]
 use crate::streaming;
@@ -238,7 +238,7 @@ impl Client {
                         .write()
                         .caches
                         .lyrics
-                        .insert(query, result, *CACHE_DURATION);
+                        .insert(query, result, *TTL_CACHE_DURATION);
                 }
             }
             ClientRequest::ConnectDevice(id) => {
@@ -272,14 +272,32 @@ impl Client {
             }
             ClientRequest::GetUserPlaylists => {
                 let playlists = self.current_user_playlists().await?;
+                store_data_into_file_cache(
+                    FileCacheKey::Playlists,
+                    &state.configs.cache_folder,
+                    &playlists,
+                )
+                .context("store user's playlists into the cache folder")?;
                 state.data.write().user_data.playlists = playlists;
             }
             ClientRequest::GetUserFollowedArtists => {
                 let artists = self.current_user_followed_artists().await?;
+                store_data_into_file_cache(
+                    FileCacheKey::FollowedArtists,
+                    &state.configs.cache_folder,
+                    &artists,
+                )
+                .context("store user's followed artists into the cache folder")?;
                 state.data.write().user_data.followed_artists = artists;
             }
             ClientRequest::GetUserSavedAlbums => {
                 let albums = self.current_user_saved_albums().await?;
+                store_data_into_file_cache(
+                    FileCacheKey::SavedAlbums,
+                    &state.configs.cache_folder,
+                    &albums,
+                )
+                .context("store user's saved albums into the cache folder")?;
                 state.data.write().user_data.saved_albums = albums;
             }
             ClientRequest::GetUserTopTracks => {
@@ -292,22 +310,32 @@ impl Client {
                             tracks,
                             desc: "User's top tracks".to_string(),
                         },
-                        *CACHE_DURATION,
+                        *TTL_CACHE_DURATION,
                     );
                 }
             }
             ClientRequest::GetUserSavedTracks => {
                 let tracks = self.current_user_saved_tracks().await?;
+                let tracks_hm = tracks
+                    .iter()
+                    .map(|t| (t.id.uri(), t.clone()))
+                    .collect::<HashMap<_, _>>();
+                store_data_into_file_cache(
+                    FileCacheKey::SavedTracks,
+                    &state.configs.cache_folder,
+                    &tracks_hm,
+                )
+                .context("store user's saved tracks into the cache folder")?;
+
                 let mut data = state.data.write();
-                data.user_data.saved_tracks =
-                    tracks.iter().map(|t| (t.id.uri(), t.clone())).collect();
+                data.user_data.saved_tracks = tracks_hm;
                 data.caches.context.insert(
                     USER_LIKED_TRACKS_ID.uri.to_owned(),
                     Context::Tracks {
                         tracks,
                         desc: "User's liked tracks".to_string(),
                     },
-                    *CACHE_DURATION,
+                    *TTL_CACHE_DURATION,
                 );
             }
             ClientRequest::GetUserRecentlyPlayedTracks => {
@@ -320,7 +348,7 @@ impl Client {
                             tracks,
                             desc: "User's recently played tracks".to_string(),
                         },
-                        *CACHE_DURATION,
+                        *TTL_CACHE_DURATION,
                     );
                 }
             }
@@ -345,7 +373,7 @@ impl Client {
                         .write()
                         .caches
                         .context
-                        .insert(uri, context, *CACHE_DURATION);
+                        .insert(uri, context, *TTL_CACHE_DURATION);
                 }
             }
             ClientRequest::Search(query) => {
@@ -357,7 +385,7 @@ impl Client {
                         .write()
                         .caches
                         .search
-                        .insert(query, results, *CACHE_DURATION);
+                        .insert(query, results, *TTL_CACHE_DURATION);
                 }
             }
             ClientRequest::GetRadioTracks {
@@ -374,7 +402,7 @@ impl Client {
                             tracks,
                             desc: format!("{name} Radio"),
                         },
-                        *CACHE_DURATION,
+                        *TTL_CACHE_DURATION,
                     );
                 }
             }
@@ -1329,7 +1357,7 @@ impl Client {
                 .write()
                 .caches
                 .images
-                .insert(url.to_owned(), image, *CACHE_DURATION);
+                .insert(url.to_owned(), image, *TTL_CACHE_DURATION);
         }
 
         // notify user about the playback's change if any
diff --git a/spotify_player/src/main.rs b/spotify_player/src/main.rs
index f6bec588..412af846 100644
--- a/spotify_player/src/main.rs
+++ b/spotify_player/src/main.rs
@@ -327,7 +327,7 @@ fn main() -> Result<()> {
             // log the application's configurations
             tracing::info!("Configurations: {:?}", configs);
 
-            let state = std::sync::Arc::new(state::State::new(configs));
+            let state = std::sync::Arc::new(state::State::new(configs)?);
 
             #[cfg(feature = "daemon")]
             {
diff --git a/spotify_player/src/state/data.rs b/spotify_player/src/state/data.rs
index 2b6ad21e..fd6ffd75 100644
--- a/spotify_player/src/state/data.rs
+++ b/spotify_player/src/state/data.rs
@@ -1,24 +1,32 @@
-use std::collections::HashMap;
+use std::{collections::HashMap, path::Path};
 
 use once_cell::sync::Lazy;
+use serde::{de::DeserializeOwned, Serialize};
 
 use super::model::*;
 
 pub type DataReadGuard<'a> = parking_lot::RwLockReadGuard<'a, AppData>;
 
-// cache duration, which is default to be 3h
-pub static CACHE_DURATION: Lazy<std::time::Duration> =
+#[derive(Debug)]
+pub enum FileCacheKey {
+    Playlists,
+    FollowedArtists,
+    SavedAlbums,
+    SavedTracks,
+}
+
+/// default time-to-live cache duration
+pub static TTL_CACHE_DURATION: Lazy<std::time::Duration> =
     Lazy::new(|| std::time::Duration::from_secs(60 * 60 * 3));
 
-#[derive(Default)]
 /// the application's data
 pub struct AppData {
     pub user_data: UserData,
-    pub caches: Caches,
+    pub caches: MemoryCaches,
     pub browse: BrowseData,
 }
 
-#[derive(Default, Debug)]
+#[derive(Debug)]
 /// current user's data
 pub struct UserData {
     pub user: Option<rspotify_model::PrivateUser>,
@@ -28,8 +36,8 @@ pub struct UserData {
     pub saved_tracks: HashMap<String, Track>,
 }
 
-/// the application's caches
-pub struct Caches {
+/// the application's in-memory caches
+pub struct MemoryCaches {
     pub context: ttl_cache::TtlCache<String, Context>,
     pub search: ttl_cache::TtlCache<String, SearchResults>,
     #[cfg(feature = "lyric-finder")]
@@ -39,13 +47,14 @@ pub struct Caches {
 }
 
 #[derive(Default, Debug)]
+/// Spotify browse data
 pub struct BrowseData {
     pub categories: Vec<Category>,
     pub category_playlists: HashMap<String, Vec<Playlist>>,
 }
 
-impl Default for Caches {
-    fn default() -> Self {
+impl MemoryCaches {
+    pub fn new() -> Self {
         Self {
             context: ttl_cache::TtlCache::new(64),
             search: ttl_cache::TtlCache::new(64),
@@ -58,6 +67,14 @@ impl Default for Caches {
 }
 
 impl AppData {
+    pub fn new(cache_folder: &Path) -> anyhow::Result<Self> {
+        Ok(Self {
+            user_data: UserData::new_from_file_caches(cache_folder)?,
+            caches: MemoryCaches::new(),
+            browse: BrowseData::default(),
+        })
+    }
+
     pub fn get_tracks_by_id_mut(&mut self, id: &ContextId) -> Option<&mut Vec<Track>> {
         self.caches.context.get_mut(&id.uri()).map(|c| match c {
             Context::Album { tracks, .. } => tracks,
@@ -71,6 +88,24 @@ impl AppData {
 }
 
 impl UserData {
+    /// constructs a new user data based on file caches
+    pub fn new_from_file_caches(cache_folder: &Path) -> anyhow::Result<Self> {
+        Ok(Self {
+            user: None,
+            playlists: load_data_from_file_cache(FileCacheKey::Playlists, cache_folder)?
+                .unwrap_or_default(),
+            followed_artists: load_data_from_file_cache(
+                FileCacheKey::FollowedArtists,
+                cache_folder,
+            )?
+            .unwrap_or_default(),
+            saved_albums: load_data_from_file_cache(FileCacheKey::SavedAlbums, cache_folder)?
+                .unwrap_or_default(),
+            saved_tracks: load_data_from_file_cache(FileCacheKey::SavedTracks, cache_folder)?
+                .unwrap_or_default(),
+        })
+    }
+
     /// returns a list of playlists that are **possibly** modifiable by user
     pub fn modifiable_playlists(&self) -> Vec<&Playlist> {
         match self.user {
@@ -88,3 +123,33 @@ impl UserData {
         self.saved_tracks.contains_key(&track.id.uri())
     }
 }
+
+pub fn store_data_into_file_cache<T: Serialize>(
+    key: FileCacheKey,
+    cache_folder: &Path,
+    data: &T,
+) -> std::io::Result<()> {
+    let path = cache_folder.join(format!("{key:?}_cache.json"));
+    let f = std::fs::File::create(path)?;
+    serde_json::to_writer(f, data)?;
+    Ok(())
+}
+
+pub fn load_data_from_file_cache<T>(
+    key: FileCacheKey,
+    cache_folder: &Path,
+) -> std::io::Result<Option<T>>
+where
+    T: DeserializeOwned,
+{
+    let path = cache_folder.join(format!("{key:?}_cache.json"));
+    if path.exists() {
+        tracing::info!("Loading {key:?} data from {}...", path.display());
+        let f = std::fs::File::open(path)?;
+        let data = serde_json::from_reader(f)?;
+        tracing::info!("Successfully loaded {key:?} data!");
+        Ok(Some(data))
+    } else {
+        Ok(None)
+    }
+}
diff --git a/spotify_player/src/state/mod.rs b/spotify_player/src/state/mod.rs
index d156f883..54cd8dbb 100644
--- a/spotify_player/src/state/mod.rs
+++ b/spotify_player/src/state/mod.rs
@@ -48,7 +48,7 @@ pub struct State {
 }
 
 impl State {
-    pub fn new(configs: Configs) -> Self {
+    pub fn new(configs: Configs) -> Result<Self> {
         let mut ui = UIState::default();
 
         if let Some(theme) = configs.theme_config.find_theme(&configs.app_config.theme) {
@@ -56,11 +56,13 @@ impl State {
             ui.theme = theme;
         }
 
-        Self {
+        let app_data = AppData::new(&configs.cache_folder)?;
+
+        Ok(Self {
             configs,
             ui: Mutex::new(ui),
             player: RwLock::new(PlayerState::default()),
-            data: RwLock::new(AppData::default()),
-        }
+            data: RwLock::new(app_data),
+        })
     }
 }
diff --git a/spotify_player/src/state/model.rs b/spotify_player/src/state/model.rs
index ccd9572c..ce2ad980 100644
--- a/spotify_player/src/state/model.rs
+++ b/spotify_player/src/state/model.rs
@@ -2,8 +2,8 @@ pub use rspotify::model as rspotify_model;
 use rspotify::model::CurrentPlaybackContext;
 pub use rspotify::model::{AlbumId, ArtistId, Id, PlaylistId, TrackId, UserId};
 
-use crate::utils::{format_duration, map_join};
-use serde::Serialize;
+use crate::utils::map_join;
+use serde::{Deserialize, Serialize};
 use std::borrow::Cow;
 
 #[derive(Serialize, Clone, Debug)]
@@ -112,28 +112,20 @@ pub struct Device {
     pub name: String,
 }
 
-#[derive(Serialize, Debug, Clone)]
+#[derive(Deserialize, Serialize, Debug, Clone)]
 /// A Spotify track
 pub struct Track {
     pub id: TrackId<'static>,
     pub name: String,
     pub artists: Vec<Artist>,
     pub album: Option<Album>,
-    #[serde(serialize_with = "serialize_duration")]
-    pub duration: chrono::Duration,
+    pub duration: std::time::Duration,
     pub explicit: bool,
     #[serde(skip)]
     pub added_at: u64,
 }
 
-pub fn serialize_duration<S>(dur: &chrono::Duration, s: S) -> Result<S::Ok, S::Error>
-where
-    S: serde::Serializer,
-{
-    s.serialize_str(&format_duration(dur))
-}
-
-#[derive(Serialize, Debug, Clone)]
+#[derive(Deserialize, Serialize, Debug, Clone)]
 /// A Spotify album
 pub struct Album {
     pub id: AlbumId<'static>,
@@ -142,14 +134,14 @@ pub struct Album {
     pub artists: Vec<Artist>,
 }
 
-#[derive(Serialize, Debug, Clone)]
+#[derive(Deserialize, Serialize, Debug, Clone)]
 /// A Spotify artist
 pub struct Artist {
     pub id: ArtistId<'static>,
     pub name: String,
 }
 
-#[derive(Serialize, Debug, Clone)]
+#[derive(Deserialize, Serialize, Debug, Clone)]
 /// A Spotify playlist
 pub struct Playlist {
     pub id: PlaylistId<'static>,
@@ -265,7 +257,7 @@ impl Track {
                 name: track.name,
                 artists: from_simplified_artists_to_artists(track.artists),
                 album: None,
-                duration: track.duration,
+                duration: track.duration.to_std().expect("valid chrono duration"),
                 explicit: track.explicit,
                 added_at: 0,
             })
@@ -286,7 +278,7 @@ impl Track {
                 name: track.name,
                 artists: from_simplified_artists_to_artists(track.artists),
                 album: Album::try_from_simplified_album(track.album),
-                duration: track.duration,
+                duration: track.duration.to_std().expect("valid chrono duration"),
                 explicit: track.explicit,
                 added_at: 0,
             })
diff --git a/spotify_player/src/ui/page.rs b/spotify_player/src/ui/page.rs
index 91c79db9..3d6a6820 100644
--- a/spotify_player/src/ui/page.rs
+++ b/spotify_player/src/ui/page.rs
@@ -722,7 +722,11 @@ pub fn render_track_table_window(
                 Cell::from(t.display_name()),
                 Cell::from(t.artists_info()),
                 Cell::from(t.album_info()),
-                Cell::from(crate::utils::format_duration(&t.duration)),
+                Cell::from(format!(
+                    "{}:{:02}",
+                    t.duration.as_secs() / 60,
+                    t.duration.as_secs() % 60,
+                )),
             ])
             .style(style)
         })
diff --git a/spotify_player/src/ui/popup.rs b/spotify_player/src/ui/popup.rs
index 4f25342e..e0813f02 100644
--- a/spotify_player/src/ui/popup.rs
+++ b/spotify_player/src/ui/popup.rs
@@ -320,11 +320,12 @@ pub fn render_queue_popup(
     rect: Rect,
 ) {
     use rspotify::model::{FullEpisode, FullTrack, PlayableItem};
-    fn get_playable_name(item: &PlayableItem) -> &str {
+    fn get_playable_name(item: &PlayableItem) -> String {
         match item {
             PlayableItem::Track(FullTrack { ref name, .. }) => name,
             PlayableItem::Episode(FullEpisode { ref name, .. }) => name,
         }
+        .to_string()
     }
     fn get_playable_artists(item: &PlayableItem) -> String {
         match item {
@@ -372,9 +373,9 @@ pub fn render_queue_popup(
                 .map(|(i, x)| {
                     Row::new(vec![
                         Cell::from(format!("{}", i + 1)),
-                        Cell::from(get_playable_name(x).to_string()),
-                        Cell::from(get_playable_artists(x).to_string()),
-                        Cell::from(get_playable_duration(x).to_string()),
+                        Cell::from(get_playable_name(x)),
+                        Cell::from(get_playable_artists(x)),
+                        Cell::from(get_playable_duration(x)),
                     ])
                 })
                 .collect::<Vec<_>>(),


Test patch: