mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
fix(home): keep multi-server feeds responsive
This commit is contained in:
@@ -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 != '';
|
||||
@@ -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::<Vec<_>>()
|
||||
.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<SqlValue>) {
|
||||
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<LibraryMainstageAlbumsResponse, String> {
|
||||
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::<rusqlite::Result<Vec<_>>>()?;
|
||||
let mut albums = rows.into_iter().map(album_row_to_dto).collect::<Vec<_>>();
|
||||
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::<Vec<_>>();
|
||||
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::<Vec<_>>();
|
||||
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<String> {
|
||||
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::<rusqlite::Result<Vec<_>>>()?;
|
||||
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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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?.([]);
|
||||
});
|
||||
});
|
||||
@@ -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<CoverArtRef>;
|
||||
albums?: ReadonlyArray<{ id: string; coverArt?: string | null }>;
|
||||
artists?: ReadonlyArray<{ id: string; coverArt?: string | null }>;
|
||||
songs?: ReadonlyArray<Pick<SubsonicSong, 'id' | 'albumId' | 'coverArt' | 'discNumber'>>;
|
||||
@@ -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;
|
||||
|
||||
@@ -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<typeof albumCoverRef>[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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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[]> {
|
||||
): 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);
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<HomeFeedSnapshot | null>(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<HomeFeedSnapshot | null> => {
|
||||
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<typeof startFreshHomeFeed>['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);
|
||||
|
||||
@@ -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<string, LibraryCoverPrefetchBucket>();
|
||||
const ownerBucket = (serverId?: string) => {
|
||||
const key = serverId?.trim() ?? '';
|
||||
@@ -39,6 +44,25 @@ export function groupHomeCoverPrefetchBuckets(
|
||||
return grouped;
|
||||
}
|
||||
|
||||
export function homeDiscoverCoverPrefetchBucket(
|
||||
songs: ReadonlyArray<Pick<SubsonicSong, 'albumId' | 'coverArt' | 'serverId'>>,
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<never>(() => {});
|
||||
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 () => {
|
||||
|
||||
@@ -87,6 +87,19 @@ interface LoadMoreHomeAlbumsOptions {
|
||||
deps: Pick<HomeFeedLoaderDeps, 'filterAlbumsByMixRatingsAcrossServers'> & Partial<HomeFeedLoaderDeps>;
|
||||
}
|
||||
|
||||
interface LoadHomeChronologicalFeedOptions {
|
||||
anchorServerId: string;
|
||||
scopes: LibraryScopePair[];
|
||||
feed: 'newReleases' | 'recentlyPlayed';
|
||||
offset?: number;
|
||||
deps?: Pick<HomeFeedLoaderDeps, 'libraryScopeListMainstageAlbums'>;
|
||||
}
|
||||
|
||||
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<T>(request: Promise<T>, fallback: T): P
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadHomeChronologicalFeed(
|
||||
options: LoadHomeChronologicalFeedOptions,
|
||||
): Promise<HomeChronologicalFeedResult> {
|
||||
const deps = { ...defaultDeps, ...options.deps };
|
||||
let timer: ReturnType<typeof setTimeout> | 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<HomeChronologicalFeedResult>(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<HomeFe
|
||||
const randomQuotas = allocateHomeQuotas(options.randomSize, options.serverIds.length);
|
||||
const artistQuotas = allocateHomeQuotas(HOME_DISCOVER_ARTISTS_SIZE, options.serverIds.length);
|
||||
const songQuotas = allocateHomeQuotas(HOME_DISCOVER_SONGS_SIZE, options.serverIds.length);
|
||||
const localAlbums = (feed: 'newReleases' | 'recentlyPlayed') => 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<HomeFe
|
||||
options.showArtists,
|
||||
options.showSongs,
|
||||
deps,
|
||||
))),
|
||||
localAlbums('newReleases'),
|
||||
localAlbums('recentlyPlayed'),
|
||||
]);
|
||||
)));
|
||||
|
||||
let offsets = createOffsets(options.serverIds);
|
||||
const advanceInitial = (section: PerServerHomeAlbumSection, groups: OwnedAlbum[][]) => {
|
||||
@@ -297,11 +355,6 @@ export async function loadHomeFeed(options: LoadHomeFeedOptions): Promise<HomeFe
|
||||
advanceInitial('starred', starredGroups);
|
||||
advanceInitial('random', randomGroups);
|
||||
advanceInitial('mostPlayed', mostPlayedGroups);
|
||||
offsets.recent = { offset: newReleases.albums.length, hasMore: newReleases.hasMore };
|
||||
offsets.recentlyPlayed = {
|
||||
offset: recentlyPlayed.albums.length,
|
||||
hasMore: recentlyPlayed.hasMore,
|
||||
};
|
||||
|
||||
const randomRaw = dedupeOwned(stableRoundRobin(randomGroups, options.randomSize));
|
||||
const filteredRandom = dedupeOwned(await deps.filterAlbumsByMixRatingsAcrossServers(
|
||||
@@ -319,11 +372,11 @@ export async function loadHomeFeed(options: LoadHomeFeedOptions): Promise<HomeFe
|
||||
savedAt: Date.now(),
|
||||
offsets,
|
||||
starred: dedupeOwned(stableRoundRobin(starredGroups, HOME_PAGE_SIZE)),
|
||||
recent: newReleases.albums.map(albumToAlbum),
|
||||
recent: [],
|
||||
heroAlbums: filteredRandom.slice(0, HOME_HERO_COUNT),
|
||||
random: filteredRandom.slice(HOME_HERO_COUNT, HOME_DISCOVER_SLICE),
|
||||
mostPlayed: dedupeOwned(stableRoundRobin(mostPlayedGroups, HOME_PAGE_SIZE)),
|
||||
recentlyPlayed: recentlyPlayed.albums.map(albumToAlbum),
|
||||
recentlyPlayed: [],
|
||||
randomArtists: artists,
|
||||
discoverSongs: dedupeOwned(stableRoundRobin(bundles.map(bundle => 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,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -91,6 +91,20 @@ export function writeHomeFeedCache(data: Omit<HomeFeedSnapshot, 'savedAt'>): 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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -111,7 +111,7 @@ export async function reconcileStartupPlayQueues(): Promise<StartupQueueReconcil
|
||||
|
||||
if (changed.length !== 1 || changed[0].queue.songs.length === 0) return 'kept-local';
|
||||
const [{ serverId, queue }] = changed;
|
||||
const mappedTracks = queue.songs.map(songToTrack);
|
||||
const mappedTracks = queue.songs.map(song => ({ ...songToTrack(song), serverId }));
|
||||
if (representedServerIds.size > 1) {
|
||||
applyMappedQueueProjection(mappedTracks, queue, serverId);
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user