From 77abe0ff706360162a6fb03d0f0763cf712f1204 Mon Sep 17 00:00:00 2001 From: cucadmuh <49571317+cucadmuh@users.noreply.github.com> Date: Thu, 16 Jul 2026 20:24:46 +0300 Subject: [PATCH] fix(home): keep multi-server feeds responsive --- .../migrations/019_mainstage_feed_indexes.sql | 8 + .../psysonic-library/src/mainstage_browse.rs | 496 +++++++++++++++--- .../crates/psysonic-library/src/store.rs | 23 +- src/cover/useLibraryCoverPrefetch.test.ts | 95 ++++ src/cover/useLibraryCoverPrefetch.ts | 18 +- src/cover/warmDiskPeek.test.ts | 10 +- src/cover/warmDiskPeek.ts | 8 +- src/features/home/pages/Home.test.ts | 28 +- src/features/home/pages/Home.tsx | 67 ++- src/features/home/pages/homeCoverPrefetch.ts | 24 + .../home/pages/homeFeedLoader.test.ts | 101 ++-- src/features/home/pages/homeFeedLoader.ts | 125 +++-- src/features/home/store/homeFeedCache.test.ts | 19 + src/features/home/store/homeFeedCache.ts | 14 + .../store/startupPlayQueueReconcile.test.ts | 25 +- .../store/startupPlayQueueReconcile.ts | 2 +- 16 files changed, 875 insertions(+), 188 deletions(-) create mode 100644 src-tauri/crates/psysonic-library/migrations/019_mainstage_feed_indexes.sql create mode 100644 src/cover/useLibraryCoverPrefetch.test.ts diff --git a/src-tauri/crates/psysonic-library/migrations/019_mainstage_feed_indexes.sql b/src-tauri/crates/psysonic-library/migrations/019_mainstage_feed_indexes.sql new file mode 100644 index 00000000..29858060 --- /dev/null +++ b/src-tauri/crates/psysonic-library/migrations/019_mainstage_feed_indexes.sql @@ -0,0 +1,8 @@ +-- Candidate-first New Releases: seek one selected library in descending +-- server creation order, with album/track ids available for stable ties. +CREATE INDEX IF NOT EXISTS idx_track_library_created_album + ON track(server_id, library_id, server_created_at DESC, album_id, id) + WHERE deleted = 0 + AND server_created_at IS NOT NULL + AND album_id IS NOT NULL + AND album_id != ''; diff --git a/src-tauri/crates/psysonic-library/src/mainstage_browse.rs b/src-tauri/crates/psysonic-library/src/mainstage_browse.rs index 69c5cd6c..e31e25ec 100644 --- a/src-tauri/crates/psysonic-library/src/mainstage_browse.rs +++ b/src-tauri/crates/psysonic-library/src/mainstage_browse.rs @@ -2,113 +2,243 @@ use rusqlite::types::Value as SqlValue; use rusqlite::params_from_iter; +use serde_json::Value; +use crate::album_compilation_filter::pick_album_group_artist; use crate::browse_support::overlay_album_starred_at_rows; use crate::dto::{ - LibraryMainstageAlbumFeed, LibraryMainstageAlbumsRequest, LibraryMainstageAlbumsResponse, + LibraryAlbumDto, LibraryMainstageAlbumFeed, LibraryMainstageAlbumsRequest, + LibraryMainstageAlbumsResponse, LibraryScopePair, }; use crate::scope_merge::{ - album_row_to_dto, ensure_cluster_keys_for_scopes, non_empty_scopes, scope_cte_sql, - ALBUM_DEDUP_KEY, ALBUM_PICK_KEY, TRACK_DEDUP_KEY, + non_empty_scopes, scope_cte_sql, ALBUM_DEDUP_KEY, ALBUM_PICK_KEY, }; use crate::search::PAGE_LIMIT_MAX; use crate::store::LibraryStore; +const CANDIDATE_MULTIPLIER: u32 = 8; +const CANDIDATE_MARGIN: u32 = 128; +const MAX_CANDIDATE_LIMIT: u32 = 65_536; + +fn candidate_limit(offset: u32, fetch_limit: u32) -> u32 { + offset + .saturating_add(fetch_limit) + .saturating_mul(CANDIDATE_MULTIPLIER) + .saturating_add(CANDIDATE_MARGIN) +} + +fn candidate_columns(feed_at: &str, priority: usize) -> String { + format!( + "t.server_id, t.album_id, t.album, t.artist, t.artist_id, t.album_artist, \ + t.year, t.genre, t.cover_art_id, t.starred_at, t.synced_at, t.id, \ + {priority} AS pr, ck.album_key, {ALBUM_DEDUP_KEY} AS album_dedup, \ + {feed_at} AS feed_at" + ) +} + +fn new_release_candidates_sql(scopes: &[LibraryScopePair]) -> String { + scopes + .iter() + .enumerate() + .map(|(priority, _)| { + let columns = candidate_columns("t.server_created_at", priority); + format!( + "SELECT * FROM ( \ + SELECT {columns} \ + FROM track t INDEXED BY idx_track_library_created_album \ + LEFT JOIN cluster.track_cluster_key ck \ + ON ck.server_id = t.server_id AND ck.track_id = t.id \ + WHERE t.server_id = ? AND t.library_id = ? \ + AND t.deleted = 0 AND t.server_created_at IS NOT NULL \ + AND t.album_id IS NOT NULL AND t.album_id != '' \ + ORDER BY t.server_created_at DESC, t.album_id ASC, t.id ASC \ + LIMIT ? \ + )" + ) + }) + .collect::>() + .join(" UNION ALL ") +} + +fn recently_played_candidates_sql() -> String { + let columns = candidate_columns("ps.started_at_ms", 0); + format!( + "SELECT {columns} \ + FROM play_session ps INDEXED BY idx_play_session_started \ + INNER JOIN track t INDEXED BY sqlite_autoindex_track_1 \ + ON t.server_id = ps.server_id AND t.id = ps.track_id \ + INNER JOIN scope matched_scope \ + ON matched_scope.server_id = t.server_id \ + AND matched_scope.library_id = t.library_id \ + LEFT JOIN cluster.track_cluster_key ck \ + ON ck.server_id = t.server_id AND ck.track_id = t.id \ + WHERE t.deleted = 0 AND t.album_id IS NOT NULL AND t.album_id != '' \ + ORDER BY ps.started_at_ms DESC \ + LIMIT ?" + ) + .replace("0 AS pr", "matched_scope.pr AS pr") +} + +fn build_mainstage_query( + scopes: &[LibraryScopePair], + feed: LibraryMainstageAlbumFeed, + bounded_candidates: u32, + result_offset: u32, + result_limit: u32, +) -> (String, Vec) { + let (cte, mut binds) = scope_cte_sql(scopes); + let candidates_sql = match feed { + LibraryMainstageAlbumFeed::NewReleases => { + for pair in scopes { + binds.push(SqlValue::Text(pair.server_id.clone())); + binds.push(SqlValue::Text(pair.library_id.clone())); + binds.push(SqlValue::Integer(i64::from(bounded_candidates))); + } + new_release_candidates_sql(scopes) + } + LibraryMainstageAlbumFeed::RecentlyPlayed => { + binds.push(SqlValue::Integer(i64::from(bounded_candidates))); + recently_played_candidates_sql() + } + }; + + let sql = format!( + "{cte}, \ + candidates AS MATERIALIZED ({candidates_sql}), \ + candidate_groups AS ( \ + SELECT album_dedup, MAX(feed_at) AS feed_at, MAX(album_key) AS album_key \ + FROM candidates GROUP BY album_dedup \ + ), \ + representative_pool AS ( \ + SELECT t.server_id, t.album_id, t.album, t.artist, t.artist_id, t.album_artist, \ + t.year, t.genre, t.cover_art_id, t.starred_at, t.synced_at, t.id, \ + s.pr, grouped.album_dedup \ + FROM candidate_groups grouped \ + CROSS JOIN scope s \ + CROSS JOIN cluster.track_cluster_key ck INDEXED BY idx_ck_scope_album \ + ON ck.server_id = s.server_id AND ck.library_id = s.library_id \ + AND ck.album_key = grouped.album_key \ + INNER JOIN track t INDEXED BY sqlite_autoindex_track_1 \ + ON t.server_id = ck.server_id AND t.id = ck.track_id \ + WHERE grouped.album_key IS NOT NULL AND t.deleted = 0 \ + AND t.library_id = s.library_id \ + AND t.album_id IS NOT NULL AND t.album_id != '' \ + UNION ALL \ + SELECT server_id, album_id, album, artist, artist_id, album_artist, \ + year, genre, cover_art_id, starred_at, synced_at, id, pr, album_dedup \ + FROM candidates WHERE album_key IS NULL \ + ), \ + representatives AS ( \ + SELECT server_id, album_id, album, artist, artist_id, album_artist, \ + year, genre, cover_art_id, starred_at, synced_at, album_dedup, \ + MIN({ALBUM_PICK_KEY}) AS _pick \ + FROM representative_pool GROUP BY album_dedup \ + ) \ + SELECT representative.server_id, representative.album_id, representative.album, \ + representative.artist, representative.artist_id, representative.album_artist, \ + representative.year, representative.genre, representative.cover_art_id, \ + representative.starred_at, representative.synced_at, \ + (SELECT COUNT(*) FROM candidates) AS candidate_count \ + FROM representatives representative \ + INNER JOIN candidate_groups grouped \ + ON grouped.album_dedup = representative.album_dedup \ + ORDER BY grouped.feed_at DESC, representative.album COLLATE NOCASE ASC, \ + representative.server_id ASC, representative.album_id ASC \ + LIMIT ? OFFSET ?" + ); + binds.push(SqlValue::Integer(i64::from(result_limit))); + binds.push(SqlValue::Integer(i64::from(result_offset))); + (sql, binds) +} + +fn map_mainstage_album( + r: &rusqlite::Row<'_>, +) -> rusqlite::Result<(LibraryAlbumDto, u32)> { + let track_artist = r.get(3)?; + let album_artist = r.get(5)?; + Ok(( + LibraryAlbumDto { + server_id: r.get(0)?, + id: r.get(1)?, + name: r.get(2)?, + artist: pick_album_group_artist(track_artist, album_artist), + artist_id: r.get(4)?, + song_count: None, + duration_sec: None, + year: r.get(6)?, + genre: r.get(7)?, + cover_art_id: r.get(8)?, + starred_at: r.get(9)?, + synced_at: r.get(10)?, + raw_json: Value::Null, + }, + r.get(11)?, + )) +} + pub fn list_mainstage_albums( store: &LibraryStore, request: &LibraryMainstageAlbumsRequest, ) -> Result { let scopes = non_empty_scopes(&request.scopes)?; - ensure_cluster_keys_for_scopes(store, scopes)?; let limit = request.limit.unwrap_or(30).clamp(1, PAGE_LIMIT_MAX); let offset = request.offset.unwrap_or(0); let fetch_limit = limit.saturating_add(1); - let (cte, mut binds) = scope_cte_sql(scopes); - let feed_times_sql = match request.feed { - LibraryMainstageAlbumFeed::NewReleases => { - "SELECT album_dedup, MAX(server_created_at) AS feed_at \ - FROM selected WHERE server_created_at IS NOT NULL GROUP BY album_dedup" - } - LibraryMainstageAlbumFeed::RecentlyPlayed => { - "SELECT st.album_dedup, MAX(ps.started_at_ms) AS feed_at \ - FROM selected st \ - INNER JOIN play_session ps \ - ON ps.server_id = st.server_id AND ps.track_id = st.id \ - GROUP BY st.album_dedup" - } - }; + let requested_results = offset.saturating_add(fetch_limit); + let initial_candidates = candidate_limit(offset, fetch_limit); - let sql = format!( - "{cte}, \ - selected AS ( \ - SELECT t.server_id, t.album_id, t.album, t.artist, t.artist_id, t.album_artist, \ - t.year, t.genre, t.cover_art_id, t.starred_at, t.synced_at, t.duration_sec, \ - t.server_created_at, t.id, s.pr, {ALBUM_DEDUP_KEY} AS album_dedup, \ - {TRACK_DEDUP_KEY} AS track_dedup \ - FROM scope s \ - CROSS JOIN track t ON t.server_id = s.server_id AND t.library_id = s.library_id \ - LEFT JOIN cluster.track_cluster_key ck \ - ON ck.server_id = t.server_id AND ck.track_id = t.id \ - WHERE t.deleted = 0 AND t.album_id IS NOT NULL AND t.album_id != '' \ - ), \ - feed_times AS ( \ - {feed_times_sql} \ - ), \ - albums AS ( \ - SELECT server_id, album_id, album, artist, artist_id, album_artist, \ - COUNT(DISTINCT track_dedup) AS song_count, SUM(duration_sec) AS duration_total, \ - year, genre, cover_art_id, starred_at, synced_at, album_dedup, \ - MIN({ALBUM_PICK_KEY}) AS _pick \ - FROM selected GROUP BY album_dedup \ - ) \ - SELECT a.server_id, a.album_id, a.album, a.artist, a.artist_id, a.album_artist, \ - a.song_count, a.duration_total, a.year, a.genre, a.cover_art_id, \ - a.starred_at, a.synced_at \ - FROM albums a \ - INNER JOIN feed_times f ON f.album_dedup = a.album_dedup \ - ORDER BY f.feed_at DESC, a.album COLLATE NOCASE ASC, a.server_id ASC, a.album_id ASC \ - LIMIT ? OFFSET ?", - ); - binds.push(SqlValue::Integer(i64::from(fetch_limit))); - binds.push(SqlValue::Integer(i64::from(offset))); - - store - .with_read_conn(|conn| { + store.with_read_conn(|conn| { + let mut bounded_candidates = initial_candidates; + loop { + let (sql, binds) = build_mainstage_query( + scopes, + request.feed, + bounded_candidates, + 0, + requested_results, + ); let mut stmt = conn.prepare(&sql)?; let rows = stmt - .query_map(params_from_iter(binds.iter()), |r| { - Ok(( - r.get(0)?, - r.get(1)?, - r.get(2)?, - r.get(3)?, - r.get(4)?, - r.get(5)?, - r.get(6)?, - r.get(7)?, - r.get(8)?, - r.get(9)?, - r.get(10)?, - r.get(11)?, - r.get(12)?, - )) - })? + .query_map(params_from_iter(binds.iter()), map_mainstage_album)? .collect::>>()?; - let mut albums = rows.into_iter().map(album_row_to_dto).collect::>(); + let candidate_count = rows.first().map(|(_, count)| *count).unwrap_or(0); + let candidate_capacity = match request.feed { + LibraryMainstageAlbumFeed::NewReleases => { + bounded_candidates.saturating_mul(scopes.len() as u32) + } + LibraryMainstageAlbumFeed::RecentlyPlayed => bounded_candidates, + }; + if rows.len() < requested_results as usize + && candidate_count >= candidate_capacity + && bounded_candidates < MAX_CANDIDATE_LIMIT + { + bounded_candidates = bounded_candidates + .saturating_mul(2) + .min(MAX_CANDIDATE_LIMIT); + continue; + } + let mut albums = rows + .into_iter() + .skip(offset as usize) + .map(|(album, _)| album) + .collect::>(); let has_more = albums.len() > limit as usize; albums.truncate(limit as usize); overlay_album_starred_at_rows(conn, &mut albums); - Ok(LibraryMainstageAlbumsResponse { albums, has_more }) - }) - .map_err(|e| e.to_string()) + return Ok(LibraryMainstageAlbumsResponse { albums, has_more }); + } + }).map_err(|e| e.to_string()) } #[cfg(test)] mod tests { use super::*; use crate::dto::{LibraryScopePair, PlaySessionInputDto}; + use crate::identity::ensure_cluster_keys_built; use crate::repos::{PlaySessionRepository, TrackRepository, TrackRow}; + use rusqlite::params; + use std::time::{Duration, Instant}; fn scope(server_id: &str, library_id: &str) -> LibraryScopePair { LibraryScopePair { @@ -271,6 +401,8 @@ mod tests { track("s2", "t-later", "Shared", "later-id", "l2", Some(500)), ]) .unwrap(); + ensure_cluster_keys_built(&store, "s1").unwrap(); + ensure_cluster_keys_built(&store, "s2").unwrap(); let response = list_mainstage_albums( &store, @@ -285,6 +417,36 @@ mod tests { assert_eq!(response.albums[0].id, "priority-id"); } + #[test] + fn missing_cluster_keys_use_non_merge_fallback_without_rebuild() { + let store = LibraryStore::open_in_memory(); + TrackRepository::new(&store) + .upsert_batch(&[ + track("s1", "t1", "Shared", "a1", "l1", Some(200)), + track("s2", "t2", "Shared", "a2", "l2", Some(100)), + ]) + .unwrap(); + + let response = list_mainstage_albums( + &store, + &request( + vec![scope("s1", "l1"), scope("s2", "l2")], + LibraryMainstageAlbumFeed::NewReleases, + ), + ) + .unwrap(); + assert_eq!(response.albums.len(), 2); + + let key_count: i64 = store + .with_read_conn(|conn| { + conn.query_row("SELECT COUNT(*) FROM cluster.track_cluster_key", [], |row| { + row.get(0) + }) + }) + .unwrap(); + assert_eq!(key_count, 0, "latency-sensitive browse must not rebuild keys"); + } + #[test] fn pagination_fetches_one_extra_for_has_more() { let store = LibraryStore::open_in_memory(); @@ -308,6 +470,47 @@ mod tests { assert!(!second.has_more); } + #[test] + fn candidate_window_expands_when_one_album_dominates_newest_tracks() { + let store = LibraryStore::open_in_memory(); + let mut tracks = (0..220) + .map(|n| track("s1", &format!("shared-{n}"), "Shared", "shared", "l1", Some(1_000 - n))) + .collect::>(); + tracks.push(track("s1", "other", "Other", "other", "l1", Some(700))); + TrackRepository::new(&store).upsert_batch(&tracks).unwrap(); + + let mut req = request(vec![scope("s1", "l1")], LibraryMainstageAlbumFeed::NewReleases); + req.limit = Some(2); + let response = list_mainstage_albums(&store, &req).unwrap(); + + assert_eq!(response.albums.len(), 2); + assert_eq!(response.albums[0].name, "Shared"); + assert_eq!(response.albums[1].name, "Other"); + } + + #[test] + fn candidate_window_expands_when_one_album_dominates_recent_sessions() { + let store = LibraryStore::open_in_memory(); + TrackRepository::new(&store) + .upsert_batch(&[ + track("s1", "shared", "Shared", "shared", "l1", Some(1)), + track("s1", "other", "Other", "other", "l1", Some(1)), + ]) + .unwrap(); + for started_at_ms in 1..=220 { + play(&store, "s1", "shared", 1_000 + started_at_ms); + } + play(&store, "s1", "other", 700); + + let mut req = request(vec![scope("s1", "l1")], LibraryMainstageAlbumFeed::RecentlyPlayed); + req.limit = Some(2); + let response = list_mainstage_albums(&store, &req).unwrap(); + + assert_eq!(response.albums.len(), 2); + assert_eq!(response.albums[0].name, "Shared"); + assert_eq!(response.albums[1].name, "Other"); + } + #[test] fn feed_and_response_serialize_with_ipc_camel_case() { assert_eq!( @@ -334,6 +537,8 @@ mod tests { track("s2", "t-later", "Shared", "later-id", "l2", Some(500)), ]) .unwrap(); + ensure_cluster_keys_built(&store, "s1").unwrap(); + ensure_cluster_keys_built(&store, "s2").unwrap(); store .with_conn("test.mainstage_star", |conn| { conn.execute( @@ -357,4 +562,145 @@ mod tests { assert_eq!(response.albums[0].server_id, "s1"); assert_eq!(response.albums[0].starred_at, Some(1234)); } + + fn query_plan( + store: &LibraryStore, + scopes: &[LibraryScopePair], + feed: LibraryMainstageAlbumFeed, + ) -> Vec { + let (sql, binds) = build_mainstage_query( + scopes, + feed, + candidate_limit(0, 31), + 0, + 31, + ); + store + .with_read_conn(|conn| { + let mut stmt = conn.prepare(&format!("EXPLAIN QUERY PLAN {sql}"))?; + let plan = stmt + .query_map(params_from_iter(binds.iter()), |row| row.get(3))? + .collect::>>()?; + Ok(plan) + }) + .unwrap() + } + + #[test] + fn mainstage_query_plans_use_bounded_feed_indexes() { + let store = LibraryStore::open_in_memory(); + let scopes = vec![scope("s1", "l1"), scope("s2", "l2")]; + + let releases = query_plan(&store, &scopes, LibraryMainstageAlbumFeed::NewReleases); + assert!( + releases + .iter() + .any(|line| line.contains("idx_track_library_created_album")), + "New Releases plan did not use created index: {releases:#?}" + ); + assert!( + !releases + .iter() + .any(|line| line == "SCAN t" || line.contains("SCAN track")), + "New Releases plan contains an unindexed track scan: {releases:#?}" + ); + + let recent = query_plan(&store, &scopes, LibraryMainstageAlbumFeed::RecentlyPlayed); + assert!( + recent + .iter() + .any(|line| line.contains("idx_play_session_started")), + "Recently Played plan did not drive from newest sessions: {recent:#?}" + ); + assert!( + recent + .iter() + .any(|line| line.contains("sqlite_autoindex_track_1")), + "Recently Played plan did not use the track primary key: {recent:#?}" + ); + } + + #[test] + fn large_scoped_feeds_stay_bounded() { + const TRACKS: i64 = 214_000; + const SESSIONS: i64 = 40_000; + let store = LibraryStore::open_in_memory(); + store + .with_conn_mut("test.seed_mainstage_perf", |conn| { + conn.execute_batch( + "DROP TRIGGER track_ai; DROP TRIGGER track_ad; DROP TRIGGER track_au;", + )?; + let tx = conn.transaction()?; + { + let mut insert_track = tx.prepare( + "INSERT INTO track (server_id, id, title, artist, artist_id, album, \ + album_id, album_artist, duration_sec, year, genre, cover_art_id, \ + library_id, server_created_at, deleted, synced_at, raw_json) \ + VALUES (?1, ?2, ?3, 'Artist', 'artist', ?4, ?5, 'Artist', 180, \ + 2026, 'Rock', ?6, ?7, ?8, 0, 1, '{}')", + )?; + for n in 0..TRACKS { + let server = if n % 2 == 0 { "s1" } else { "s2" }; + let library = if n % 2 == 0 { "l1" } else { "l2" }; + let album = n / 10; + insert_track.execute(params![ + server, + format!("track-{n}"), + format!("Track {n}"), + format!("Album {album}"), + format!("album-{album}"), + format!("cover-{album}"), + library, + n, + ])?; + } + } + { + let mut insert_session = tx.prepare( + "INSERT INTO play_session \ + (server_id, track_id, started_at_ms, listened_sec, position_max_sec, \ + completion, end_reason) \ + VALUES (?1, ?2, ?3, 20.0, 20.0, 'partial', 'skip')", + )?; + for n in 0..SESSIONS { + let track_number = TRACKS - 1 - n; + let server = if track_number % 2 == 0 { "s1" } else { "s2" }; + insert_session.execute(params![ + server, + format!("track-{track_number}"), + n, + ])?; + } + } + tx.commit()?; + Ok(()) + }) + .unwrap(); + + let scopes = vec![scope("s1", "l1"), scope("s2", "l2")]; + let release_request = request(scopes.clone(), LibraryMainstageAlbumFeed::NewReleases); + let started = Instant::now(); + let releases = list_mainstage_albums(&store, &release_request).unwrap(); + let release_elapsed = started.elapsed(); + + let recent_request = request(scopes, LibraryMainstageAlbumFeed::RecentlyPlayed); + let started = Instant::now(); + let recent = list_mainstage_albums(&store, &recent_request).unwrap(); + let recent_elapsed = started.elapsed(); + + eprintln!( + "mainstage 214k fixture: releases={release_elapsed:?}, recent={recent_elapsed:?}" + ); + assert_eq!(releases.albums.len(), 30); + assert_eq!(recent.albums.len(), 30); + assert!(releases.albums.iter().all(|album| album.song_count.is_none())); + assert!( + release_elapsed < Duration::from_millis(500), + "New Releases regressed to an unbounded query: {release_elapsed:?}" + ); + assert!( + recent_elapsed < Duration::from_millis(500), + "Recently Played regressed to an unbounded query: {recent_elapsed:?}" + ); + } } diff --git a/src-tauri/crates/psysonic-library/src/store.rs b/src-tauri/crates/psysonic-library/src/store.rs index 5782f17b..183fd7ce 100644 --- a/src-tauri/crates/psysonic-library/src/store.rs +++ b/src-tauri/crates/psysonic-library/src/store.rs @@ -12,7 +12,7 @@ use tauri::Manager; /// /// Migration checklist (wiring, data backfill, open/swap path): /// psysonic-workdocs `ai/agent-rules/08-library-db-migrations.md`. -pub const LIBRARY_DB_SCHEMA_VERSION: i64 = 18; +pub const LIBRARY_DB_SCHEMA_VERSION: i64 = 19; /// One-time data repair after migration 014 (`artist.name_sort`). pub(crate) const ARTIST_NAME_SORT_RECONCILE_ID: &str = "artist_name_sort_reconcile_v1"; @@ -59,6 +59,10 @@ pub(crate) const MIGRATION_017_LIBRARY_TAG_STATE: &str = /// prune's freshness lookup is an index seek instead of a per-server scan. pub(crate) const MIGRATION_018_ARTIST_SYNCED_INDEX: &str = include_str!("../migrations/018_artist_synced_index.sql"); +/// Version 19: partial covering-order index for candidate-first main-stage +/// New Releases reads over one `(server_id, library_id)` scope. +pub(crate) const MIGRATION_019_MAINSTAGE_FEED_INDEXES: &str = + include_str!("../migrations/019_mainstage_feed_indexes.sql"); /// Embedded migrations. Ordered ascending by `version`; the runner sorts /// defensively before applying so the source order can stay readable. @@ -71,6 +75,7 @@ const MIGRATIONS: &[(i64, &str)] = &[ (16, MIGRATION_016_MULTI_LIBRARY_SCOPE), (17, MIGRATION_017_LIBRARY_TAG_STATE), (18, MIGRATION_018_ARTIST_SYNCED_INDEX), + (19, MIGRATION_019_MAINSTAGE_FEED_INDEXES), ]; /// Idempotent repair — also runs after the migration runner on every open so @@ -1434,6 +1439,22 @@ mod tests { assert!(stat_rows > 0, "ANALYZE should populate sqlite_stat1"); } + #[test] + fn migration_019_creates_mainstage_created_index() { + let store = LibraryStore::open_in_memory(); + let sql: String = store + .with_conn("test.mainstage_index", |conn| { + conn.query_row( + "SELECT sql FROM sqlite_master WHERE type = 'index' AND name = ?1", + params!["idx_track_library_created_album"], + |row| row.get(0), + ) + }) + .unwrap(); + assert!(sql.contains("server_id, library_id, server_created_at DESC, album_id, id")); + assert!(sql.contains("server_created_at IS NOT NULL")); + } + #[test] fn library_id_backfill_reconcile_populates_from_raw_json() { let store = LibraryStore::open_in_memory(); diff --git a/src/cover/useLibraryCoverPrefetch.test.ts b/src/cover/useLibraryCoverPrefetch.test.ts new file mode 100644 index 00000000..6f9233bc --- /dev/null +++ b/src/cover/useLibraryCoverPrefetch.test.ts @@ -0,0 +1,95 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { CoverArtRef } from './types'; + +const coverPrefetchRegister = vi.hoisted(() => vi.fn((_refs: CoverArtRef[]) => vi.fn())); +const resolveAlbumCoverRefsFromLibrary = vi.hoisted(() => vi.fn()); +const resolveArtistCoverRefsFromLibrary = vi.hoisted(() => vi.fn()); +const resolveTrackCoverRefsFromLibrary = vi.hoisted(() => vi.fn()); + +vi.mock('./prefetchRegistry', () => ({ coverPrefetchRegister })); +vi.mock('./resolveEntryLibrary', () => ({ + resolveAlbumCoverRefsFromLibrary, + resolveArtistCoverRefsFromLibrary, + resolveTrackCoverRefsFromLibrary, +})); + +import { useLibraryCoverPrefetch } from './useLibraryCoverPrefetch'; + +describe('useLibraryCoverPrefetch', () => { + beforeEach(() => { + coverPrefetchRegister.mockClear(); + resolveAlbumCoverRefsFromLibrary.mockReset(); + resolveArtistCoverRefsFromLibrary.mockReset(); + resolveTrackCoverRefsFromLibrary.mockReset(); + }); + + it('applies one global limit to direct refs across mixed owners without library resolves', async () => { + const refs = Array.from({ length: 18 }, (_, index): CoverArtRef => ({ + cacheKind: 'album', + cacheEntityId: `album-${index}`, + fetchCoverArtId: `cover-${index}`, + serverScope: { + kind: 'server', + serverId: index % 2 === 0 ? 'srv-a' : 'srv-b', + url: `https://srv-${index % 2}.test`, + username: 'user', + password: 'secret', + }, + })); + + renderHook(() => useLibraryCoverPrefetch([ + { refs, limit: 16, priority: 'middle' }, + ], [refs])); + + await waitFor(() => expect(coverPrefetchRegister).toHaveBeenCalledTimes(1)); + expect(coverPrefetchRegister.mock.calls[0]?.[0]).toHaveLength(16); + expect(coverPrefetchRegister.mock.calls[0]?.[0]).toEqual(refs.slice(0, 16)); + expect(resolveAlbumCoverRefsFromLibrary).not.toHaveBeenCalled(); + expect(resolveArtistCoverRefsFromLibrary).not.toHaveBeenCalled(); + expect(resolveTrackCoverRefsFromLibrary).not.toHaveBeenCalled(); + }); + + it('retains library resolution for ordinary album buckets', async () => { + const resolved: CoverArtRef = { + cacheKind: 'album', + cacheEntityId: 'album-1', + fetchCoverArtId: 'resolved-cover', + serverScope: { kind: 'active' }, + }; + resolveAlbumCoverRefsFromLibrary.mockResolvedValue([resolved]); + + renderHook(() => useLibraryCoverPrefetch([ + { albums: [{ id: 'album-1', coverArt: 'fallback-cover' }], priority: 'high' }, + ], [])); + + await waitFor(() => expect(coverPrefetchRegister).toHaveBeenCalledTimes(1)); + expect(resolveAlbumCoverRefsFromLibrary).toHaveBeenCalledWith( + [{ id: 'album-1', coverArt: 'fallback-cover' }], + { kind: 'active' }, + ); + expect(coverPrefetchRegister.mock.calls[0]?.[0]).toEqual([resolved]); + }); + + it('registers direct refs without waiting for another bucket library resolve', async () => { + let finishAlbumResolve: ((refs: CoverArtRef[]) => void) | undefined; + resolveAlbumCoverRefsFromLibrary.mockImplementation(() => new Promise(resolve => { + finishAlbumResolve = resolve; + })); + const direct: CoverArtRef = { + cacheKind: 'album', + cacheEntityId: 'discover', + fetchCoverArtId: 'discover-cover', + serverScope: { kind: 'active' }, + }; + + renderHook(() => useLibraryCoverPrefetch([ + { albums: [{ id: 'slow-album' }], priority: 'low' }, + { refs: [direct], priority: 'middle' }, + ], [])); + + await waitFor(() => expect(coverPrefetchRegister).toHaveBeenCalledTimes(1)); + expect(coverPrefetchRegister.mock.calls[0]?.[0]).toEqual([direct]); + finishAlbumResolve?.([]); + }); +}); diff --git a/src/cover/useLibraryCoverPrefetch.ts b/src/cover/useLibraryCoverPrefetch.ts index 8d844a15..378ec479 100644 --- a/src/cover/useLibraryCoverPrefetch.ts +++ b/src/cover/useLibraryCoverPrefetch.ts @@ -6,9 +6,16 @@ import { resolveArtistCoverRefsFromLibrary, resolveTrackCoverRefsFromLibrary, } from './resolveEntryLibrary'; -import { COVER_SCOPE_ACTIVE, type CoverPrefetchPriority, type CoverServerScope, type CoverSurfaceKind } from './types'; +import { + COVER_SCOPE_ACTIVE, + type CoverArtRef, + type CoverPrefetchPriority, + type CoverServerScope, + type CoverSurfaceKind, +} from './types'; export type LibraryCoverPrefetchBucket = { + refs?: ReadonlyArray; albums?: ReadonlyArray<{ id: string; coverArt?: string | null }>; artists?: ReadonlyArray<{ id: string; coverArt?: string | null }>; songs?: ReadonlyArray>; @@ -27,10 +34,10 @@ export function useLibraryCoverPrefetch( let cancelled = false; const unregisters: Array<() => void> = []; - void (async () => { - for (const bucket of buckets) { + void Promise.all(buckets.map(async bucket => { const scope = bucket.serverScope ?? COVER_SCOPE_ACTIVE; const refs = [ + ...(bucket.refs ?? []), ...(bucket.albums?.length ? await resolveAlbumCoverRefsFromLibrary(bucket.albums, scope) : []), @@ -42,15 +49,14 @@ export function useLibraryCoverPrefetch( : []), ]; const capped = bucket.limit != null ? refs.slice(0, bucket.limit) : refs; - if (cancelled || capped.length === 0) continue; + if (cancelled || capped.length === 0) return; unregisters.push( coverPrefetchRegister(capped, { surface: bucket.surface ?? 'dense', priority: bucket.priority, }), ); - } - })(); + })); return () => { cancelled = true; diff --git a/src/cover/warmDiskPeek.test.ts b/src/cover/warmDiskPeek.test.ts index 47b04d58..30dbc50c 100644 --- a/src/cover/warmDiskPeek.test.ts +++ b/src/cover/warmDiskPeek.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { albumCoverRef } from './ref'; const coverCachePeekBatch = vi.hoisted(() => vi.fn(async (_refs: unknown[]) => ({}))); +const resolveAlbumCoverRefFromLibrary = vi.hoisted(() => vi.fn()); vi.mock('@/lib/api/coverCache', () => ({ coverCachePeekBatch })); vi.mock('./diskSrcLookup', () => ({ @@ -24,11 +24,7 @@ vi.mock('./serverScope', () => ({ : { kind: 'active' }, })); vi.mock('./resolveEntryLibrary', () => ({ - resolveAlbumCoverRefFromLibrary: vi.fn(async ( - albumId: string, - coverArt: string, - serverScope: Parameters[2], - ) => albumCoverRef(albumId, coverArt, serverScope)), + resolveAlbumCoverRefFromLibrary, })); import { warmHomeMainstageCovers } from './warmDiskPeek'; @@ -36,6 +32,7 @@ import { warmHomeMainstageCovers } from './warmDiskPeek'; describe('warmHomeMainstageCovers', () => { beforeEach(() => { coverCachePeekBatch.mockClear(); + resolveAlbumCoverRefFromLibrary.mockClear(); }); it('builds owner-scoped album and song refs and uses song coverArt as the fetch fallback', async () => { @@ -75,5 +72,6 @@ describe('warmHomeMainstageCovers', () => { serverScope: expect.objectContaining({ kind: 'server', serverId: 'srv-owner' }), }), ])); + expect(resolveAlbumCoverRefFromLibrary).not.toHaveBeenCalled(); }); }); diff --git a/src/cover/warmDiskPeek.ts b/src/cover/warmDiskPeek.ts index 6b2aa34c..063a10b8 100644 --- a/src/cover/warmDiskPeek.ts +++ b/src/cover/warmDiskPeek.ts @@ -77,17 +77,17 @@ export function collectAlbumCoverWarmItems( return out; } -export async function collectSongCoverWarmItems( +export function collectSongCoverWarmItems( songs: ReadonlyArray<{ albumId?: string; coverArt?: string | null; serverId?: string }>, displayCssPx: number, surface: CoverSurfaceKind = 'dense', limit = 96, -): Promise { +): CoverWarmItem[] { const out: CoverWarmItem[] = []; for (const s of songs) { if (!s.albumId || out.length >= limit) break; out.push( - await coverWarmItemFromLibrary( + coverWarmItem( s.albumId, s.coverArt ?? s.albumId, displayCssPx, @@ -259,7 +259,7 @@ export async function warmHomeMainstageCovers(snapshot: { ...collectAlbumCoverWarmItems(snapshot.mostPlayed, 300, 'dense', 20), ...collectAlbumCoverWarmItems(snapshot.recentlyPlayed, 300, 'dense', 20), ...collectAlbumCoverWarmItems(snapshot.starred, 300, 'dense', 20), - ...(await collectSongCoverWarmItems(snapshot.discoverSongs ?? [], 200, 'dense', 20)), + ...collectSongCoverWarmItems(snapshot.discoverSongs ?? [], 200, 'dense', 20), ]); await warmCoverDiskSrcBatch(items); diff --git a/src/features/home/pages/Home.test.ts b/src/features/home/pages/Home.test.ts index 4127c0a0..c34f993a 100644 --- a/src/features/home/pages/Home.test.ts +++ b/src/features/home/pages/Home.test.ts @@ -4,7 +4,11 @@ vi.mock('@/cover/serverScope', () => ({ coverServerScopeForServerId: (serverId?: string) => ({ kind: 'test', serverId }), })); -import { groupHomeCoverPrefetchBuckets, shouldOfferHomeLoadMore } from './homeCoverPrefetch'; +import { + groupHomeCoverPrefetchBuckets, + homeDiscoverCoverPrefetchBucket, + shouldOfferHomeLoadMore, +} from './homeCoverPrefetch'; describe('groupHomeCoverPrefetchBuckets', () => { it('splits mixed albums, artists, and songs into owner-scoped buckets', () => { @@ -36,6 +40,28 @@ describe('groupHomeCoverPrefetchBuckets', () => { }), ])); }); + + it('keeps mixed-owner Discover refs in one globally limited bucket', () => { + const bucket = homeDiscoverCoverPrefetchBucket(Array.from({ length: 18 }, (_, index) => ({ + albumId: `album-${index}`, + coverArt: `cover-${index}`, + serverId: index % 2 === 0 ? 'srv-a' : 'srv-b', + }))); + + const buckets = groupHomeCoverPrefetchBuckets([bucket]); + + expect(buckets).toHaveLength(1); + expect(buckets[0]?.limit).toBe(16); + expect(buckets[0]?.refs).toHaveLength(18); + expect(buckets[0]?.refs?.[0]).toEqual(expect.objectContaining({ + cacheEntityId: 'album-0', + fetchCoverArtId: 'cover-0', + serverScope: { kind: 'test', serverId: 'srv-a' }, + })); + expect(buckets[0]?.refs?.[1]).toEqual(expect.objectContaining({ + serverScope: { kind: 'test', serverId: 'srv-b' }, + })); + }); }); describe('shouldOfferHomeLoadMore', () => { diff --git a/src/features/home/pages/Home.tsx b/src/features/home/pages/Home.tsx index 9c6b2750..3e98291b 100644 --- a/src/features/home/pages/Home.tsx +++ b/src/features/home/pages/Home.tsx @@ -23,6 +23,7 @@ import { isHomeFeedSnapshotEmpty, readHomeFeedCache, readHomeFeedCacheStale, + patchHomeFeedCache, writeHomeFeedCache, type HomeFeedSnapshot, } from '@/features/home/store/homeFeedCache'; @@ -34,12 +35,17 @@ import { appendServerQuery } from '@/lib/navigation/detailServerScope'; import { getLibraryBrowseScope } from '@/lib/library/libraryBrowseScope'; import { deriveHomeFeedScope, + loadHomeChronologicalFeed, loadHomeFeed, loadMoreHomeAlbums, + patchHomeChronologicalFeed, + preserveHomeChronologicalFeeds, + type HomeChronologicalFeedResult, type HomeAlbumSection, } from '@/features/home/pages/homeFeedLoader'; import { groupHomeCoverPrefetchBuckets, + homeDiscoverCoverPrefetchBucket, shouldOfferHomeLoadMore, } from '@/features/home/pages/homeCoverPrefetch'; @@ -109,6 +115,7 @@ export default function Home() { ); const [loading, setLoading] = useState(initialFeed == null); const displayedSnapshotRef = useRef(initialFeed); + const feedLoadVersionRef = useRef(0); const applyFeedSnapshot = (snap: HomeFeedSnapshot) => { displayedSnapshotRef.current = snap; @@ -138,7 +145,7 @@ export default function Home() { limit: 24, priority: 'low', }, - { songs: discoverSongs, limit: 16, priority: 'middle' }, + homeDiscoverCoverPrefetchBucket(discoverSongs), ]), [heroAlbums, recent, random, mostPlayed, recentlyPlayed, starred, randomArtists, discoverSongs, servers], ); @@ -146,12 +153,14 @@ export default function Home() { useEffect(() => { if (serverIds.length === 0 || !scopeKey || !anchorServerId) return; let cancelled = false; - const fetchFreshHomeFeed = async (): Promise => { + const loadVersion = ++feedLoadVersionRef.current; + const isCurrentLoad = () => !cancelled && feedLoadVersionRef.current === loadVersion; + const startFreshHomeFeed = () => { const mixCfg = getMixMinRatingsConfigFromAuth(); const albumMix = mixCfg.enabled && (mixCfg.minAlbum > 0 || mixCfg.minArtist > 0); const randomSize = albumMix ? HOME_RANDOM_FETCH : HOME_DISCOVER_SLICE; - return loadHomeFeed({ + const snapshot = loadHomeFeed({ serverIds, scopeKey, anchorServerId, @@ -163,6 +172,36 @@ export default function Home() { mixConfig: mixCfg, deps: { filterAlbumsByMixRatingsAcrossServers }, }); + const chronological = { + recent: loadHomeChronologicalFeed({ + anchorServerId, + scopes, + feed: 'newReleases', + }), + recentlyPlayed: loadHomeChronologicalFeed({ + anchorServerId, + scopes, + feed: 'recentlyPlayed', + }), + }; + return { snapshot, chronological }; + }; + const applyChronologicalResult = ( + section: 'recent' | 'recentlyPlayed', + result: HomeChronologicalFeedResult, + ) => { + if (result.status !== 'success' || !isCurrentLoad()) return; + const displayed = displayedSnapshotRef.current; + if (displayed?.scopeKey !== scopeKey || displayed.scopeVersion !== scopeVersion) return; + const nextDisplayed = patchHomeChronologicalFeed(displayed, section, result); + applyFeedSnapshot(nextDisplayed); + patchHomeFeedCache(scopeKey, scopeVersion, snapshot => ( + patchHomeChronologicalFeed(snapshot, section, result) + )); + }; + const patchChronologicalFeeds = (chronological: ReturnType['chronological']) => { + void chronological.recent.then(result => applyChronologicalResult('recent', result)); + void chronological.recentlyPlayed.then(result => applyChronologicalResult('recentlyPlayed', result)); }; const cached = readHomeFeedCache(scopeKey, scopeVersion) @@ -177,15 +216,19 @@ export default function Home() { void primeAlbumCoversForDisplay(becauseSnap?.recs ?? [], HOME_BECAUSE_CARD_COVER_CSS_PX, { limit: 6, }); - // Keep the current visit visually stable, but prepare fresh data so the - // next re-enter opens with a newer snapshot immediately. + // Keep cached content for first paint, then refresh this visit as soon as + // the independent server bundle is ready. if (!offlineBrowseActive) { void (async () => { try { - const fresh = await fetchFreshHomeFeed(); - if (!fresh || cancelled || isHomeFeedSnapshotEmpty(fresh)) return; - if (displayedSnapshotRef.current !== cached) return; + const freshLoad = startFreshHomeFeed(); + const loaded = await freshLoad.snapshot; + if (!isCurrentLoad()) return; + const fresh = preserveHomeChronologicalFeeds(loaded, displayedSnapshotRef.current); + if (isHomeFeedSnapshotEmpty(fresh)) return; writeHomeFeedCache(fresh); + applyFeedSnapshot(fresh); + patchChronologicalFeeds(freshLoad.chronological); void warmHomeMainstageCovers(fresh); } catch { /* ignore */ @@ -207,12 +250,14 @@ export default function Home() { setLoading(true); (async () => { try { - const snap = await fetchFreshHomeFeed(); - if (!snap) return; - if (cancelled) return; + const freshLoad = startFreshHomeFeed(); + const loaded = await freshLoad.snapshot; + if (!isCurrentLoad()) return; + const snap = preserveHomeChronologicalFeeds(loaded, displayedSnapshotRef.current); if (offlineBrowseActive && isHomeFeedSnapshotEmpty(snap)) return; writeHomeFeedCache(snap); applyFeedSnapshot(snap); + patchChronologicalFeeds(freshLoad.chronological); if (!cancelled) setLoading(false); void warmHomeMainstageCovers(snap); const becauseSnap = readBecauseYouLikeCache(scopeKey, scopeVersion); diff --git a/src/features/home/pages/homeCoverPrefetch.ts b/src/features/home/pages/homeCoverPrefetch.ts index 0fc25d8a..bf854f51 100644 --- a/src/features/home/pages/homeCoverPrefetch.ts +++ b/src/features/home/pages/homeCoverPrefetch.ts @@ -1,3 +1,4 @@ +import { albumCoverRef } from '@/cover/ref'; import { coverServerScopeForServerId } from '@/cover/serverScope'; import type { LibraryCoverPrefetchBucket } from '@/cover/useLibraryCoverPrefetch'; import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from '@/lib/api/subsonicTypes'; @@ -7,6 +8,10 @@ export function groupHomeCoverPrefetchBuckets( ): LibraryCoverPrefetchBucket[] { const grouped: LibraryCoverPrefetchBucket[] = []; for (const bucket of buckets) { + if (bucket.refs?.length) { + grouped.push(bucket); + continue; + } const byOwner = new Map(); const ownerBucket = (serverId?: string) => { const key = serverId?.trim() ?? ''; @@ -39,6 +44,25 @@ export function groupHomeCoverPrefetchBuckets( return grouped; } +export function homeDiscoverCoverPrefetchBucket( + songs: ReadonlyArray>, + limit = 16, +): LibraryCoverPrefetchBucket { + return { + refs: songs.flatMap(song => { + const albumId = song.albumId?.trim(); + if (!albumId) return []; + return [albumCoverRef( + albumId, + song.coverArt ?? albumId, + coverServerScopeForServerId(song.serverId), + )]; + }), + limit, + priority: 'middle', + }; +} + export function shouldOfferHomeLoadMore(hasMore: boolean): boolean { return hasMore; } diff --git a/src/features/home/pages/homeFeedLoader.test.ts b/src/features/home/pages/homeFeedLoader.test.ts index 0f74b69a..a55f28f4 100644 --- a/src/features/home/pages/homeFeedLoader.test.ts +++ b/src/features/home/pages/homeFeedLoader.test.ts @@ -7,8 +7,11 @@ import { advanceHomeOffsets, allocateHomeQuotas, deriveHomeFeedScope, + loadHomeChronologicalFeed, loadHomeFeed, loadMoreHomeAlbums, + patchHomeChronologicalFeed, + preserveHomeChronologicalFeeds, stableRoundRobin, withinHomeDeadline, } from '@/features/home/pages/homeFeedLoader'; @@ -82,7 +85,8 @@ describe('homeFeedLoader pure helpers', () => { }); describe('homeFeedLoader failure isolation', () => { - it('never requests newest/recent from servers and preserves local chronological order', async () => { + it('resolves the network snapshot while local chronological work is pending', async () => { + vi.useFakeTimers(); const getAlbumListForServer = vi.fn(async ( serverId: string, type: string, @@ -93,12 +97,14 @@ describe('homeFeedLoader failure isolation', () => { ) => { return Array.from({ length: size }, (_, index) => album(serverId, `${type}-${index}`)); }); - const libraryScopeListMainstageAlbums = vi.fn(async (_serverId, request) => ({ - albums: request.feed === 'newReleases' - ? [albumDto('b', 'new-3'), albumDto('a', 'new-2'), albumDto('b', 'new-1')] - : [albumDto('a', 'played-3'), albumDto('b', 'played-2')], - hasMore: true, - })); + const localPending = new Promise(() => {}); + const libraryScopeListMainstageAlbums = vi.fn(() => localPending); + const chronological = loadHomeChronologicalFeed({ + anchorServerId: 'a', + scopes: [{ serverId: 'a', libraryId: 'lib-a' }, { serverId: 'b', libraryId: 'lib-b' }], + feed: 'newReleases', + deps: { libraryScopeListMainstageAlbums }, + }); const result = await loadHomeFeed({ serverIds: ['a', 'b'], scopeKey: 'scope', scopeVersion: 7, randomSize: 20, anchorServerId: 'a', @@ -116,38 +122,59 @@ describe('homeFeedLoader failure isolation', () => { }); expect(getAlbumListForServer.mock.calls.map(call => call[1])).not.toContain('newest'); expect(getAlbumListForServer.mock.calls.map(call => call[1])).not.toContain('recent'); - expect(result.recent.map(item => `${item.serverId}:${item.id}`)) - .toEqual(['b:new-3', 'a:new-2', 'b:new-1']); - expect(result.recentlyPlayed.map(item => `${item.serverId}:${item.id}`)) - .toEqual(['a:played-3', 'b:played-2']); - expect(result.offsets.recent).toEqual({ offset: 3, hasMore: true }); - expect(result.offsets.recentlyPlayed).toEqual({ offset: 2, hasMore: true }); - expect(result.starred.some(item => item.serverId === 'a')).toBe(true); - expect(getAlbumListForServer.mock.calls.every(call => call[5] === HOME_REQUEST_TIMEOUT_MS)).toBe(true); - }); - - it('treats local errors as authoritative empty without network fallback', async () => { - const getAlbumListForServer = vi.fn(async (_serverId: string, _type: string) => []); - const result = await loadHomeFeed({ - serverIds: ['a'], scopeKey: 'scope', scopeVersion: 1, randomSize: 0, - anchorServerId: 'a', scopes: [{ serverId: 'a', libraryId: 'lib-a' }], - showArtists: false, showSongs: false, mixConfig, - deps: { - getAlbumListForServer: getAlbumListForServer as never, - libraryScopeListMainstageAlbums: vi.fn(async () => { throw new Error('local failed'); }), - getArtistsForServer: vi.fn(async () => []), - getRandomSongsForServer: vi.fn(async () => []), - runLocalRandomSongs: vi.fn(async () => null), - filterAlbumsByMixRatingsAcrossServers: vi.fn(async albums => albums), - shuffle: items => items, - }, - }); expect(result.recent).toEqual([]); expect(result.recentlyPlayed).toEqual([]); - expect(result.offsets.recent).toEqual({ offset: 0, hasMore: false }); - expect(result.offsets.recentlyPlayed).toEqual({ offset: 0, hasMore: false }); - expect(getAlbumListForServer.mock.calls.map(call => call[1])).not.toContain('newest'); - expect(getAlbumListForServer.mock.calls.map(call => call[1])).not.toContain('recent'); + expect(result.starred.some(item => item.serverId === 'a')).toBe(true); + expect(getAlbumListForServer.mock.calls.every(call => call[5] === HOME_REQUEST_TIMEOUT_MS)).toBe(true); + let chronologicalSettled = false; + void chronological.then(() => { chronologicalSettled = true; }); + await Promise.resolve(); + expect(chronologicalSettled).toBe(false); + vi.useRealTimers(); + }); + + it('preserves an existing chronological rail and hasMore after a local timeout', async () => { + vi.useFakeTimers(); + const current = snapshot(); + current.recent = [album('a', 'prior')]; + const resultPromise = loadHomeChronologicalFeed({ + anchorServerId: 'a', scopes: [{ serverId: 'a', libraryId: 'lib-a' }], + feed: 'newReleases', + deps: { + libraryScopeListMainstageAlbums: vi.fn(() => new Promise<{ + albums: LibraryAlbumDto[]; + hasMore: boolean; + }>(() => {})), + }, + }); + await vi.advanceTimersByTimeAsync(HOME_REQUEST_TIMEOUT_MS); + const result = await resultPromise; + expect(result).toEqual({ status: 'timeout' }); + const patched = patchHomeChronologicalFeed(current, 'recent', result); + expect(patched).toBe(current); + expect(patched.recent.map(item => item.id)).toEqual(['prior']); + expect(patched.offsets.recent).toEqual({ offset: 5, hasMore: true }); + const networkSnapshot = { ...current, recent: [], offsets: { + ...current.offsets, + recent: { offset: 0, hasMore: false }, + } }; + const preserved = preserveHomeChronologicalFeeds(networkSnapshot, current); + expect(preserved.recent.map(item => item.id)).toEqual(['prior']); + expect(preserved.offsets.recent).toEqual({ offset: 5, hasMore: true }); + vi.useRealTimers(); + }); + + it('distinguishes a successful empty chronological query from an error', async () => { + const success = await loadHomeChronologicalFeed({ + anchorServerId: 'a', scopes: [], feed: 'recentlyPlayed', + deps: { libraryScopeListMainstageAlbums: vi.fn(async () => ({ albums: [], hasMore: false })) }, + }); + const error = await loadHomeChronologicalFeed({ + anchorServerId: 'a', scopes: [], feed: 'recentlyPlayed', + deps: { libraryScopeListMainstageAlbums: vi.fn(async () => { throw new Error('failed'); }) }, + }); + expect(success).toEqual({ status: 'success', albums: [], hasMore: false }); + expect(error).toEqual({ status: 'error' }); }); it('uses per-server offsets, dedupes owner-qualified ids, and advances raw cursors', async () => { diff --git a/src/features/home/pages/homeFeedLoader.ts b/src/features/home/pages/homeFeedLoader.ts index 0395b7b0..cf1c2079 100644 --- a/src/features/home/pages/homeFeedLoader.ts +++ b/src/features/home/pages/homeFeedLoader.ts @@ -87,6 +87,19 @@ interface LoadMoreHomeAlbumsOptions { deps: Pick & Partial; } +interface LoadHomeChronologicalFeedOptions { + anchorServerId: string; + scopes: LibraryScopePair[]; + feed: 'newReleases' | 'recentlyPlayed'; + offset?: number; + deps?: Pick; +} + +export type HomeChronologicalFeedResult = + | { status: 'success'; albums: SubsonicAlbum[]; hasMore: boolean } + | { status: 'error' } + | { status: 'timeout' }; + interface ServerBundle { serverId: string; starred: OwnedAlbum[]; @@ -200,6 +213,67 @@ export async function withinHomeDeadline(request: Promise, fallback: T): P } } +export async function loadHomeChronologicalFeed( + options: LoadHomeChronologicalFeedOptions, +): Promise { + const deps = { ...defaultDeps, ...options.deps }; + let timer: ReturnType | undefined; + const request = deps.libraryScopeListMainstageAlbums(options.anchorServerId, { + scopes: options.scopes, + feed: options.feed, + limit: HOME_PAGE_SIZE, + offset: options.offset ?? 0, + }).then(response => ({ + status: 'success' as const, + albums: response.albums.map(albumToAlbum), + hasMore: response.hasMore, + })).catch(() => ({ status: 'error' as const })); + try { + return await Promise.race([ + request, + new Promise(resolve => { + timer = setTimeout(() => resolve({ status: 'timeout' }), HOME_REQUEST_TIMEOUT_MS); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +} + +export function patchHomeChronologicalFeed( + snapshot: HomeFeedSnapshot, + section: 'recent' | 'recentlyPlayed', + result: HomeChronologicalFeedResult, +): HomeFeedSnapshot { + if (result.status !== 'success') return snapshot; + return { + ...snapshot, + savedAt: Date.now(), + offsets: { + ...snapshot.offsets, + [section]: { offset: result.albums.length, hasMore: result.hasMore }, + }, + [section]: result.albums, + }; +} + +export function preserveHomeChronologicalFeeds( + snapshot: HomeFeedSnapshot, + previous: HomeFeedSnapshot | null, +): HomeFeedSnapshot { + if (!previous || previous.scopeKey !== snapshot.scopeKey) return snapshot; + return { + ...snapshot, + offsets: { + ...snapshot.offsets, + recent: previous.offsets.recent, + recentlyPlayed: previous.offsets.recentlyPlayed, + }, + recent: previous.recent, + recentlyPlayed: previous.recentlyPlayed, + }; +} + async function loadServerBundle( serverId: string, albumQuota: number, @@ -258,20 +332,7 @@ export async function loadHomeFeed(options: LoadHomeFeedOptions): Promise withinHomeDeadline( - isolated( - () => deps.libraryScopeListMainstageAlbums(options.anchorServerId, { - scopes: options.scopes, - feed, - limit: HOME_PAGE_SIZE, - offset: 0, - }), - { albums: [], hasMore: false }, - ), - { albums: [], hasMore: false }, - ); - const [bundles, newReleases, recentlyPlayed] = await Promise.all([ - Promise.all(options.serverIds.map((serverId, index) => loadServerBundle( + const bundles = await Promise.all(options.serverIds.map((serverId, index) => loadServerBundle( serverId, albumQuotas[index] ?? 0, randomQuotas[index] ?? 0, @@ -280,10 +341,7 @@ export async function loadHomeFeed(options: LoadHomeFeedOptions): Promise { @@ -297,11 +355,6 @@ export async function loadHomeFeed(options: LoadHomeFeedOptions): Promise bundle.songs), HOME_DISCOVER_SONGS_SIZE)), }; @@ -335,18 +388,14 @@ export async function loadMoreHomeAlbums(options: LoadMoreHomeAlbumsOptions): Pr const section = options.section; const cursor = options.snapshot.offsets[section]; if (!cursor.hasMore) return options.snapshot; - const response = await withinHomeDeadline( - isolated( - () => deps.libraryScopeListMainstageAlbums(options.anchorServerId, { - scopes: options.scopes, - feed: mainstageFeeds[section], - limit: HOME_PAGE_SIZE, - offset: cursor.offset, - }), - { albums: [], hasMore: false }, - ), - { albums: [], hasMore: false }, - ); + const response = await loadHomeChronologicalFeed({ + anchorServerId: options.anchorServerId, + scopes: options.scopes, + feed: mainstageFeeds[section], + offset: cursor.offset, + deps: { libraryScopeListMainstageAlbums: deps.libraryScopeListMainstageAlbums }, + }); + if (response.status !== 'success') return options.snapshot; return { ...options.snapshot, savedAt: Date.now(), @@ -359,7 +408,7 @@ export async function loadMoreHomeAlbums(options: LoadMoreHomeAlbumsOptions): Pr }, [section]: [ ...options.snapshot[section], - ...response.albums.map(albumToAlbum), + ...response.albums, ], }; } diff --git a/src/features/home/store/homeFeedCache.test.ts b/src/features/home/store/homeFeedCache.test.ts index 4c95be67..85ae6983 100644 --- a/src/features/home/store/homeFeedCache.test.ts +++ b/src/features/home/store/homeFeedCache.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { clearHomeFeedCache, + patchHomeFeedCache, readHomeFeedCache, readHomeFeedCacheStale, writeHomeFeedCache, @@ -53,4 +54,22 @@ describe('homeFeedCache', () => { expect(readHomeFeedCache('scope', 1)).toBeNull(); expect(readHomeFeedCacheStale('scope')).toBeNull(); }); + + it('patches an existing initial snapshot without invalidating its cache key', () => { + write('scope', 1); + const patched = patchHomeFeedCache('scope', 1, snapshot => ({ + ...snapshot, + recent: [{ + id: 'new', + name: 'New', + artist: 'Artist', + artistId: 'artist', + songCount: 1, + duration: 1, + }], + offsets: { ...snapshot.offsets, recent: { offset: 1, hasMore: true } }, + })); + expect(patched?.recent.map(album => album.id)).toEqual(['new']); + expect(readHomeFeedCache('scope', 1)?.offsets.recent).toEqual({ offset: 1, hasMore: true }); + }); }); diff --git a/src/features/home/store/homeFeedCache.ts b/src/features/home/store/homeFeedCache.ts index f5d2a750..fbdcb9d8 100644 --- a/src/features/home/store/homeFeedCache.ts +++ b/src/features/home/store/homeFeedCache.ts @@ -91,6 +91,20 @@ export function writeHomeFeedCache(data: Omit): voi } } +export function patchHomeFeedCache( + scopeKey: string, + scopeVersion: number, + patch: (snapshot: HomeFeedSnapshot) => HomeFeedSnapshot, +): HomeFeedSnapshot | null { + const key = cacheKey(scopeKey, scopeVersion); + const current = snapshots.get(key); + if (!current || Date.now() - current.savedAt > TTL_MS) return null; + const next = { ...patch(current), savedAt: Date.now() }; + snapshots.delete(key); + snapshots.set(key, next); + return next; +} + export function clearHomeFeedCache(): void { snapshots.clear(); } diff --git a/src/features/playback/store/startupPlayQueueReconcile.test.ts b/src/features/playback/store/startupPlayQueueReconcile.test.ts index 4f5ca471..567f7a1a 100644 --- a/src/features/playback/store/startupPlayQueueReconcile.test.ts +++ b/src/features/playback/store/startupPlayQueueReconcile.test.ts @@ -3,6 +3,10 @@ import type { SubsonicSong } from '@/lib/api/subsonicTypes'; import { resetAllStores } from '@/test/helpers/storeReset'; import { useAuthStore } from '@/store/authStore'; import { usePlayerStore } from '@/features/playback/store/playerStore'; +import { + _resetQueueResolverForTest, + getCachedTrack, +} from '@/features/playback/store/queueTrackResolver'; const { fetchPlayQueueForServerMock, applyMappedQueueMock } = vi.hoisted(() => ({ fetchPlayQueueForServerMock: vi.fn(), @@ -30,6 +34,7 @@ function remote(ids: string[], current = ids[0]) { beforeEach(() => { resetAllStores(); + _resetQueueResolverForTest(); fetchPlayQueueForServerMock.mockReset(); applyMappedQueueMock.mockReset(); useAuthStore.setState({ @@ -64,19 +69,23 @@ describe('reconcileStartupPlayQueues', () => { expect(applyMappedQueueMock).not.toHaveBeenCalled(); }); - it('applies the only structurally changed server queue', async () => { + it('applies a changed non-active server projection with the reconciled owner', async () => { + const currentTrack = { id: 'a1', title: 'a1', artist: 'Artist', album: 'Album', albumId: 'album', duration: 100, serverId: 'a' }; + usePlayerStore.setState({ queueIndex: 0, currentTrack }); fetchPlayQueueForServerMock.mockImplementation(async (serverId: string) => ( - serverId === 'a' ? remote(['a1', 'a3'], 'a1') : remote(['b1'], 'b1') + serverId === 'a' ? remote(['a1', 'a2'], 'a1') : remote(['b1', 'b2'], 'b1') )); await expect(reconcileStartupPlayQueues()).resolves.toBe('applied'); expect(applyMappedQueueMock).not.toHaveBeenCalled(); expect(usePlayerStore.getState().queueItems).toEqual([ - { serverId: 'a.test', trackId: 'a1' }, - { serverId: 'b', trackId: 'b1' }, - { serverId: 'a.test', trackId: 'a3' }, + { serverId: 'a', trackId: 'a1' }, + { serverId: 'b.test', trackId: 'b1' }, + { serverId: 'b.test', trackId: 'b2' }, + { serverId: 'a', trackId: 'a2' }, ]); - expect(usePlayerStore.getState().queueIndex).toBe(1); - expect(usePlayerStore.getState().currentTrack?.id).toBe('b1'); + expect(usePlayerStore.getState().queueIndex).toBe(0); + expect(usePlayerStore.getState().currentTrack).toBe(currentTrack); + expect(getCachedTrack({ serverId: 'b.test', trackId: 'b2' })?.serverId).toBe('b'); }); it('keeps the existing whole-queue apply behavior for a single-server queue', async () => { @@ -90,7 +99,7 @@ describe('reconcileStartupPlayQueues', () => { await expect(reconcileStartupPlayQueues()).resolves.toBe('applied'); expect(applyMappedQueueMock).toHaveBeenCalledWith( - expect.arrayContaining([expect.objectContaining({ id: 'a3' })]), + expect.arrayContaining([expect.objectContaining({ id: 'a3', serverId: 'a' })]), expect.objectContaining({ current: 'a1' }), 'a', true, diff --git a/src/features/playback/store/startupPlayQueueReconcile.ts b/src/features/playback/store/startupPlayQueueReconcile.ts index 93c21785..08551a68 100644 --- a/src/features/playback/store/startupPlayQueueReconcile.ts +++ b/src/features/playback/store/startupPlayQueueReconcile.ts @@ -111,7 +111,7 @@ export async function reconcileStartupPlayQueues(): Promise ({ ...songToTrack(song), serverId })); if (representedServerIds.size > 1) { applyMappedQueueProjection(mappedTracks, queue, serverId); } else {