fix(home): keep multi-server feeds responsive

This commit is contained in:
cucadmuh
2026-07-16 20:24:46 +03:00
parent 9ac6da2057
commit 77abe0ff70
16 changed files with 875 additions and 188 deletions
@@ -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:?}"
);
}
}
+22 -1
View File
@@ -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();
+95
View File
@@ -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?.([]);
});
});
+12 -6
View File
@@ -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;
+4 -6
View File
@@ -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();
});
});
+4 -4
View File
@@ -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);
+27 -1
View File
@@ -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', () => {
+56 -11
View File
@@ -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;
}
+64 -37
View File
@@ -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 -38
View File
@@ -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 });
});
});
+14
View File
@@ -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 {