From efd8e2e14ee8e84dd7f581b36128b7993c91cbd0 Mon Sep 17 00:00:00 2001 From: Andre Sun Date: Sat, 26 Sep 2026 13:56:03 -0400 Subject: [PATCH 1/6] core: model the whole Liked Songs library in liked_songs.json Spotify lists saved tracks newest first, so one page tells a head check what was liked since the last sync, and the page's total tells it whether anything was removed elsewhere. Only when the two disagree, on first use, or as a weekly backstop does the library need a full walk. The walk is resumable: it records how many items it has consumed and starts over only when the total moves under it. The committed list stays in use until a walk finishes with exactly Spotify's count. Local files count toward the total but are not shown. The list lives in its own file because cache.json is rewritten whole on every cache update and should not carry thousands of rows. --- crates/echo-core/src/lib.rs | 1 + crates/echo-core/src/liked_songs.rs | 542 ++++++++++++++++++++++++++++ 2 files changed, 543 insertions(+) create mode 100644 crates/echo-core/src/liked_songs.rs diff --git a/crates/echo-core/src/lib.rs b/crates/echo-core/src/lib.rs index 0475bc5..ae77d55 100644 --- a/crates/echo-core/src/lib.rs +++ b/crates/echo-core/src/lib.rs @@ -19,6 +19,7 @@ pub mod home; pub mod i18n; pub mod image_tasks; pub mod intent; +pub mod liked_songs; pub mod models; pub mod platform; pub mod search; diff --git a/crates/echo-core/src/liked_songs.rs b/crates/echo-core/src/liked_songs.rs new file mode 100644 index 0000000..40f10db --- /dev/null +++ b/crates/echo-core/src/liked_songs.rs @@ -0,0 +1,542 @@ +//! The whole Liked Songs library, kept in its own `liked_songs.json` rather than `cache.json`, +//! which is rewritten on every cache update and should not carry thousands of rows. +//! +//! Spotify lists saved tracks newest first, so a like made anywhere lands on the first page. +//! A head check reads that one page, prepends what is new, and compares Spotify's `total` +//! with the cached count: if they agree nothing was removed elsewhere and no walk is needed. +//! A full walk runs only on first use, when a head check cannot account for the difference, +//! and weekly as a backstop for an add and a remove elsewhere that cancel out in the total. + +use std::collections::HashSet; +use std::fs; +use std::path::PathBuf; +use std::sync::{Mutex, OnceLock, PoisonError}; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; + +use crate::models::Track; + +pub const PAGE_LIMIT: u32 = 50; +/// How long a head check stays good when nothing local made it stale. +pub const HEAD_CHECK_INTERVAL: Duration = Duration::from_secs(15 * 60); +pub const FULL_WALK_INTERVAL: Duration = Duration::from_secs(7 * 24 * 60 * 60); +/// Walk progress is written out every this many pages, so a restart resumes close to where +/// it stopped without rewriting a large file once per request. +pub const WALK_CHECKPOINT_PAGES: u32 = 10; + +/// One `/me/tracks` page. +#[derive(Clone, Debug, Default)] +pub struct SavedPage { + pub offset: u32, + pub total: u32, + /// One entry per item Spotify returned, `None` for rows echo cannot show (local files). + /// Kept so offsets and counts line up with Spotify's. + pub items: Vec>, + pub has_next: bool, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct LikedSongs { + /// Newest first, as Spotify lists them. Complete once `total` is set. + #[serde(default)] + pub tracks: Vec, + /// Spotify's count behind `tracks`, local files included. `None` until a walk completes. + #[serde(default)] + pub total: Option, + #[serde(default)] + pub walked_at: Option, + #[serde(default)] + pub checked_at: Option, + /// Something was liked in echo since the last head check. The like call carries only an + /// id, so the row itself arrives with the next check. + #[serde(default)] + pub head_stale: bool, + /// A head check could not reconcile the first page with the cache. + #[serde(default)] + pub needs_walk: bool, + /// An unfinished walk. The committed `tracks` stay in use until it completes. + #[serde(default)] + pub walk: Option, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct Walk { + pub tracks: Vec, + /// Items consumed, local files included: the next page's offset. + pub seen: u32, + pub total: u32, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Next { + Nothing, + HeadCheck, + Walk { offset: u32 }, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum HeadOutcome { + /// Ids newly at the head, newest first. Empty when nothing changed. + Added(Vec), + NeedsWalk, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum WalkOutcome { + Continue { + offset: u32, + }, + /// The library changed under the walk (its total moved); it starts over from `offset`. + Restarted { + offset: u32, + }, + /// Spotify stopped short of its own total. Nothing is committed; a later sync retries. + Incomplete, + Done, +} + +impl LikedSongs { + /// What a Liked Songs view can show now: the committed list, or while the first walk is + /// still running, the rows it has so far. + pub fn visible(&self) -> Option<&[Track]> { + if self.total.is_some() { + return Some(&self.tracks); + } + self.walk.as_ref().map(|walk| walk.tracks.as_slice()) + } + + /// Spotify's count for the list [`Self::visible`] returns. + pub fn count(&self) -> Option { + self.total.or(self.walk.as_ref().map(|walk| walk.total)) + } + + pub fn ids(&self) -> HashSet { + self.tracks.iter().map(|track| track.id.clone()).collect() + } + + pub fn next(&self, now: u64, force_head: bool) -> Next { + if let Some(walk) = &self.walk { + return Next::Walk { offset: walk.seen }; + } + if self.total.is_none() + || self.needs_walk + || older_than(self.walked_at, now, FULL_WALK_INTERVAL) + { + return Next::Walk { offset: 0 }; + } + if force_head || self.head_stale || older_than(self.checked_at, now, HEAD_CHECK_INTERVAL) { + return Next::HeadCheck; + } + Next::Nothing + } + + /// Reconciles the first page with the cache. New rows are prepended when the rest of the + /// page matches the cached head and the totals add up; anything else needs a walk. + pub fn apply_head(&mut self, page: SavedPage, now: u64) -> HeadOutcome { + let Some(total) = self.total.filter(|_| page.offset == 0) else { + self.needs_walk = true; + return HeadOutcome::NeedsWalk; + }; + let split = match self.tracks.first() { + Some(head) => page + .items + .iter() + .position(|item| item.as_ref().is_some_and(|track| track.id == head.id)), + None if total == 0 => Some(page.items.len()), + None => None, + }; + let consistent = split.is_some_and(|split| { + page.total == total.saturating_add(split as u32) + && page.items[split..] + .iter() + .flatten() + .zip(&self.tracks) + .all(|(fetched, cached)| fetched.id == cached.id) + }); + let Some(split) = split.filter(|_| consistent) else { + self.needs_walk = true; + return HeadOutcome::NeedsWalk; + }; + let added: Vec = page.items.into_iter().take(split).flatten().collect(); + let ids = added.iter().map(|track| track.id.clone()).collect(); + self.tracks.splice(0..0, added); + self.total = Some(page.total); + self.checked_at = Some(now); + self.head_stale = false; + HeadOutcome::Added(ids) + } + + /// Adds one walk page. A page from a different offset or with a different total than the + /// walk started with means rows shifted underneath it, so the walk starts over rather + /// than risk skipped or repeated rows. + pub fn apply_walk_page(&mut self, page: SavedPage, now: u64) -> WalkOutcome { + let walk = self.walk.get_or_insert_with(|| Walk { + total: page.total, + ..Walk::default() + }); + if page.offset != walk.seen || page.total != walk.total { + *walk = Walk { + total: page.total, + ..Walk::default() + }; + if page.offset != 0 { + return WalkOutcome::Restarted { offset: 0 }; + } + } + walk.seen = walk.seen.saturating_add(page.items.len() as u32); + walk.tracks.extend(page.items.into_iter().flatten()); + if walk.seen < walk.total && page.has_next && walk.seen > page.offset { + return WalkOutcome::Continue { offset: walk.seen }; + } + let walk = self.walk.take().unwrap_or_default(); + if walk.seen != walk.total { + return WalkOutcome::Incomplete; + } + let mut seen = HashSet::new(); + self.tracks = walk + .tracks + .into_iter() + .filter(|track| seen.insert(track.id.clone())) + .collect(); + self.total = Some(walk.total); + self.walked_at = Some(now); + self.checked_at = Some(now); + self.needs_walk = false; + self.head_stale = false; + WalkOutcome::Done + } + + /// A like echo made. Its row comes with the next head check. + pub fn liked(&mut self, track_id: &str) { + if !self.tracks.iter().any(|track| track.id == track_id) { + self.head_stale = true; + } + } + + /// An unlike echo made, applied without a request. Returns whether a row was removed. + pub fn unliked(&mut self, track_id: &str) -> bool { + let Some(index) = self.tracks.iter().position(|track| track.id == track_id) else { + return false; + }; + self.tracks.remove(index); + self.total = self.total.map(|total| total.saturating_sub(1)); + true + } +} + +fn older_than(at: Option, now: u64, age: Duration) -> bool { + at.is_none_or(|at| now.saturating_sub(at) > age.as_secs()) +} + +impl LikedSongs { + pub fn path() -> PathBuf { + crate::config::echo_config_root().join("liked_songs.json") + } + + /// A copy of the process-wide state. Only the first call reads the disk. + pub fn load() -> Self { + cell() + .lock() + .unwrap_or_else(PoisonError::into_inner) + .clone() + } + + /// Changes the process-wide state under its lock and writes it out. + pub fn update(change: impl FnOnce(&mut Self) -> R) -> R { + Self::update_and_persist(true, change) + } + + /// Like [`Self::update`], but skips the write when `persist` is false. Walk pages use it + /// between checkpoints. + pub fn update_and_persist(persist: bool, change: impl FnOnce(&mut Self) -> R) -> R { + let mut state = cell().lock().unwrap_or_else(PoisonError::into_inner); + let result = change(&mut state); + if persist { + let _ = write(&state); + } + result + } +} + +fn cell() -> &'static Mutex { + static STATE: OnceLock> = OnceLock::new(); + STATE.get_or_init(|| Mutex::new(read())) +} + +fn read() -> LikedSongs { + fs::read_to_string(LikedSongs::path()) + .ok() + .and_then(|contents| serde_json::from_str(&contents).ok()) + .unwrap_or_default() +} + +/// Writes to a temporary file first, so an interrupted write leaves the previous copy intact. +fn write(state: &LikedSongs) -> anyhow::Result<()> { + let path = LikedSongs::path(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let temporary = path.with_extension("json.tmp"); + fs::write(&temporary, serde_json::to_vec(state)?)?; + fs::rename(temporary, path)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::TrackSource; + + const NOW: u64 = 1_000_000; + + fn track(n: u32) -> Track { + Track { + explicit: false, + added_by: None, + id: format!("t{n}"), + source: TrackSource::Spotify, + local_path: None, + name: format!("Song {n}"), + artist: "Artist".into(), + album: "Album".into(), + added_at: None, + duration_ms: 1000, + image_url: None, + album_id: None, + artist_id: None, + artists: Vec::new(), + } + } + + /// Items `ids[offset..offset + PAGE_LIMIT]` of a library listed newest first. + fn page(ids: &[u32], offset: u32) -> SavedPage { + let end = (offset + PAGE_LIMIT).min(ids.len() as u32); + SavedPage { + offset, + total: ids.len() as u32, + items: ids[offset as usize..end as usize] + .iter() + .map(|&n| Some(track(n))) + .collect(), + has_next: end < ids.len() as u32, + } + } + + fn walked(ids: &[u32]) -> LikedSongs { + let mut liked = LikedSongs::default(); + let mut offset = 0; + loop { + match liked.apply_walk_page(page(ids, offset), NOW) { + WalkOutcome::Continue { offset: next } => offset = next, + WalkOutcome::Done => return liked, + other => panic!("unexpected {other:?}"), + } + } + } + + fn visible_ids(liked: &LikedSongs) -> Vec { + liked + .visible() + .unwrap() + .iter() + .map(|track| track.id.clone()) + .collect() + } + + #[test] + fn a_first_walk_commits_every_page() { + let ids: Vec = (0..120).collect(); + let liked = walked(&ids); + assert_eq!(liked.tracks.len(), 120); + assert_eq!(liked.total, Some(120)); + assert_eq!(liked.walk, None); + assert_eq!(liked.next(NOW, false), Next::Nothing); + } + + #[test] + fn an_empty_library_walks_in_one_request() { + let liked = walked(&[]); + assert_eq!(liked.total, Some(0)); + assert_eq!(liked.visible(), Some(&[][..])); + } + + #[test] + fn the_partial_walk_is_visible_and_resumes_from_its_offset() { + let ids: Vec = (0..120).collect(); + let mut liked = LikedSongs::default(); + assert_eq!( + liked.apply_walk_page(page(&ids, 0), NOW), + WalkOutcome::Continue { offset: 50 } + ); + assert_eq!(liked.visible().unwrap().len(), 50); + assert_eq!(liked.count(), Some(120)); + let restored: LikedSongs = + serde_json::from_slice(&serde_json::to_vec(&liked).unwrap()).unwrap(); + assert_eq!(restored.next(NOW, false), Next::Walk { offset: 50 }); + } + + #[test] + fn a_walk_restarts_when_the_total_moves_under_it() { + let ids: Vec = (0..120).collect(); + let mut liked = LikedSongs::default(); + liked.apply_walk_page(page(&ids, 0), NOW); + let shifted: Vec = (0..121).collect(); + assert_eq!( + liked.apply_walk_page(page(&shifted, 50), NOW), + WalkOutcome::Restarted { offset: 0 } + ); + assert_eq!(liked.walk.as_ref().unwrap().seen, 0); + assert_eq!(liked.total, None); + } + + #[test] + fn a_walk_short_of_its_total_commits_nothing() { + let ids: Vec = (0..60).collect(); + let mut liked = walked(&ids); + let before = liked.tracks.clone(); + liked.needs_walk = true; + let mut short = page(&ids, 0); + short.has_next = false; + assert_eq!( + liked.apply_walk_page(short, NOW + 1), + WalkOutcome::Incomplete + ); + assert_eq!(liked.tracks, before); + assert!(liked.needs_walk); + assert_eq!(liked.walk, None); + } + + #[test] + fn local_files_count_toward_the_total_without_showing() { + let ids: Vec = (0..3).collect(); + let mut first = page(&ids, 0); + first.items[1] = None; + let mut liked = LikedSongs::default(); + assert_eq!(liked.apply_walk_page(first, NOW), WalkOutcome::Done); + assert_eq!(visible_ids(&liked), ["t0", "t2"]); + assert_eq!(liked.total, Some(3)); + } + + #[test] + fn a_head_check_prepends_new_likes() { + let mut liked = walked(&(0..80).collect::>()); + let newer: Vec = [1000, 1001].into_iter().chain(0..80).collect(); + assert_eq!( + liked.apply_head(page(&newer, 0), NOW + 10), + HeadOutcome::Added(vec!["t1000".into(), "t1001".into()]) + ); + assert_eq!(liked.tracks.len(), 82); + assert_eq!(&visible_ids(&liked)[..3], ["t1000", "t1001", "t0"]); + assert_eq!(liked.total, Some(82)); + assert_eq!(liked.checked_at, Some(NOW + 10)); + } + + #[test] + fn an_unchanged_head_check_only_stamps_the_time() { + let ids: Vec = (0..80).collect(); + let mut liked = walked(&ids); + assert_eq!( + liked.apply_head(page(&ids, 0), NOW + 10), + HeadOutcome::Added(Vec::new()) + ); + assert_eq!(liked.tracks.len(), 80); + assert_eq!(liked.checked_at, Some(NOW + 10)); + } + + #[test] + fn a_remote_unlike_below_the_head_needs_a_walk() { + let mut liked = walked(&(0..80).collect::>()); + let removed: Vec = (0..80).filter(|&n| n != 70).collect(); + assert_eq!( + liked.apply_head(page(&removed, 0), NOW + 10), + HeadOutcome::NeedsWalk + ); + assert_eq!(liked.next(NOW + 10, false), Next::Walk { offset: 0 }); + } + + #[test] + fn a_remote_unlike_of_the_head_needs_a_walk() { + let mut liked = walked(&(0..80).collect::>()); + let removed: Vec = (1..80).collect(); + assert_eq!( + liked.apply_head(page(&removed, 0), NOW + 10), + HeadOutcome::NeedsWalk + ); + } + + #[test] + fn a_remote_unlike_within_the_first_page_needs_a_walk() { + let mut liked = walked(&(0..80).collect::>()); + let removed: Vec = [1000] + .into_iter() + .chain((0..80).filter(|&n| n != 3)) + .collect(); + assert_eq!( + liked.apply_head(page(&removed, 0), NOW + 10), + HeadOutcome::NeedsWalk + ); + } + + #[test] + fn more_new_likes_than_a_page_needs_a_walk() { + let mut liked = walked(&(0..10).collect::>()); + let newer: Vec = (1000..1060).chain(0..10).collect(); + assert_eq!( + liked.apply_head(page(&newer, 0), NOW + 10), + HeadOutcome::NeedsWalk + ); + } + + #[test] + fn local_edits_keep_the_next_head_check_consistent() { + let mut liked = walked(&(0..80).collect::>()); + assert!(liked.unliked("t5")); + assert!(!liked.unliked("t5")); + assert_eq!(liked.total, Some(79)); + liked.liked("t1000"); + assert_eq!(liked.next(NOW, false), Next::HeadCheck); + let server: Vec = [1000] + .into_iter() + .chain((0..80).filter(|&n| n != 5)) + .collect(); + assert_eq!( + liked.apply_head(page(&server, 0), NOW + 10), + HeadOutcome::Added(vec!["t1000".into()]) + ); + assert!(!liked.head_stale); + assert_eq!(liked.total, Some(80)); + } + + #[test] + fn schedules_follow_the_intervals() { + let liked = walked(&(0..10).collect::>()); + assert_eq!(liked.next(NOW, false), Next::Nothing); + assert_eq!(liked.next(NOW, true), Next::HeadCheck); + let later = NOW + HEAD_CHECK_INTERVAL.as_secs() + 1; + assert_eq!(liked.next(later, false), Next::HeadCheck); + let much_later = NOW + FULL_WALK_INTERVAL.as_secs() + 1; + assert_eq!(liked.next(much_later, false), Next::Walk { offset: 0 }); + assert_eq!( + LikedSongs::default().next(NOW, false), + Next::Walk { offset: 0 } + ); + } + + #[test] + fn a_rewalk_keeps_the_old_list_visible_until_it_completes() { + let ids: Vec = (0..120).collect(); + let mut liked = walked(&ids); + liked.needs_walk = true; + let fewer: Vec = (1..120).collect(); + liked.apply_walk_page(page(&fewer, 0), NOW + 1); + assert_eq!(liked.visible().unwrap().len(), 120); + liked.apply_walk_page(page(&fewer, 50), NOW + 1); + assert_eq!( + liked.apply_walk_page(page(&fewer, 100), NOW + 1), + WalkOutcome::Done + ); + assert_eq!(liked.tracks.len(), 119); + assert!(!liked.needs_walk); + } +} From 8b844eaef817480857c8b843d46e275f21897147 Mon Sep 17 00:00:00 2001 From: Andre Sun Date: Sat, 26 Sep 2026 14:00:18 -0400 Subject: [PATCH 2/6] core: sync Liked Songs with a head check and a paced, resumable walk The startup sync walked every saved-track page daily through rspotify's stream, 100 ms apart, and kept only the ids. A failed page, a 429 included, was skipped and the partial set still replaced the hearts until the next day's walk. Local-file likes were dropped by the same replacement. Pages now go through the client's rate-limit gate, so a 429 records Spotify's Retry-After as a cooldown and stops the sync. On startup, one page is usually enough: it picks up new likes and confirms nothing was removed elsewhere. A walk runs only when that check fails, on first use or weekly. It sends one request a second, saves its progress every ten pages, resumes from there after a restart or cooldown, and replaces the hearts only once it has read exactly Spotify's count. Local-file hearts are kept. Likes and unlikes made in echo update the stored list once Spotify accepts them: an unlike drops the row without a request, a like marks the head for the next check. --- crates/echo-core/src/config.rs | 9 +- crates/echo-core/src/liked_songs.rs | 5 + crates/echo-core/src/worker/api/cache.rs | 1 + crates/echo-core/src/worker/api/client.rs | 87 +++++++++++ crates/echo-core/src/worker/liked_songs.rs | 172 +++++++++++++++++++++ crates/echo-core/src/worker/mod.rs | 90 ++--------- 6 files changed, 281 insertions(+), 83 deletions(-) create mode 100644 crates/echo-core/src/worker/liked_songs.rs diff --git a/crates/echo-core/src/config.rs b/crates/echo-core/src/config.rs index fcb7522..4720326 100644 --- a/crates/echo-core/src/config.rs +++ b/crates/echo-core/src/config.rs @@ -35,15 +35,10 @@ pub struct AppConfig { #[derive(Serialize, Deserialize, Default, Clone)] pub struct CacheData { #[serde(default)] + /// Liked-state hearts: Spotify ids, kept in step by the Liked Songs sync, plus `local:` + /// ids, which only echo knows about. pub liked_tracks: HashSet, #[serde(default)] - pub last_liked_sync_time: Option, - /// When the whole Liked Songs library was last walked. The hourly sync only tops up with - /// the most recent page, which cannot observe an unlike; the full walk rebuilds the set so - /// tracks unliked on another device stop showing a heart. - #[serde(default)] - pub last_liked_full_sync_time: Option, - #[serde(default)] pub playlists: Option>>, #[serde(default)] pub saved_albums: Option>>, diff --git a/crates/echo-core/src/liked_songs.rs b/crates/echo-core/src/liked_songs.rs index 40f10db..e5d133a 100644 --- a/crates/echo-core/src/liked_songs.rs +++ b/crates/echo-core/src/liked_songs.rs @@ -242,6 +242,11 @@ impl LikedSongs { .clone() } + /// Reads the process-wide state in place, for callers that need a little of a large list. + pub fn inspect(read: impl FnOnce(&Self) -> R) -> R { + read(&cell().lock().unwrap_or_else(PoisonError::into_inner)) + } + /// Changes the process-wide state under its lock and writes it out. pub fn update(change: impl FnOnce(&mut Self) -> R) -> R { Self::update_and_persist(true, change) diff --git a/crates/echo-core/src/worker/api/cache.rs b/crates/echo-core/src/worker/api/cache.rs index 94000c9..ee3608b 100644 --- a/crates/echo-core/src/worker/api/cache.rs +++ b/crates/echo-core/src/worker/api/cache.rs @@ -12,6 +12,7 @@ pub enum CacheKey { TopArtists(TopItemsRange), RecentlyPlayed, FollowedArtists, + LikedSongs, } #[derive(Clone, Debug, PartialEq)] diff --git a/crates/echo-core/src/worker/api/client.rs b/crates/echo-core/src/worker/api/client.rs index 08ebc86..805a043 100644 --- a/crates/echo-core/src/worker/api/client.rs +++ b/crates/echo-core/src/worker/api/client.rs @@ -8,6 +8,7 @@ use tokio::sync::Mutex; use crate::{ config::{AppConfig, CacheData}, + liked_songs::SavedPage, models::{Album, Artist, ArtistPageData, Track}, }; @@ -643,6 +644,18 @@ impl EchoSpotifyClient { Ok(artists) } + /// One page of Liked Songs through the rate-limit gate: `Ok(None)` when another page + /// request is already in flight, an error while a cooldown from an earlier 429 runs. + pub async fn saved_tracks_page(&self, offset: u32) -> Result> { + let key = CacheKey::LikedSongs; + if !self.begin_fetch(key.clone(), "Liked Songs").await? { + return Ok(None); + } + let result = self.third_party_json(&saved_tracks_url(offset)).await; + self.finish_fetch(&key, &result).await; + Ok(Some(parse_saved_tracks_page(&result?, offset))) + } + fn third_party_worker(&self) -> SpotifyWorker { SpotifyWorker::from_client(self.third_party.clone()) } @@ -978,6 +991,47 @@ fn parse_artist_albums_page(json: &serde_json::Value) -> (Vec, bool) { (albums, has_next) } +fn saved_tracks_url(offset: u32) -> String { + format!( + "https://api.spotify.com/v1/me/tracks?limit={}&offset={offset}", + crate::liked_songs::PAGE_LIMIT + ) +} + +fn parse_saved_tracks_page(json: &serde_json::Value, offset: u32) -> SavedPage { + let items = json + .get("items") + .and_then(|v| v.as_array()) + .map(|items| { + items + .iter() + .map(|item| { + let mut track = item.get("track").and_then(parse::track)?; + // The same form rspotify gives playlist rows, so date-added sorts alike. + track.added_at = item + .get("added_at") + .and_then(|v| v.as_str()) + .and_then(|v| chrono::DateTime::parse_from_rfc3339(v).ok()) + .map(|v| v.with_timezone(&chrono::Utc).to_rfc3339()); + Some(track) + }) + .collect() + }) + .unwrap_or_default(); + SavedPage { + offset: json + .get("offset") + .and_then(|v| v.as_u64()) + .map_or(offset, |v| v as u32), + total: json + .get("total") + .and_then(|v| v.as_u64()) + .unwrap_or_default() as u32, + items, + has_next: json.get("next").is_some_and(|v| !v.is_null()), + } +} + fn record_rate_limit_cooldown(key: &str, retry_after: Duration) -> Duration { AppConfig::update_cache(|cache| cache.record_rate_limit_cooldown(key.to_string(), retry_after)) } @@ -989,6 +1043,7 @@ fn persistent_cooldown_key(key: &CacheKey) -> String { CacheKey::TopArtists(_) => "top_artists".to_string(), CacheKey::RecentlyPlayed => "recently_played".to_string(), CacheKey::FollowedArtists => "followed_artists".to_string(), + CacheKey::LikedSongs => "liked_songs".to_string(), CacheKey::ArtistAlbums(artist_id) => format!("artist_albums:{artist_id}"), CacheKey::ArtistTopTracks(artist_id) => format!("artist_top_tracks:{artist_id}"), } @@ -1090,4 +1145,36 @@ mod tests { assert!(albums.is_empty()); assert!(!has_next); } + + #[test] + fn saved_tracks_page_keeps_a_slot_for_every_item() { + let json = serde_json::json!({ + "offset": 50, + "total": 103, + "next": "https://api.spotify.com/v1/me/tracks?offset=100&limit=50", + "items": [ + { + "added_at": "2024-05-01T10:00:00Z", + "track": { + "id": "a", "name": "A", "duration_ms": 1000, + "artists": [{"id": "ar", "name": "Artist"}], + "album": {"id": "al", "name": "Album", "images": []} + } + }, + {"added_at": "2024-04-01T10:00:00Z", "track": {"is_local": true, "id": null, "name": "Local"}}, + {"added_at": "2024-03-01T10:00:00Z", "track": null} + ] + }); + + let page = parse_saved_tracks_page(&json, 50); + + assert_eq!((page.offset, page.total, page.has_next), (50, 103, true)); + assert_eq!(page.items.len(), 3); + let first = page.items[0].as_ref().unwrap(); + assert_eq!(first.id, "a"); + assert_eq!(first.added_at.as_deref(), Some("2024-05-01T10:00:00+00:00")); + assert!(page.items[1].is_none()); + assert!(page.items[2].is_none()); + assert!(saved_tracks_url(50).ends_with("/v1/me/tracks?limit=50&offset=50")); + } } diff --git a/crates/echo-core/src/worker/liked_songs.rs b/crates/echo-core/src/worker/liked_songs.rs new file mode 100644 index 0000000..a48bd6f --- /dev/null +++ b/crates/echo-core/src/worker/liked_songs.rs @@ -0,0 +1,172 @@ +//! Keeps [`LikedSongs`] in step with Spotify: a one-page head check when that is enough, a +//! paced walk when it is not. Every request goes through the client's rate-limit gate, so a +//! 429 stops the sync, its `Retry-After` is honored, and the walk resumes from its offset on +//! a later sync. + +use std::collections::HashMap; +use std::io::Write; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; + +use tokio::sync::mpsc; + +use crate::config::{AppConfig, now_epoch_secs}; +use crate::events::WorkerEvent; +use crate::liked_songs::{HeadOutcome, LikedSongs, Next, WALK_CHECKPOINT_PAGES, WalkOutcome}; + +use super::api::client::EchoSpotifyClient; + +/// Spacing between walk pages. The walk runs in the background, so it trades speed for +/// staying well inside Spotify's rolling rate-limit window: 10,000 songs take about 3 minutes. +const WALK_PAGE_DELAY: Duration = Duration::from_secs(1); +/// A library that keeps changing under the walk stops it here; the next sync starts over. +const MAX_WALK_RESTARTS: u32 = 3; + +static SYNCING: AtomicBool = AtomicBool::new(false); + +struct SyncGuard; + +impl Drop for SyncGuard { + fn drop(&mut self) { + SYNCING.store(false, Ordering::SeqCst); + } +} + +/// Starts a sync unless one is already running. `force_head` asks for a head check even +/// when the last one is recent, as an explicit refresh does. +pub fn spawn_sync(api: Option, tx: mpsc::Sender, force_head: bool) { + let Some(api) = api else { + return; + }; + if SYNCING.swap(true, Ordering::SeqCst) { + return; + } + let guard = SyncGuard; + tokio::spawn(async move { + let _guard = guard; + if let Err(error) = sync(&api, &tx, force_head).await { + log(&format!("liked_songs sync stopped err={error:#}")); + } + }); +} + +async fn sync( + api: &EchoSpotifyClient, + tx: &mpsc::Sender, + force_head: bool, +) -> anyhow::Result<()> { + let now = now_epoch_secs(); + let mut offset = match LikedSongs::inspect(|liked| liked.next(now, force_head)) { + Next::Nothing => return Ok(()), + Next::Walk { offset } => offset, + Next::HeadCheck => { + let Some(page) = api.saved_tracks_page(0).await? else { + return Ok(()); + }; + match LikedSongs::update(|liked| liked.apply_head(page, now)) { + HeadOutcome::Added(ids) => { + log(&format!("liked_songs head_check added={}", ids.len())); + publish_likes(tx, ids).await; + return Ok(()); + } + HeadOutcome::NeedsWalk => { + log("liked_songs head_check mismatch, walking"); + 0 + } + } + } + }; + + let mut pages = 0u32; + let mut restarts = 0u32; + loop { + if pages > 0 { + tokio::time::sleep(WALK_PAGE_DELAY).await; + } + let page = match api.saved_tracks_page(offset).await { + Ok(Some(page)) => page, + Ok(None) => { + checkpoint(); + return Ok(()); + } + Err(error) => { + checkpoint(); + return Err(error); + } + }; + pages += 1; + let persist = pages.is_multiple_of(WALK_CHECKPOINT_PAGES); + match LikedSongs::update_and_persist(persist, |liked| liked.apply_walk_page(page, now)) { + WalkOutcome::Continue { offset: next } => offset = next, + WalkOutcome::Restarted { offset: next } => { + restarts += 1; + if restarts > MAX_WALK_RESTARTS { + log("liked_songs walk gave up: library kept changing"); + checkpoint(); + return Ok(()); + } + offset = next; + } + WalkOutcome::Incomplete => { + log("liked_songs walk ended short of Spotify's total"); + checkpoint(); + return Ok(()); + } + WalkOutcome::Done => { + checkpoint(); + log(&format!("liked_songs walk done pages={pages}")); + publish_walk(tx).await; + return Ok(()); + } + } + } +} + +fn checkpoint() { + LikedSongs::update(|_| ()); +} + +/// Hearts for rows a head check found. +async fn publish_likes(tx: &mpsc::Sender, ids: Vec) { + if ids.is_empty() { + return; + } + AppConfig::update_cache(|cache| cache.liked_tracks.extend(ids.iter().cloned())); + let update = ids.into_iter().map(|id| (id, true)).collect(); + let _ = tx.send(WorkerEvent::LikedStatusUpdate(update)).await; +} + +/// A completed walk is the whole truth for Spotify tracks: hearts for anything unliked +/// elsewhere go, local-file likes are echo's own and stay. +async fn publish_walk(tx: &mpsc::Sender) { + let ids = LikedSongs::inspect(LikedSongs::ids); + let update: HashMap = AppConfig::update_cache(|cache| { + let mut update = HashMap::new(); + cache.liked_tracks.retain(|id| { + let keep = id.starts_with("local:") || ids.contains(id); + if !keep { + update.insert(id.clone(), false); + } + keep + }); + for id in &ids { + if cache.liked_tracks.insert(id.clone()) { + update.insert(id.clone(), true); + } + } + update + }); + if !update.is_empty() { + let _ = tx.send(WorkerEvent::LikedStatusUpdate(update)).await; + } +} + +fn log(message: &str) { + if let Ok(mut file) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(crate::config::debug_log_path("echo-debug-api.log")) + { + let _ = writeln!(file, "{} {message}", chrono::Utc::now().to_rfc3339()); + } +} diff --git a/crates/echo-core/src/worker/mod.rs b/crates/echo-core/src/worker/mod.rs index ba36a79..8d770fb 100644 --- a/crates/echo-core/src/worker/mod.rs +++ b/crates/echo-core/src/worker/mod.rs @@ -3,6 +3,7 @@ pub mod artist_page; pub mod audio; pub mod browse; pub mod errors; +mod liked_songs; pub mod local_files; pub mod local_playback; pub mod media; @@ -816,79 +817,7 @@ impl Worker { spotify_opt = Some(client); let _ = self.tx.send(WorkerEvent::AuthenticationComplete).await; - if let Some(ref sp) = spotify_opt { - use rspotify::prelude::OAuthClient; - use rspotify::prelude::Id; - - // Eagerly fetch and cache Liked Songs in background - let client = sp.client.clone(); - let tx = self.tx.clone(); - tokio::spawn(async move { - use futures_util::stream::StreamExt; - let cache = crate::config::AppConfig::load_cache(); - let mut tracks = cache.liked_tracks; - let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs(); - // A full walk rebuilds the set, so unlikes made elsewhere disappear; it is - // the expensive path, so it runs daily. In between, top up hourly with the - // most recent page to pick up new likes cheaply. - let full_sync = cache.last_liked_full_sync_time.map(|t| now > t + 86400).unwrap_or(true); - let top_up = cache.last_liked_sync_time.map(|t| now > t + 3600).unwrap_or(true); - - if full_sync { - let mut stream = client.current_user_saved_tracks(None); - let mut rebuilt = std::collections::HashSet::new(); - let mut seen = 0u32; - - while let Some(item) = stream.next().await { - if let Ok(saved_track) = item - && let Some(id) = saved_track.track.id { - rebuilt.insert(id.id().to_string()); - } - seen += 1; - // rspotify pages this 50 at a time. A large library is a lot of - // requests back to back, so pause briefly between pages; this runs - // in the background and nothing waits on it. - if seen.is_multiple_of(50) { - tokio::time::sleep(std::time::Duration::from_millis(100)).await; - } - } - - // Replace rather than union: the point of the walk is to drop ids that - // are no longer saved. - tracks = rebuilt; - crate::config::AppConfig::update_cache(|cache| { - cache.last_liked_full_sync_time = Some(now); - cache.last_liked_sync_time = Some(now); - cache.liked_tracks = tracks.clone(); - }); - } else if top_up { - let mut stream = client.current_user_saved_tracks(None); - let mut fetched_count = 0; - - while let Some(item) = stream.next().await { - if let Ok(saved_track) = item - && let Some(id) = saved_track.track.id { - tracks.insert(id.id().to_string()); - } - fetched_count += 1; - if fetched_count >= 100 { - break; // One page is enough to catch recent likes between full walks. - } - } - - crate::config::AppConfig::update_cache(|cache| { - cache.last_liked_sync_time = Some(now); - cache.liked_tracks = tracks.clone(); - }); - } - - let mut results = std::collections::HashMap::new(); - for tid in tracks { - results.insert(tid, true); - } - let _ = tx.send(WorkerEvent::LikedStatusUpdate(results)).await; - }); - } + liked_songs::spawn_sync(api_client.clone(), self.tx.clone(), false); audio::spawn_librespot_daemon( String::new(), @@ -1854,10 +1783,19 @@ impl Worker { use rspotify::model::{TrackId, LibraryId}; if let Ok(tid) = TrackId::from_id(&track_id) { let lib_id = LibraryId::Track(tid); - if like { - let _ = sp.client.library_add([lib_id]).await; + let written = if like { + sp.client.library_add([lib_id]).await } else { - let _ = sp.client.library_remove([lib_id]).await; + sp.client.library_remove([lib_id]).await + }; + if written.is_ok() { + crate::liked_songs::LikedSongs::update(|liked| { + if like { + liked.liked(&track_id); + } else { + liked.unliked(&track_id); + } + }); } AppConfig::update_cache(|cache| { if like { From 21210b421b1b1c7caf2bd29fbdccc6b7ccdcb07d Mon Sep 17 00:00:00 2001 From: Andre Sun Date: Sat, 26 Sep 2026 14:04:56 -0400 Subject: [PATCH 3/6] core: show the whole Liked Songs list from liked_songs.json Opening Liked Songs fetched its first 100 rows again and asked Spotify for the count in a separate request. It now shows the stored list and count at once and leaves Spotify to the sync, which usually makes one request at most and none if the last check was recent. An explicit refresh forces the head check. The open list updates in place, keeping selection and sort, when a head check finds new likes, when a first walk has more rows to show, when a walk finishes, and when a song is unliked in echo. If nothing is stored yet and the sync fails, a rate-limit cooldown included, the status line says why. Playing Liked Songs from the top takes its first 100 ids from the stored list instead of fetching them. The view's old 100-row entry in cache.json is dropped after the first completed walk. --- .../echo-core/src/apply_worker_event/data.rs | 58 +++++++++++++++++++ .../echo-core/src/apply_worker_event/mod.rs | 3 + crates/echo-core/src/context_details.rs | 14 +++++ crates/echo-core/src/events.rs | 6 ++ crates/echo-core/src/worker/api/client.rs | 6 +- crates/echo-core/src/worker/api/playback.rs | 17 +++++- crates/echo-core/src/worker/liked_songs.rs | 46 ++++++++++++++- crates/echo-core/src/worker/mod.rs | 10 +++- crates/echo-core/src/worker/tracks.rs | 48 ++++++++++----- 9 files changed, 179 insertions(+), 29 deletions(-) diff --git a/crates/echo-core/src/apply_worker_event/data.rs b/crates/echo-core/src/apply_worker_event/data.rs index 55699d7..74be333 100644 --- a/crates/echo-core/src/apply_worker_event/data.rs +++ b/crates/echo-core/src/apply_worker_event/data.rs @@ -95,6 +95,27 @@ pub fn handle_tracks_loaded( state.sort_tracks(state.ui.track_sort); } +/// Refreshes Liked Songs in place when it is the open list; otherwise the stored copy is +/// what the next open shows. +pub fn handle_liked_songs_updated( + state: &mut AppState, + worker_tx: &mpsc::Sender, + tracks: Vec, + total: Option, +) { + let Some(context) = state + .data + .active_tracklist_context + .clone() + .filter(|context| context.id == "LIKED_SONGS") + else { + return; + }; + let details = crate::context_details::liked_songs(&context, &tracks, total); + handle_tracks_loaded(state, worker_tx, tracks, context); + state.data.active_context_details = Some(details); +} + pub fn handle_tracks_load_failed(state: &mut AppState, message: String) { set_timed_status(state, format!("Unable to load tracks: {message}"), 5); } @@ -325,6 +346,43 @@ mod tests { assert_eq!(state.data.tracks.len(), 2); } + #[test] + fn liked_songs_updates_refresh_only_the_open_liked_list() { + let (tx, _) = mpsc::channel(1); + let mut state = AppState::new(); + let album = TrackListContext::album("a".into(), "A".into(), "Artist".into(), None); + state.begin_tracklist_load(album); + handle_liked_songs_updated(&mut state, &tx, vec![sample_track("x")], Some(1)); + assert!(state.data.tracks.is_empty()); + + let liked = TrackListContext::playlist( + "LIKED_SONGS".into(), + "Liked Songs".into(), + String::new(), + "spotify".into(), + None, + ); + state.begin_tracklist_load(liked); + handle_liked_songs_updated( + &mut state, + &tx, + vec![sample_track("x"), sample_track("y")], + Some(3), + ); + state.ui.selected_track_index = 1; + handle_liked_songs_updated( + &mut state, + &tx, + vec![sample_track("new"), sample_track("x"), sample_track("y")], + Some(4), + ); + assert_eq!(state.data.tracks.len(), 3); + assert_eq!(state.data.tracks[state.ui.selected_track_index].id, "y"); + let details = state.data.active_context_details.as_ref().unwrap(); + assert_eq!(details.track_count, Some(4)); + assert_eq!(details.duration_ms, 3000); + } + use super::*; use crate::models::{BrowseNode, TrackSource}; diff --git a/crates/echo-core/src/apply_worker_event/mod.rs b/crates/echo-core/src/apply_worker_event/mod.rs index ae31a34..0757c5a 100644 --- a/crates/echo-core/src/apply_worker_event/mod.rs +++ b/crates/echo-core/src/apply_worker_event/mod.rs @@ -87,6 +87,9 @@ pub fn apply_worker_event( WorkerEvent::LikedStatusUpdate(results) => { library::handle_liked_status_update(state, results) } + WorkerEvent::LikedSongsUpdated { tracks, total } => { + data::handle_liked_songs_updated(state, worker_tx, tracks, total) + } WorkerEvent::Tick => playback::handle_tick(state, app_tx), WorkerEvent::PlaybackStarted { item } => { playback::handle_playback_started(state, app_tx, worker_tx, item) diff --git a/crates/echo-core/src/context_details.rs b/crates/echo-core/src/context_details.rs index 64875a5..f9e8a32 100644 --- a/crates/echo-core/src/context_details.rs +++ b/crates/echo-core/src/context_details.rs @@ -36,6 +36,20 @@ impl ContextDetails { } } +/// Liked Songs' header, from the stored list alone. +pub fn liked_songs( + context: &TrackListContext, + tracks: &[Track], + total: Option, +) -> ContextDetails { + ContextDetails { + owner: context.subtitle.clone(), + track_count: total, + duration_ms: total_duration(tracks), + ..Default::default() + } +} + pub fn total_duration(tracks: &[Track]) -> u64 { tracks .iter() diff --git a/crates/echo-core/src/events.rs b/crates/echo-core/src/events.rs index f479322..dc47a2d 100644 --- a/crates/echo-core/src/events.rs +++ b/crates/echo-core/src/events.rs @@ -203,6 +203,12 @@ pub enum WorkerEvent { artwork: Option, }, LikedStatusUpdate(std::collections::HashMap), + /// The stored Liked Songs list changed: a sync step or an unlike. `total` is Spotify's + /// count, which can run ahead of `tracks` while the first walk is still going. + LikedSongsUpdated { + tracks: Vec, + total: Option, + }, DevicesLoaded(Vec), LyricsLoaded(Option), TopTracksLoaded(Vec), diff --git a/crates/echo-core/src/worker/api/client.rs b/crates/echo-core/src/worker/api/client.rs index 805a043..cf2ca12 100644 --- a/crates/echo-core/src/worker/api/client.rs +++ b/crates/echo-core/src/worker/api/client.rs @@ -160,11 +160,7 @@ impl EchoSpotifyClient { ..Default::default() }; if context.id == "LIKED_SONGS" { - let page = self - .third_party - .current_user_saved_tracks_manual(None, Some(1), Some(0)) - .await?; - details.track_count = Some(page.total); + details.track_count = crate::liked_songs::LikedSongs::inspect(|liked| liked.count()); return Ok(details); } let resource = if context.is_album() { diff --git a/crates/echo-core/src/worker/api/playback.rs b/crates/echo-core/src/worker/api/playback.rs index 19f95f7..b7625d8 100644 --- a/crates/echo-core/src/worker/api/playback.rs +++ b/crates/echo-core/src/worker/api/playback.rs @@ -426,8 +426,21 @@ impl SpotifyWorker { async fn play_context_inner(&mut self, context_id: &str, is_album: bool) -> Result<()> { if context_id == "LIKED_SONGS" { - let tracks = self.fetch_tracks(context_id).await?; - let ids: Vec<_> = tracks.into_iter().map(|track| track.id).collect(); + // The first 100, as before the whole list was stored, so the play request stays + // small; the network is only asked while the first sync has nothing yet. + let mut ids: Vec = crate::liked_songs::LikedSongs::inspect(|liked| { + liked + .visible() + .unwrap_or_default() + .iter() + .take(100) + .map(|track| track.id.clone()) + .collect() + }); + if ids.is_empty() { + let tracks = self.fetch_tracks(context_id).await?; + ids = tracks.into_iter().map(|track| track.id).collect(); + } anyhow::ensure!(!ids.is_empty(), "No saved tracks to play"); return self.play_uris(&ids, 0).await; } diff --git a/crates/echo-core/src/worker/liked_songs.rs b/crates/echo-core/src/worker/liked_songs.rs index a48bd6f..2641bba 100644 --- a/crates/echo-core/src/worker/liked_songs.rs +++ b/crates/echo-core/src/worker/liked_songs.rs @@ -33,8 +33,14 @@ impl Drop for SyncGuard { } /// Starts a sync unless one is already running. `force_head` asks for a head check even -/// when the last one is recent, as an explicit refresh does. -pub fn spawn_sync(api: Option, tx: mpsc::Sender, force_head: bool) { +/// when the last one is recent, as an explicit refresh does; `report_errors` turns a failed +/// sync, a rate-limit cooldown included, into a status message. +pub fn spawn_sync( + api: Option, + tx: mpsc::Sender, + force_head: bool, + report_errors: bool, +) { let Some(api) = api else { return; }; @@ -46,6 +52,19 @@ pub fn spawn_sync(api: Option, tx: mpsc::Sender, let _guard = guard; if let Err(error) = sync(&api, &tx, force_head).await { log(&format!("liked_songs sync stopped err={error:#}")); + if report_errors { + // The gate's cooldown message already carries the remaining time. + let message = match error.to_string() { + message if message.starts_with("rate limited") => message, + _ => super::errors::api_request_error_message(&error), + }; + let _ = tx + .send(WorkerEvent::ApiRequestFailed { + label: "Liked Songs".to_string(), + message, + }) + .await; + } } }); } @@ -66,6 +85,9 @@ async fn sync( match LikedSongs::update(|liked| liked.apply_head(page, now)) { HeadOutcome::Added(ids) => { log(&format!("liked_songs head_check added={}", ids.len())); + if !ids.is_empty() { + publish_list(tx).await; + } publish_likes(tx, ids).await; return Ok(()); } @@ -97,7 +119,14 @@ async fn sync( pages += 1; let persist = pages.is_multiple_of(WALK_CHECKPOINT_PAGES); match LikedSongs::update_and_persist(persist, |liked| liked.apply_walk_page(page, now)) { - WalkOutcome::Continue { offset: next } => offset = next, + WalkOutcome::Continue { offset: next } => { + offset = next; + // A first walk grows the open list as it goes; a re-walk keeps showing the + // committed one, so there is nothing new to send until it is done. + if (pages == 1 || persist) && LikedSongs::inspect(|liked| liked.total.is_none()) { + publish_list(tx).await; + } + } WalkOutcome::Restarted { offset: next } => { restarts += 1; if restarts > MAX_WALK_RESTARTS { @@ -115,6 +144,7 @@ async fn sync( WalkOutcome::Done => { checkpoint(); log(&format!("liked_songs walk done pages={pages}")); + publish_list(tx).await; publish_walk(tx).await; return Ok(()); } @@ -126,6 +156,14 @@ fn checkpoint() { LikedSongs::update(|_| ()); } +pub async fn publish_list(tx: &mpsc::Sender) { + let (tracks, total) = + LikedSongs::inspect(|liked| (liked.visible().unwrap_or_default().to_vec(), liked.count())); + let _ = tx + .send(WorkerEvent::LikedSongsUpdated { tracks, total }) + .await; +} + /// Hearts for rows a head check found. async fn publish_likes(tx: &mpsc::Sender, ids: Vec) { if ids.is_empty() { @@ -141,6 +179,8 @@ async fn publish_likes(tx: &mpsc::Sender, ids: Vec) { async fn publish_walk(tx: &mpsc::Sender) { let ids = LikedSongs::inspect(LikedSongs::ids); let update: HashMap = AppConfig::update_cache(|cache| { + // Where the view kept its first 100 rows before liked_songs.json existed. + cache.context_tracks.remove("playlist:LIKED_SONGS"); let mut update = HashMap::new(); cache.liked_tracks.retain(|id| { let keep = id.starts_with("local:") || ids.contains(id); diff --git a/crates/echo-core/src/worker/mod.rs b/crates/echo-core/src/worker/mod.rs index 8d770fb..3a8ea7e 100644 --- a/crates/echo-core/src/worker/mod.rs +++ b/crates/echo-core/src/worker/mod.rs @@ -817,7 +817,7 @@ impl Worker { spotify_opt = Some(client); let _ = self.tx.send(WorkerEvent::AuthenticationComplete).await; - liked_songs::spawn_sync(api_client.clone(), self.tx.clone(), false); + liked_songs::spawn_sync(api_client.clone(), self.tx.clone(), false, false); audio::spawn_librespot_daemon( String::new(), @@ -1789,13 +1789,17 @@ impl Worker { sp.client.library_remove([lib_id]).await }; if written.is_ok() { - crate::liked_songs::LikedSongs::update(|liked| { + let removed = crate::liked_songs::LikedSongs::update(|liked| { if like { liked.liked(&track_id); + false } else { - liked.unliked(&track_id); + liked.unliked(&track_id) } }); + if removed { + liked_songs::publish_list(&self.tx).await; + } } AppConfig::update_cache(|cache| { if like { diff --git a/crates/echo-core/src/worker/tracks.rs b/crates/echo-core/src/worker/tracks.rs index a0030be..bb78cfc 100644 --- a/crates/echo-core/src/worker/tracks.rs +++ b/crates/echo-core/src/worker/tracks.rs @@ -3,6 +3,7 @@ use tokio::sync::mpsc; use crate::{ config::{AppConfig, CacheData}, events::WorkerEvent, + liked_songs::LikedSongs, models::TrackListContext, }; @@ -45,10 +46,12 @@ async fn load_context_tracks_with_policy( return; }; - if context.is_album() { + if context.id == "LIKED_SONGS" { + load_liked_songs(api, context, tx, policy).await; + } else if context.is_album() { load_album_tracks(sp, api, context, tx, policy).await; } else { - load_playlist_tracks(sp, api, context, tx, policy).await; + load_playlist_tracks(sp, api, context, tx).await; } } @@ -100,26 +103,39 @@ async fn load_album_tracks( } } -async fn load_playlist_tracks( - sp: &SpotifyWorker, +/// Shows the stored list straight away, then lets the sync decide whether Spotify needs +/// asking: usually one page at most, and nothing when the last check is recent. +async fn load_liked_songs( api: Option<&super::api::client::EchoSpotifyClient>, - mut context: TrackListContext, + context: TrackListContext, tx: &mpsc::Sender, policy: ContextTrackCachePolicy, ) { - if policy == ContextTrackCachePolicy::UseCache - && context.id == "LIKED_SONGS" - && let Some(entry) = AppConfig::load_cache().get_context_tracks_entry(&context) - { - let needs_refresh = CacheData::context_tracks_need_refresh(&entry); - let cached = entry.value; - send_loaded(api, cached.tracks.clone(), cached.context.clone(), tx).await; - if !needs_refresh { - return; - } - context = cached.context; + let (tracks, total) = + LikedSongs::inspect(|liked| (liked.visible().map(<[_]>::to_vec), liked.count())); + let refresh = policy == ContextTrackCachePolicy::Refresh; + // Nothing on screen yet, or the user asked: a failed sync should say why. + let report_errors = refresh || tracks.is_none(); + if let Some(tracks) = tracks { + let details = crate::context_details::liked_songs(&context, &tracks, total); + let context_id = context.id.clone(); + let _ = tx.send(WorkerEvent::TracksLoaded(tracks, context)).await; + let _ = tx + .send(WorkerEvent::ContextDetailsLoaded { + context_id, + details, + }) + .await; } + super::liked_songs::spawn_sync(api.cloned(), tx.clone(), refresh, report_errors); +} +async fn load_playlist_tracks( + sp: &SpotifyWorker, + api: Option<&super::api::client::EchoSpotifyClient>, + context: TrackListContext, + tx: &mpsc::Sender, +) { let id = context.id.clone(); match sp.fetch_tracks(&id).await { Ok(tracks) => { From ab48ee25273c8e8e6c398c7611eb024b45c0c55e Mon Sep 17 00:00:00 2001 From: Andre Sun Date: Sat, 26 Sep 2026 14:06:13 -0400 Subject: [PATCH 4/6] docs: describe the Liked Songs cache and sync in the READMEs and release.md --- README.md | 6 +++--- README.zh.md | 6 +++--- README.zht.md | 6 +++--- release.md | 7 +++++++ 4 files changed, 16 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index d5e6803..544f728 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ echo is a native desktop music player and Spotify client written in Rust. echo b - **Full playback control**: Play/pause, next/previous, seek, shuffle, repeat, volume, queue, and device switching from the now-playing bar. - **Synced lyrics**: Time-synced lyrics inline in the player bar or as a full-screen view. - **What's New**: A feed of recent albums and singles from the artists you follow, refreshed at most every 6 hours. -- **Blazing fast Liked Songs**: Your entire Liked Songs library is cached locally (`~/.config/echo/cache.json`) for zero-latency, rate-limit-free scrolling, even with thousands of saved tracks. +- **Blazing fast Liked Songs**: Your entire Liked Songs library is cached locally (`~/.config/echo/liked_songs.json`) for zero-latency scrolling, even with thousands of saved tracks. Keeping it current usually takes a single request; a full re-read is paced, resumable, and backs off when Spotify rate-limits. - **Library management**: Create, rename, delete, and organize playlists into folders; reorder tracks in your own playlists. - **Local music support**: Scan a local music folder, play local files, and create local playlists that can also reference Spotify tracks. - **Search**: Fast global search (`ctrl-k`) across the Spotify catalog and your scanned local tracks. @@ -296,7 +296,7 @@ Embedded artwork is used when available. If a track has no embedded artwork, ech ## Troubleshooting - **Theme color rendering issues (Windows)**: Disable "Adjust indistinguishable text" in the Appearance settings of the Defaults profile. - **Images not rendering**: Cover art is drawn with half-block cells and needs nothing from the terminal beyond truecolor support, which every modern terminal has. -- **Cache desync**: If your Liked Songs are out of sync with other devices, simply restart echo. It eagerly syncs your library in the background on startup. +- **Cache desync**: Likes from other devices show up on startup or when you open Liked Songs (checked at most every 15 minutes). Songs unliked elsewhere are caught at the same point and trigger a background re-read of the library; a rate-limited re-read picks up where it stopped. Deleting `~/.config/echo/liked_songs.json` while echo is closed forces a full re-read. - **Local file missing**: If a file was deleted or moved after scanning, run `:rescanlocal` to refresh the local library. - **Audio sounds mono or muffled (Bluetooth headsets)**: Windows exposes a Bluetooth headset as two output devices — a stereo "Headphones" (A2DP) endpoint, and a mono "Hands-Free" (HFP) endpoint capped at 16 kHz. Windows switches to Hands-Free whenever an application opens the microphone. Check `echo-debug-audio-spotify.log`: if it reports `channels=1`, quit whatever is holding the mic and select the stereo endpoint as your default output device. -- **Configuration Path**: `~/.config/echo/config.toml` (holds tokens and preferences), `~/.config/echo/cache.json` (holds liked tracks), `~/.config/echo/local_library.json`, and `~/.config/echo/local_playlists.json`. +- **Configuration Path**: `~/.config/echo/config.toml` (holds tokens and preferences), `~/.config/echo/cache.json` (holds library caches and liked-state hearts), `~/.config/echo/liked_songs.json` (holds the Liked Songs list), `~/.config/echo/local_library.json`, and `~/.config/echo/local_playlists.json`. diff --git a/README.zh.md b/README.zh.md index e7f5265..44a6978 100644 --- a/README.zh.md +++ b/README.zh.md @@ -22,7 +22,7 @@ echo 是一款用 Rust 编写的原生桌面音乐播放器和 Spotify 客户端 - **全面的播放控制**:在正在播放栏中即可播放/暂停、上一曲/下一曲、跳转进度、随机播放、重复、音量、队列和设备切换。 - **同步歌词**:时间同步的歌词可直接显示在播放栏中,或以全屏视图呈现。 - **最新动态**:来自您关注艺术家的近期专辑与单曲信息流,最多每 6 小时刷新一次。 -- **极速喜欢的歌曲**:您整个喜欢的歌曲库会缓存在本地(`~/.config/echo/cache.json`),实现零延迟、无速率限制的滚动浏览,即使有数千首保存的曲目也毫无压力。 +- **极速喜欢的歌曲**:您整个喜欢的歌曲库会缓存在本地(`~/.config/echo/liked_songs.json`),实现零延迟的滚动浏览,即使有数千首保存的曲目也毫无压力。保持同步通常只需一次请求;完整重读会限速进行、可断点续传,并在 Spotify 限流时自动退避。 - **库管理**:创建、重命名、删除播放列表并将其组织到文件夹中;在自己的播放列表中重新排列曲目顺序。 - **本地音乐支持**:扫描本地音乐文件夹,播放本地文件,创建也可引用 Spotify 曲目的本地播放列表。 - **搜索**:快速全局搜索(`ctrl-k`),覆盖 Spotify 目录和已扫描的本地曲目。 @@ -294,7 +294,7 @@ device=Headphones (WH-1000XM5) channels=2 sample_rate=48000 format=F32 - **主题颜色渲染问题 (Windows)**:在“默认值”配置文件的“外观”设置中,禁用“调整难以区分的文本”。 - **图像无法渲染**:封面使用半格单元格绘制,只需要终端支持真彩色——所有现代终端都已具备。 -- **缓存不同步**:如果您喜欢的歌曲与其他设备不同步,只需重启 echo。它会在启动时在后台急切地同步您的库。 +- **缓存不同步**:其他设备上喜欢的歌曲会在启动时或打开喜欢的歌曲时出现(最多每 15 分钟检查一次)。在其他设备上取消喜欢的歌曲也会在同一时机被发现,并触发后台重读整个库;被限流的重读会从中断处继续。在 echo 关闭时删除 `~/.config/echo/liked_songs.json` 可强制完整重读。 - **本地文件丢失**:如果文件在扫描后被删除或移动,运行 `:rescanlocal` 以刷新本地库。 - **音频听起来单声道或沉闷(蓝牙耳机)**:Windows 将蓝牙耳机暴露为两个输出设备——立体声的“耳机”(A2DP)端点,以及限制在 16 kHz 的单声道“免提”(HFP)端点。每当有应用程序打开麦克风时,Windows 就会切换到免提模式。检查 `echo-debug-audio-spotify.log`:如果报告 `channels=1`,请退出占用麦克风的程序,并将立体声端点设为默认输出设备。 -- **配置文件路径**:`~/.config/echo/config.toml`(保存令牌和偏好设置)、`~/.config/echo/cache.json`(保存喜欢的曲目)、`~/.config/echo/local_library.json` 和 `~/.config/echo/local_playlists.json`。 +- **配置文件路径**:`~/.config/echo/config.toml`(保存令牌和偏好设置)、`~/.config/echo/cache.json`(保存库缓存和喜欢状态)、`~/.config/echo/liked_songs.json`(保存喜欢的歌曲列表)、`~/.config/echo/local_library.json` 和 `~/.config/echo/local_playlists.json`。 diff --git a/README.zht.md b/README.zht.md index b4d4df8..1d532fc 100644 --- a/README.zht.md +++ b/README.zht.md @@ -22,7 +22,7 @@ echo 是一款用 Rust 編寫的原生桌面音樂播放器和 Spotify 用戶端 - **全面的播放控制**:在正在播放列中即可播放/暫停、上一曲/下一曲、跳轉進度、隨機播放、重複、音量、佇列和裝置切換。 - **同步歌詞**:時間同步的歌詞可直接顯示在播放列中,或以全螢幕檢視呈現。 - **最新動態**:來自您追蹤藝術家的近期專輯與單曲資訊流,最多每 6 小時重新整理一次。 -- **極速喜歡的歌曲**:採用全域快取架構。您整個喜歡的歌曲庫會快取在本地(`~/.config/echo/cache.json`),實現零延遲、無速率限制的捲動瀏覽,即使有數千首儲存的曲目也毫無壓力。 +- **極速喜歡的歌曲**:採用全域快取架構。您整個喜歡的歌曲庫會快取在本地(`~/.config/echo/liked_songs.json`),實現零延遲的捲動瀏覽,即使有數千首儲存的曲目也毫無壓力。保持同步通常只需一次請求;完整重讀會限速進行、可斷點續傳,並在 Spotify 限流時自動退避。 - **庫管理**:建立、重新命名、刪除播放清單並將其組織到資料夾中;在自己的播放清單中重新排列曲目順序。 - **本地音樂支援**:掃描本地音樂資料夾,播放本地檔案,建立也可引用 Spotify 曲目的本地播放清單。 - **搜尋**:快速全域搜尋(`ctrl-k`),涵蓋 Spotify 目錄和已掃描的本地曲目。 @@ -294,7 +294,7 @@ device=Headphones (WH-1000XM5) channels=2 sample_rate=48000 format=F32 - **主題顏色渲染問題 (Windows)**:在「預設值」設定檔的「外觀」設定中,停用「調整難以分辨的文字」。 - **影像無法渲染**:封面使用半格儲存格繪製,只需要終端機支援全彩——所有現代終端機都已具備。 -- **快取不同步**:如果您喜歡的歌曲與其他裝置不同步,只需重新啟動 echo。它會在啟動時在背景積極地同步您的庫。 +- **快取不同步**:其他裝置上喜歡的歌曲會在啟動時或開啟喜歡的歌曲時出現(最多每 15 分鐘檢查一次)。在其他裝置上取消喜歡的歌曲也會在同一時機被發現,並觸發背景重讀整個庫;被限流的重讀會從中斷處繼續。在 echo 關閉時刪除 `~/.config/echo/liked_songs.json` 可強制完整重讀。 - **本地檔案遺失**:如果檔案在掃描後被刪除或移動,執行 `:rescanlocal` 以重新整理本地庫。 - **音訊聽起來像單聲道或悶悶的(藍牙耳機)**:Windows 將藍牙耳機暴露為兩個輸出裝置——立體聲的「耳機」(A2DP)端點,以及限制在 16 kHz 的單聲道「免持」(HFP)端點。每當有應用程式開啟麥克風時,Windows 就會切換到免持模式。檢查 `echo-debug-audio-spotify.log`:如果報告 `channels=1`,請結束占用麥克風的程式,並將立體聲端點設為預設輸出裝置。 -- **設定檔路徑**:`~/.config/echo/config.toml`(儲存權杖和偏好設定)、`~/.config/echo/cache.json`(儲存喜歡的曲目)、`~/.config/echo/local_library.json` 和 `~/.config/echo/local_playlists.json`。 +- **設定檔路徑**:`~/.config/echo/config.toml`(儲存權杖和偏好設定)、`~/.config/echo/cache.json`(儲存庫快取和喜歡狀態)、`~/.config/echo/liked_songs.json`(儲存喜歡的歌曲列表)、`~/.config/echo/local_library.json` 和 `~/.config/echo/local_playlists.json`。 diff --git a/release.md b/release.md index 83f0531..94006fa 100644 --- a/release.md +++ b/release.md @@ -5,3 +5,10 @@ - Artist portraits and covers that are taller than they are wide no longer stretch into ovals or push into the title row; every picture box is centre-cropped to a square - Song rows on the search page select on a single click and play on a double click, like every other list - Recent searches drop down under the search box while it has focus instead of replacing the page, and narrow to the entries containing what you type; clicking one runs it again, and the remove and clear buttons keep the box focused + +### Liked Songs + - Liked Songs shows the whole library instead of the first 100 songs, in both apps, straight from `~/.config/echo/liked_songs.json` + - Keeping it current usually costs one request: new likes show up at the top, and a full re-read happens only on first use, when songs were removed on another device, or once a week + - A full re-read goes one page a second, stops on a rate limit and waits out Spotify's retry time, and picks up where it left off after a restart + - A failed or rate-limited sync no longer leaves hearts missing, and hearts on local files are no longer cleared by it + - Unliking a song in echo removes it from the open list right away From ff4cc42db2cf9b00af080e07aaa6d4b3a39d50b7 Mon Sep 17 00:00:00 2001 From: Andre Sun Date: Sat, 26 Sep 2026 14:12:15 -0400 Subject: [PATCH 5/6] docs: clean up old changelog --- release.md | 6 ------ 1 file changed, 6 deletions(-) diff --git a/release.md b/release.md index 94006fa..eeed27a 100644 --- a/release.md +++ b/release.md @@ -1,11 +1,5 @@ # Changelog -### Desktop App - - Right-click and `q` / `shift-a` now work on the song rows of the search page, on the All overview as well as the Songs tab - - Artist portraits and covers that are taller than they are wide no longer stretch into ovals or push into the title row; every picture box is centre-cropped to a square - - Song rows on the search page select on a single click and play on a double click, like every other list - - Recent searches drop down under the search box while it has focus instead of replacing the page, and narrow to the entries containing what you type; clicking one runs it again, and the remove and clear buttons keep the box focused - ### Liked Songs - Liked Songs shows the whole library instead of the first 100 songs, in both apps, straight from `~/.config/echo/liked_songs.json` - Keeping it current usually costs one request: new likes show up at the top, and a full re-read happens only on first use, when songs were removed on another device, or once a week From a207b196c4a53104168e1534556b2323800a3099 Mon Sep 17 00:00:00 2001 From: Andre Sun Date: Sat, 26 Sep 2026 14:15:25 -0400 Subject: [PATCH 6/6] desktop: run the Linux tray on ksni's async-io backend ksni's default "tokio" feature turns on zbus's tokio backend for the whole build. GPUI's own zbus users (AccessKit, the XDG portals, notify-rust) then call into zbus from threads outside any tokio runtime, and zbus panics with "there is no reactor running" when it spawns a blocking task. With async-io, ksni and zbus drive D-Bus on their own executor and no longer assume a tokio runtime. --- Cargo.lock | 18 +++++++++++++++--- crates/echo-desktop/Cargo.toml | 5 ++++- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9465d9c..fd53d7d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4140,10 +4140,15 @@ version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "814b44c24cd2cb236c3b8a41c7f08237b452a8e76ecaa81f1cec40b5b678215b" dependencies = [ + "async-executor", + "async-io", + "async-lock", + "futures-channel", + "futures-lite", "futures-util", "pastey 0.2.3", "serde", - "tokio", + "task-local", "zbus", ] @@ -8469,6 +8474,15 @@ version = "0.12.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" +[[package]] +name = "task-local" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2972044a9e5e448a506a7ff6f0d03b566d8ef4cd6918a58fc59835a0f8666626" +dependencies = [ + "pin-project-lite", +] + [[package]] name = "tauri-winrt-notification" version = "0.7.3" @@ -8735,7 +8749,6 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", - "tracing", "windows-sys 0.61.2", ] @@ -10968,7 +10981,6 @@ dependencies = [ "rustix 1.1.4", "serde", "serde_repr", - "tokio", "tracing", "uds_windows", "uuid", diff --git a/crates/echo-desktop/Cargo.toml b/crates/echo-desktop/Cargo.toml index 082918c..60875f4 100644 --- a/crates/echo-desktop/Cargo.toml +++ b/crates/echo-desktop/Cargo.toml @@ -40,7 +40,10 @@ gpui_platform = { git = "https://github.com/zed-industries/zed", rev = "a8cae3bd tray-icon = "0.24" [target.'cfg(target_os = "linux")'.dependencies] -ksni = "0.3" +# Not ksni's default "tokio" feature: it switches zbus to tokio for the whole build, and +# GPUI's own zbus users (accessibility, portals, notifications) then panic with "no reactor +# running" because they call into zbus from threads outside any tokio runtime. +ksni = { version = "0.3", default-features = false, features = ["async-io"] } [build-dependencies] embed-resource = "3"