perf(browse): fast local All Albums path and scoped multi-library union

Add library_list_albums (album table when unscoped, track GROUP BY when
scoped), cluster single-member bypass, ready-member caching, and UI load
deferrals. Remove REST scope allowlists from local hot paths; fix multi-
library scope union and invalidate cluster scope cache on picker toggles.
This commit is contained in:
Maxim Isaev
2026-06-05 21:04:43 +03:00
parent 26716e2fb0
commit 478c049d0a
26 changed files with 1017 additions and 202 deletions
@@ -0,0 +1,10 @@
-- Plain All Albums browse: read from `album` with ORDER BY name (not track GROUP BY).
CREATE INDEX IF NOT EXISTS idx_album_server_name_browse
ON album(server_id, name COLLATE NOCASE);
-- Scoped album EXISTS probes: (server, album, library) on live tracks.
CREATE INDEX IF NOT EXISTS idx_track_server_album_library_browse
ON track(server_id, album_id, library_id)
WHERE deleted = 0
AND album_id IS NOT NULL
AND album_id != '';
@@ -0,0 +1,497 @@
//! Paginated All Albums browse from the local index (plain, no filters).
//!
//! Prefers the synced `album` table (≈5k rows) with LIMIT/OFFSET. Falls back to
//! track `GROUP BY` only when the album catalog is not populated (N1 ingest).
use crate::dto::{
LibraryAlbumBrowseRequest, LibraryAlbumBrowseResponse, LibraryAlbumDto, LibrarySortClause,
SortDir,
};
use crate::search::library_scope_filter_sql;
use crate::store::LibraryStore;
use rusqlite::types::Value as SqlValue;
use serde_json::Value;
fn trimmed_nonempty(s: Option<&str>) -> Option<String> {
s.map(str::trim)
.filter(|s| !s.is_empty())
.map(String::from)
}
fn effective_scope_ids(req: &LibraryAlbumBrowseRequest) -> Vec<String> {
if let Some(ids) = &req.library_scope_ids {
let trimmed: Vec<_> = ids
.iter()
.filter(|s| !s.trim().is_empty())
.cloned()
.collect();
if !trimmed.is_empty() {
return trimmed;
}
}
trimmed_nonempty(req.library_scope.as_deref())
.map(|s| vec![s])
.unwrap_or_default()
}
fn album_table_order_sql(sort: &[LibrarySortClause]) -> String {
let mut keys: Vec<String> = Vec::new();
for s in sort {
let col = match s.field.as_str() {
"name" => "a.name COLLATE NOCASE",
"artist" => "a.artist COLLATE NOCASE",
"year" => "a.year",
_ => continue,
};
let dir = match s.dir {
SortDir::Asc => "ASC",
SortDir::Desc => "DESC",
};
keys.push(format!("{col} {dir}"));
}
if keys.is_empty() {
keys.push("a.name COLLATE NOCASE ASC".to_string());
}
keys.push("a.id ASC".to_string());
format!("ORDER BY {}", keys.join(", "))
}
fn track_group_order_sql(sort: &[LibrarySortClause]) -> String {
let mut keys: Vec<String> = Vec::new();
for s in sort {
let col = match s.field.as_str() {
"name" => "COALESCE(a.name, la.album_name) COLLATE NOCASE",
"artist" => "COALESCE(a.artist, la.artist) COLLATE NOCASE",
"year" => "COALESCE(a.year, la.year)",
_ => continue,
};
let dir = match s.dir {
SortDir::Asc => "ASC",
SortDir::Desc => "DESC",
};
keys.push(format!("{col} {dir}"));
}
if keys.is_empty() {
keys.push("COALESCE(a.name, la.album_name) COLLATE NOCASE ASC".to_string());
}
keys.push("la.album_id ASC".to_string());
format!("ORDER BY {}", keys.join(", "))
}
fn push_album_id_allowlist(
where_clauses: &mut Vec<String>,
params: &mut Vec<SqlValue>,
column: &str,
ids: Option<&[String]>,
) {
let Some(ids) = ids else {
return;
};
if ids.is_empty() {
where_clauses.push("1 = 0".to_string());
return;
}
let placeholders = (0..ids.len()).map(|_| "?").collect::<Vec<_>>().join(", ");
where_clauses.push(format!("{column} IN ({placeholders})"));
for id in ids {
params.push(SqlValue::Text(id.clone()));
}
}
/// `album` rows have no `library_id`; scope is enforced via matching tracks.
fn push_album_table_library_scope(
where_clauses: &mut Vec<String>,
params: &mut Vec<SqlValue>,
scope_ids: &[String],
) {
if scope_ids.is_empty() {
return;
}
let (clause, scope_params) = library_scope_filter_sql("t_scope", scope_ids);
let Some(scope_clause) = clause else {
return;
};
where_clauses.push(format!(
"EXISTS (SELECT 1 FROM track t_scope \
WHERE t_scope.server_id = a.server_id \
AND t_scope.album_id = a.id \
AND t_scope.deleted = 0 \
AND {scope_clause})"
));
params.extend(scope_params);
}
fn map_row(r: &rusqlite::Row<'_>) -> rusqlite::Result<LibraryAlbumDto> {
let raw: Option<String> = r.get(12)?;
Ok(LibraryAlbumDto {
server_id: r.get(0)?,
id: r.get(1)?,
name: r.get(2)?,
artist: r.get(3)?,
artist_id: r.get(4)?,
song_count: r.get(5)?,
duration_sec: r.get(6)?,
year: r.get(7)?,
genre: r.get(8)?,
cover_art_id: r.get(9)?,
starred_at: r.get(10)?,
synced_at: r.get(11)?,
raw_json: raw
.as_deref()
.and_then(|s| serde_json::from_str(s).ok())
.unwrap_or(Value::Null),
})
}
fn album_table_usable(store: &LibraryStore, server_id: &str) -> Result<bool, String> {
store
.with_read_conn(|c| {
c.query_row(
"SELECT EXISTS(
SELECT 1 FROM album
WHERE server_id = ?1 AND song_count IS NOT NULL
LIMIT 1
)",
rusqlite::params![server_id],
|r| r.get(0),
)
})
.map_err(|e| e.to_string())
}
fn list_albums_from_table(
store: &LibraryStore,
req: &LibraryAlbumBrowseRequest,
) -> Result<LibraryAlbumBrowseResponse, String> {
let limit = req.limit.max(1);
let offset = req.offset;
let order_sql = album_table_order_sql(&req.sort);
let mut where_clauses = vec!["a.server_id = ?1".to_string()];
let mut params: Vec<SqlValue> = vec![SqlValue::Text(req.server_id.clone())];
let scope_ids = effective_scope_ids(req);
push_album_table_library_scope(&mut where_clauses, &mut params, &scope_ids);
push_album_id_allowlist(
&mut where_clauses,
&mut params,
"a.id",
req.restrict_album_ids.as_deref(),
);
let where_sql = where_clauses.join(" AND ");
let sql = format!(
"SELECT \
a.server_id, \
a.id, \
a.name, \
a.artist, \
a.artist_id, \
a.song_count, \
a.duration_sec, \
a.year, \
a.genre, \
a.cover_art_id, \
a.starred_at, \
a.synced_at, \
a.raw_json \
FROM album a \
WHERE {where_sql} \
{order_sql} \
LIMIT ? OFFSET ?"
);
params.push(SqlValue::Integer(limit as i64));
params.push(SqlValue::Integer(offset as i64));
let albums: Vec<LibraryAlbumDto> = store
.with_read_conn(|conn| {
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), map_row)?;
rows.collect::<rusqlite::Result<Vec<_>>>()
})
.map_err(|e| e.to_string())?;
let has_more = albums.len() as u32 == limit;
Ok(LibraryAlbumBrowseResponse {
albums,
has_more,
source: "local".to_string(),
})
}
fn list_albums_from_tracks(
store: &LibraryStore,
req: &LibraryAlbumBrowseRequest,
) -> Result<LibraryAlbumBrowseResponse, String> {
let limit = req.limit.max(1);
let offset = req.offset;
let order_sql = track_group_order_sql(&req.sort);
let mut where_clauses = vec![
"t.deleted = 0".to_string(),
"t.server_id = ?1".to_string(),
"t.album_id IS NOT NULL AND t.album_id != ''".to_string(),
];
let mut params: Vec<SqlValue> = vec![SqlValue::Text(req.server_id.clone())];
let scope_ids = effective_scope_ids(req);
if let (Some(clause), scope_params) = library_scope_filter_sql("t", &scope_ids) {
where_clauses.push(clause);
params.extend(scope_params);
}
push_album_id_allowlist(
&mut where_clauses,
&mut params,
"t.album_id",
req.restrict_album_ids.as_deref(),
);
let where_sql = where_clauses.join(" AND ");
let sql = format!(
"SELECT \
la.server_id, \
la.album_id, \
COALESCE(a.name, la.album_name), \
COALESCE(a.artist, la.artist), \
COALESCE(a.artist_id, la.artist_id), \
COALESCE(a.song_count, la.track_count), \
COALESCE(a.duration_sec, la.duration_sec), \
COALESCE(a.year, la.year), \
COALESCE(a.genre, la.genre), \
COALESCE(a.cover_art_id, la.cover_art_id), \
COALESCE(a.starred_at, la.starred_at), \
COALESCE(a.synced_at, la.synced_at), \
a.raw_json \
FROM ( \
SELECT \
t.server_id, \
t.album_id, \
MAX(t.album) AS album_name, \
MAX(t.artist) AS artist, \
MAX(t.artist_id) AS artist_id, \
MAX(t.year) AS year, \
MAX(t.genre) AS genre, \
MAX(t.cover_art_id) AS cover_art_id, \
MAX(t.starred_at) AS starred_at, \
MAX(t.synced_at) AS synced_at, \
COUNT(*) AS track_count, \
COALESCE(SUM(t.duration_sec), 0) AS duration_sec \
FROM track t \
WHERE {where_sql} \
GROUP BY t.server_id, t.album_id \
) la \
LEFT JOIN album a ON a.server_id = la.server_id AND a.id = la.album_id \
{order_sql} \
LIMIT ? OFFSET ?"
);
params.push(SqlValue::Integer(limit as i64));
params.push(SqlValue::Integer(offset as i64));
let albums: Vec<LibraryAlbumDto> = store
.with_read_conn(|conn| {
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), map_row)?;
rows.collect::<rusqlite::Result<Vec<_>>>()
})
.map_err(|e| e.to_string())?;
let has_more = albums.len() as u32 == limit;
Ok(LibraryAlbumBrowseResponse {
albums,
has_more,
source: "local".to_string(),
})
}
fn browse_is_scoped(req: &LibraryAlbumBrowseRequest) -> bool {
!effective_scope_ids(req).is_empty()
|| req
.restrict_album_ids
.as_ref()
.is_some_and(|ids| !ids.is_empty())
}
pub fn list_albums(
store: &LibraryStore,
req: &LibraryAlbumBrowseRequest,
) -> Result<LibraryAlbumBrowseResponse, String> {
// Unscoped plain browse: read the synced `album` table (~5k rows).
// Scoped browse (one or more libraries): filter tracks by `library_id`
// first, then GROUP BY — same union semantics as lossless / advanced search.
if !browse_is_scoped(req) && album_table_usable(store, &req.server_id)? {
return list_albums_from_table(store, req);
}
if !crate::dto::track_index_nonempty(store, &req.server_id)? {
return Ok(LibraryAlbumBrowseResponse {
albums: Vec::new(),
has_more: false,
source: "local".to_string(),
});
}
list_albums_from_tracks(store, req)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::repos::{TrackRepository, TrackRow};
fn track(server: &str, id: &str, album_id: &str, album: &str, library_id: Option<&str>) -> TrackRow {
TrackRow {
server_id: server.into(),
id: id.into(),
title: format!("{album} track"),
title_sort: None,
artist: Some("Band".into()),
artist_id: Some("art-1".into()),
album: album.into(),
album_id: Some(album_id.into()),
album_artist: Some("Band".into()),
duration_sec: 200,
track_number: Some(1),
disc_number: Some(1),
year: Some(2020),
genre: None,
suffix: Some("mp3".into()),
bit_rate: None,
size_bytes: None,
cover_art_id: None,
starred_at: None,
user_rating: None,
play_count: None,
played_at: None,
server_path: None,
library_id: library_id.map(String::from),
isrc: None,
mbid_recording: None,
bpm: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
content_hash: None,
server_updated_at: None,
server_created_at: None,
deleted: false,
synced_at: 1,
raw_json: "{}".into(),
}
}
fn req(server: &str, limit: u32, offset: u32) -> LibraryAlbumBrowseRequest {
LibraryAlbumBrowseRequest {
server_id: server.into(),
library_scope: None,
library_scope_ids: None,
sort: Vec::new(),
restrict_album_ids: None,
limit,
offset,
}
}
fn seed_album(
store: &LibraryStore,
server_id: &str,
id: &str,
name: &str,
song_count: i64,
) {
store
.with_conn("misc", |c| {
c.execute(
"INSERT INTO album (server_id, id, name, artist, song_count, duration_sec, synced_at, raw_json) \
VALUES (?1, ?2, ?3, 'Band', ?4, 400, 1, '{}')",
rusqlite::params![server_id, id, name, song_count],
)
})
.unwrap();
}
#[test]
fn lists_albums_grouped_from_tracks() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track("s1", "t1", "al-1", "Alpha", None),
track("s1", "t2", "al-2", "Beta", None),
])
.unwrap();
let resp = list_albums(&store, &req("s1", 50, 0)).unwrap();
assert_eq!(resp.albums.len(), 2);
assert!(!resp.has_more);
}
#[test]
fn prefers_album_table_when_synced_catalog_exists() {
let store = LibraryStore::open_in_memory();
seed_album(&store, "s1", "al-1", "Alpha", 10);
seed_album(&store, "s1", "al-2", "Beta", 8);
let resp = list_albums(&store, &req("s1", 50, 0)).unwrap();
assert_eq!(resp.albums.len(), 2);
assert_eq!(resp.albums[0].name, "Alpha");
assert_eq!(resp.albums[1].name, "Beta");
}
#[test]
fn library_scope_narrows_results() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track("s1", "t1", "al-1", "In", Some("lib-a")),
track("s1", "t2", "al-2", "Out", Some("lib-b")),
])
.unwrap();
seed_album(&store, "s1", "al-1", "In", 1);
seed_album(&store, "s1", "al-2", "Out", 1);
let mut scoped = req("s1", 50, 0);
scoped.library_scope_ids = Some(vec!["lib-a".into()]);
let resp = list_albums(&store, &scoped).unwrap();
assert_eq!(resp.albums.len(), 1);
assert_eq!(resp.albums[0].id, "al-1");
}
#[test]
fn multi_library_scope_unions_albums() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track("s1", "t1", "al-1", "Alpha", Some("lib-a")),
track("s1", "t2", "al-2", "Beta", Some("lib-b")),
track("s1", "t3", "al-3", "Gamma", Some("lib-c")),
])
.unwrap();
seed_album(&store, "s1", "al-1", "Alpha", 1);
seed_album(&store, "s1", "al-2", "Beta", 1);
seed_album(&store, "s1", "al-3", "Gamma", 1);
let mut scoped = req("s1", 50, 0);
scoped.library_scope_ids = Some(vec!["lib-a".into(), "lib-b".into()]);
let resp = list_albums(&store, &scoped).unwrap();
assert_eq!(resp.albums.len(), 2);
assert_eq!(resp.albums[0].id, "al-1");
assert_eq!(resp.albums[1].id, "al-2");
}
#[test]
fn multi_library_scope_includes_track_only_albums() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track("s1", "t1", "al-1", "Alpha", Some("lib-a")),
track("s1", "t2", "al-2", "Zulu", Some("lib-b")),
])
.unwrap();
seed_album(&store, "s1", "al-1", "Alpha", 1);
let mut scoped = req("s1", 50, 0);
scoped.library_scope_ids = Some(vec!["lib-a".into(), "lib-b".into()]);
let resp = list_albums(&store, &scoped).unwrap();
assert_eq!(resp.albums.len(), 2);
assert_eq!(resp.albums[0].id, "al-1");
assert_eq!(resp.albums[1].id, "al-2");
}
}
@@ -498,6 +498,15 @@ pub async fn library_list_lossless_albums(
library_spawn_blocking(move || crate::lossless_albums::list_lossless_albums(&store, &request)).await
}
#[tauri::command]
pub async fn library_list_albums(
runtime: State<'_, LibraryRuntime>,
request: crate::dto::LibraryAlbumBrowseRequest,
) -> Result<crate::dto::LibraryAlbumBrowseResponse, String> {
let store = Arc::clone(&runtime.store);
library_spawn_blocking(move || crate::album_browse::list_albums(&store, &request)).await
}
#[tauri::command]
pub async fn library_list_albums_by_genre(
runtime: State<'_, LibraryRuntime>,
@@ -399,6 +399,38 @@ pub struct GenreAlbumCountDto {
pub song_count: u32,
}
/// `library_list_albums` request — paginated plain All Albums browse (local index).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LibraryAlbumBrowseRequest {
pub server_id: String,
#[serde(default)]
pub library_scope: Option<String>,
#[serde(default)]
pub library_scope_ids: Option<Vec<String>>,
#[serde(default)]
pub sort: Vec<LibrarySortClause>,
#[serde(default)]
pub restrict_album_ids: Option<Vec<String>>,
#[serde(default = "default_album_browse_limit")]
pub limit: u32,
#[serde(default)]
pub offset: u32,
}
fn default_album_browse_limit() -> u32 {
30
}
/// `library_list_albums` response.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LibraryAlbumBrowseResponse {
pub albums: Vec<LibraryAlbumDto>,
pub has_more: bool,
pub source: String,
}
/// `library_list_albums_by_genre` request — paginated genre album browse (local index).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
@@ -9,6 +9,7 @@
pub(crate) mod bulk_ingest;
pub mod advanced_search;
pub mod album_browse;
pub mod album_compilation_filter;
pub mod browse_support;
mod advanced_search_mood;
@@ -11,7 +11,7 @@ use crate::server_cluster::{attach_cluster_database, attach_cluster_database_uri
/// Current head of the embedded migrations. Bump each time a new
/// `migrations/NNN_*.sql` is added.
pub const LIBRARY_DB_SCHEMA_VERSION: i64 = 1;
pub const LIBRARY_DB_SCHEMA_VERSION: i64 = 2;
/// Lowest applied schema version the current code can advance from purely
/// additively. If a DB carries a version below this, the breaking-bump hook
@@ -24,10 +24,15 @@ pub const LIBRARY_DB_SCHEMA_VERSION: i64 = 1;
pub const LIBRARY_DB_MIN_COMPATIBLE_VERSION: i64 = 1;
pub(crate) const INITIAL_SQL: &str = include_str!("../migrations/001_initial.sql");
const MIGRATION_002_ALBUM_BROWSE_INDEX: &str =
include_str!("../migrations/002_album_browse_index.sql");
/// Embedded migrations. Ordered ascending by `version`; the runner sorts
/// defensively before applying so the source order can stay readable.
const MIGRATIONS: &[(i64, &str)] = &[(1, INITIAL_SQL)];
const MIGRATIONS: &[(i64, &str)] = &[
(1, INITIAL_SQL),
(2, MIGRATION_002_ALBUM_BROWSE_INDEX),
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum MigrationOutcome {
+1
View File
@@ -710,6 +710,7 @@ pub fn run() {
psysonic_library::commands::library_advanced_search,
psysonic_library::commands::library_cluster_advanced_search,
psysonic_library::commands::library_list_lossless_albums,
psysonic_library::commands::library_list_albums,
psysonic_library::commands::library_list_albums_by_genre,
psysonic_library::commands::library_get_artist_lossless_browse,
psysonic_library::commands::library_search_cross_server,
+40
View File
@@ -381,6 +381,46 @@ export function libraryLiveSearch(request: LibraryLiveSearchRequest): Promise<Li
}));
}
export interface LibraryAlbumBrowseRequest {
serverId: string;
libraryScope?: string | null;
libraryScopeIds?: string[] | null;
restrictAlbumIds?: string[] | null;
sort?: LibrarySortClause[];
limit?: number;
offset?: number;
}
export interface LibraryAlbumBrowseResponse {
albums: LibraryAlbumDto[];
hasMore: boolean;
source: 'local';
}
/** Paginated plain All Albums from the local track index. */
export function libraryListAlbums(
request: LibraryAlbumBrowseRequest,
): Promise<LibraryAlbumBrowseResponse> {
const indexKey = serverIndexKeyForId(request.serverId);
return invoke<LibraryAlbumBrowseResponse>('library_list_albums', {
request: {
serverId: indexKey,
libraryScope: request.libraryScope ?? undefined,
libraryScopeIds: request.libraryScopeIds ?? undefined,
restrictAlbumIds: request.restrictAlbumIds ?? undefined,
sort: request.sort,
limit: request.limit,
offset: request.offset,
},
}).then(response => ({
...response,
albums: response.albums.map(album => ({
...album,
serverId: mapServerIdFromIndexKey(album.serverId, request.serverId),
})),
}));
}
export interface LibraryLosslessAlbumsRequest {
serverId: string;
libraryScope?: string | null;
+15 -16
View File
@@ -92,29 +92,29 @@ export async function getAlbumList(
* so similar tracks can leak from other libraries. When the user scoped to one folder, we keep a set of
* album ids in that scope (paginated getAlbumList2) and drop songs whose albumId is not in the set.
*/
let scopedLibraryAlbumIdCache: {
serverId: string;
folderId: string;
filterVersion: number;
ids: Set<string>;
} | null = null;
const scopedLibraryAlbumIdCaches = new Map<
string,
{ folderId: string; filterVersion: number; ids: Set<string> }
>();
/** Union of album ids across selected music folders (network scope filter). */
/**
* Union of album ids across selected music folders **network fallback only**
* (Subsonic `getAlbumList2`). Local index browse filters via SQL `library_id`.
*/
export async function albumIdsInLibraryScope(serverId: string): Promise<Set<string> | null> {
const { musicLibraryFilterVersion } = useAuthStore.getState();
if (!serverId) return null;
const filter = musicLibraryFilterForServer(serverId);
if (filter === 'all') {
scopedLibraryAlbumIdCache = null;
scopedLibraryAlbumIdCaches.delete(serverId);
return null;
}
const cacheKey = musicLibraryFilterStorageKey(serverId);
const hit = scopedLibraryAlbumIdCache;
const hit = scopedLibraryAlbumIdCaches.get(serverId);
if (
hit &&
hit.serverId === serverId &&
hit.folderId === cacheKey &&
hit.filterVersion === musicLibraryFilterVersion
hit
&& hit.folderId === cacheKey
&& hit.filterVersion === musicLibraryFilterVersion
) {
return hit.ids;
}
@@ -136,12 +136,11 @@ export async function albumIdsInLibraryScope(serverId: string): Promise<Set<stri
if (offset > 500_000) break;
}
}
scopedLibraryAlbumIdCache = {
serverId,
scopedLibraryAlbumIdCaches.set(serverId, {
folderId: cacheKey,
filterVersion: musicLibraryFilterVersion,
ids,
};
});
return ids;
}
+29 -13
View File
@@ -46,6 +46,13 @@ const CATALOG_CHUNK_SIZE = 200;
/** Wait for visible-row cover ensures to drain before fetching the next SQL page (network mode). */
const LOAD_MORE_COVER_BACKLOG_MAX = 12;
/** Let the loading spinner paint before heavy browse work blocks the main thread. */
function yieldToPaint(): Promise<void> {
return new Promise(resolve => {
requestAnimationFrame(() => resolve());
});
}
type AlbumBrowseMode = 'slice' | 'page';
export type UseAlbumBrowseDataArgs = {
@@ -178,7 +185,7 @@ export function useAlbumBrowseData({
: libraryScopeIdsForServer(serverId) != null;
const narrowGenreList = yearFilterActive || losslessOnly || starredOnly || compFilterActive;
/** When true, GenreFilterBar uses `genreCatalogOptions` instead of server `getGenres()`. */
const genreCatalogActive = narrowGenreList || (indexEnabled && libraryScopeActive);
const genreCatalogActive = narrowGenreList;
const compScanExhausted = useMemo(
() => compFilterClientOnly && !genreFiltered
@@ -301,6 +308,7 @@ export function useAlbumBrowseData({
},
},
);
await yieldToPaint();
applyPage(pageResult);
} finally {
coverTrafficEndGridPagination();
@@ -328,6 +336,14 @@ export function useAlbumBrowseData({
setLoading(true);
void (async () => {
await yieldToPaint();
if (cancelled) return;
const useSliceCatalog = albumBrowseUseSliceCatalog(browseQuery, libraryScopeActive);
if (indexEnabled && serverId && !useSliceCatalog) {
setBrowseMode('page');
if (!cancelled) await loadBrowse(browseQuery, 0, false);
return;
}
if (indexEnabled && serverId) {
const generation = ++loadGenerationRef.current;
coverTrafficBeginGridPagination();
@@ -336,17 +352,11 @@ export function useAlbumBrowseData({
serverId,
browseQuery,
0,
CATALOG_CHUNK_SIZE,
CLIENT_SLICE_PAGE_SIZE,
);
if (cancelled || generation !== loadGenerationRef.current) return;
if (first != null) {
if (!albumBrowseUseSliceCatalog(browseQuery, libraryScopeActive)) {
setBrowseMode('page');
setAlbums(first.albums);
setHasMore(first.hasMore);
setLoading(false);
return;
}
await yieldToPaint();
setBrowseMode('slice');
setAlbums(first.albums);
catalogOffsetRef.current = first.albums.length;
@@ -367,22 +377,28 @@ export function useAlbumBrowseData({
return () => {
cancelled = true;
};
}, [browseQuery, indexEnabled, serverId, loadBrowse, musicLibraryFilterVersion]);
}, [browseQuery, indexEnabled, serverId, loadBrowse, musicLibraryFilterVersion, libraryScopeActive]);
useEffect(() => {
if (!genreCatalogActive) {
setGenreCatalogOptions(null);
return;
}
// Load genre bar after the grid paints — avoids competing with the first SQL page.
if (loading) return;
let cancelled = false;
void fetchAlbumBrowseGenreOptions(serverId, indexEnabled, browseQueryWithoutGenre).then(options => {
if (!cancelled) setGenreCatalogOptions(options);
});
const timer = window.setTimeout(() => {
void fetchAlbumBrowseGenreOptions(serverId, indexEnabled, browseQueryWithoutGenre).then(options => {
if (!cancelled) setGenreCatalogOptions(options);
});
}, 0);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [
genreCatalogActive,
loading,
serverId,
indexEnabled,
browseQueryWithoutGenre,
+9 -8
View File
@@ -1,5 +1,7 @@
import type { MusicLibraryFilter } from '../utils/musicLibraryFilter';
import { normalizeMusicLibraryFilter } from '../utils/musicLibraryFilter';
import { invalidateClusterAlbumBrowseScopeCache } from '../utils/serverCluster/clusterAlbumBrowseMembers';
import { invalidateClusterMergeMemberCache } from '../utils/serverCluster/representative';
import type { AuthState } from './authStoreTypes';
type SetState = (
@@ -11,6 +13,8 @@ function bumpFilter(
set: SetState,
next: Record<string, MusicLibraryFilter>,
): void {
invalidateClusterMergeMemberCache();
invalidateClusterAlbumBrowseScopeCache();
set(s => ({
musicLibraryFilterByServer: next,
musicLibraryFilterVersion: s.musicLibraryFilterVersion + 1,
@@ -70,14 +74,9 @@ export function createMusicLibraryActions(set: SetState, get: GetState): Pick<
}
const sid = resolveTargetServerId(get, targetServerId);
if (!sid) return;
set(s => {
const next = { ...s.musicLibraryFilterByServer };
next[sid] = folderId === 'all' ? 'all' : [folderId];
return {
musicLibraryFilterByServer: next,
musicLibraryFilterVersion: s.musicLibraryFilterVersion + 1,
};
});
const next = { ...get().musicLibraryFilterByServer };
next[sid] = folderId === 'all' ? 'all' : [folderId];
bumpFilter(set, next);
},
/** Toggle one folder in a multi-select set (checkbox). */
@@ -95,6 +94,8 @@ export function createMusicLibraryActions(set: SetState, get: GetState): Pick<
} else {
next[sid] = [...current, folderId];
}
invalidateClusterMergeMemberCache();
invalidateClusterAlbumBrowseScopeCache();
return {
musicLibraryFilterByServer: next,
musicLibraryFilterVersion: s.musicLibraryFilterVersion + 1,
+17 -37
View File
@@ -1,13 +1,14 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mockAdvancedSearch = vi.fn();
const mockListAlbums = vi.fn();
const mockListByGenre = vi.fn();
const mockListLossless = vi.fn();
const mockScopeArgs = vi.fn();
const mockScopedAllowlist = vi.fn();
vi.mock('../../api/library', () => ({
libraryAdvancedSearch: (...args: unknown[]) => mockAdvancedSearch(...args),
libraryListAlbums: (...args: unknown[]) => mockListAlbums(...args),
libraryListAlbumsByGenre: (...args: unknown[]) => mockListByGenre(...args),
libraryListLosslessAlbums: (...args: unknown[]) => mockListLossless(...args),
}));
@@ -16,14 +17,6 @@ vi.mock('../musicLibraryFilter', () => ({
libraryScopeInvokeArgs: (...args: unknown[]) => mockScopeArgs(...args),
}));
vi.mock('./albumBrowseLibraryScope', async importOriginal => {
const actual = await importOriginal<typeof import('./albumBrowseLibraryScope')>();
return {
...actual,
resolveScopedAlbumAllowlist: (...args: unknown[]) => mockScopedAllowlist(...args),
};
});
import { searchSingleServerAlbumBrowse } from './albumBrowseExecution';
const baseQuery = {
@@ -37,11 +30,24 @@ const baseQuery = {
beforeEach(() => {
vi.clearAllMocks();
mockScopeArgs.mockReturnValue({ libraryScopeIds: ['lib-1'], libraryScope: 'lib-1' });
mockScopedAllowlist.mockResolvedValue(null);
});
describe('searchSingleServerAlbumBrowse', () => {
it('pure lossless uses libraryListLosslessAlbums with scope and sort', async () => {
it('plain browse uses libraryListAlbums fast path', async () => {
mockListAlbums.mockResolvedValue({
source: 'local',
albums: [{ id: 'al-1', name: 'Album', serverId: 's1' }],
hasMore: false,
});
const result = await searchSingleServerAlbumBrowse('srv-1', baseQuery, 0, 30);
expect(mockListAlbums).toHaveBeenCalled();
expect(mockAdvancedSearch).not.toHaveBeenCalled();
expect(result?.albums.map(a => a.id)).toEqual(['al-1']);
});
it('pure lossless uses libraryListLosslessAlbums with SQL scope only', async () => {
mockListLossless.mockResolvedValue({
source: 'local',
albums: [{ id: 'flac-1', name: 'Hi-Res', serverId: 's1' }],
@@ -67,32 +73,6 @@ describe('searchSingleServerAlbumBrowse', () => {
expect(result?.albums.map(a => a.id)).toEqual(['flac-1']);
});
it('scoped lossless passes SQL allowlist and post-filters leaked albums', async () => {
mockScopedAllowlist.mockResolvedValue(new Set(['flac-1']));
mockListLossless.mockResolvedValue({
source: 'local',
albums: [
{ id: 'flac-1', name: 'In scope', serverId: 's1' },
{ id: 'flac-2', name: 'Out of scope', serverId: 's1' },
],
hasMore: false,
});
const result = await searchSingleServerAlbumBrowse(
'srv-1',
{ ...baseQuery, losslessOnly: true },
0,
30,
);
expect(mockListLossless).toHaveBeenCalledWith(
expect.objectContaining({
restrictAlbumIds: ['flac-1'],
}),
);
expect(result?.albums.map(a => a.id)).toEqual(['flac-1']);
});
it('lossless combined with year still uses advanced search', async () => {
mockAdvancedSearch.mockResolvedValue({
source: 'local',
+30 -28
View File
@@ -1,29 +1,29 @@
/**
* Album browse filter layering (every path must follow this order):
* Album browse filter layering (local index paths):
*
* 1. **Library scope** SQL `libraryScopeIds` on tracks with missing `library_id`
* 2. **Scoped album allowlist** cached `getAlbumList2` ids (SQL `IN` when 900, else post-filter)
* 3. **Album attributes** (AND) year, lossless, compilation, starred*
* 4. **Genre** (OR union) one `genre = ?` query per selected genre, results merged
* 5. **Starred allowlist** favorites `restrictAlbumIds` (intersected with step 2)
* 1. **Library scope** SQL `libraryScopeIds` / cluster `library_scopes` on `library_id`
* 2. **Album attributes** (AND) year, lossless, compilation, starred*
* 3. **Genre** (OR union) one `genre = ?` query per selected genre, results merged
* 4. **Starred allowlist** favorites `restrictAlbumIds` (local index ids)
*
* Network REST scope (`getAlbumList2`) is used only in `albumBrowseNetwork.ts` fallback.
*/
import {
libraryAdvancedSearch,
libraryListAlbums,
libraryListAlbumsByGenre,
libraryListLosslessAlbums,
type LibraryFilterClause,
} from '../../api/library';
import type { SubsonicAlbum } from '../../api/subsonicTypes';
import { libraryScopeInvokeArgs } from '../musicLibraryFilter';
import {
filterAlbumsByScopedAllowlist,
intersectAlbumRestrictIds,
resolveScopedAlbumAllowlist,
scopedSqlAlbumAllowlist,
} from './albumBrowseLibraryScope';
import { dedupeById } from '../dedupeById';
import { albumToAlbum } from './advancedSearchLocal';
import { albumBrowseIsPureLossless, sharedServerFilters } from './albumBrowseFilters';
import {
albumBrowseIsPureLossless,
albumBrowseIsPurePlain,
sharedServerFilters,
} from './albumBrowseFilters';
import { albumSortClauses, sortSubsonicAlbums } from './albumBrowseSort';
import type { AlbumBrowsePageResult, AlbumBrowseQuery } from './albumBrowseTypes';
import { GENRE_ALBUM_FETCH_LIMIT } from './albumBrowseTypes';
@@ -33,35 +33,26 @@ export type AlbumBrowseInvokeContext = {
invokeScope: ReturnType<typeof libraryScopeInvokeArgs> & {
restrictAlbumIds?: string[];
};
scopedAllowlist: Set<string> | null;
useServerStarredIds: boolean;
starredOnly: boolean | undefined;
attributeFilters: LibraryFilterClause[];
};
export async function resolveAlbumBrowseInvokeContext(
export function resolveAlbumBrowseInvokeContext(
serverId: string,
query: AlbumBrowseQuery,
restrictAlbumIds?: string[],
): Promise<AlbumBrowseInvokeContext> {
): AlbumBrowseInvokeContext {
const scopeArgs = libraryScopeInvokeArgs(serverId);
const scopedAllowlist = await resolveScopedAlbumAllowlist(serverId);
const allowlistArr = scopedAllowlist ? [...scopedAllowlist] : undefined;
const scopeSqlAllowlist = scopedSqlAlbumAllowlist(scopedAllowlist);
const useServerStarredIds = restrictAlbumIds != null;
const favoriteAllowlist = useServerStarredIds
? intersectAlbumRestrictIds(restrictAlbumIds, allowlistArr)
: undefined;
const sqlRestrict = favoriteAllowlist ?? scopeSqlAllowlist;
const invokeScope = {
...scopeArgs,
...(sqlRestrict?.length ? { restrictAlbumIds: sqlRestrict } : {}),
...(restrictAlbumIds?.length ? { restrictAlbumIds } : {}),
};
return {
scopeArgs,
invokeScope,
scopedAllowlist,
useServerStarredIds,
starredOnly: useServerStarredIds ? undefined : (query.starredOnly || undefined),
attributeFilters: sharedServerFilters(query, useServerStarredIds),
@@ -111,15 +102,14 @@ export async function searchSingleServerAlbumBrowse(
pageSize: number,
restrictAlbumIds?: string[],
): Promise<AlbumBrowsePageResult | null> {
const ctx = await resolveAlbumBrowseInvokeContext(serverId, query, restrictAlbumIds);
const ctx = resolveAlbumBrowseInvokeContext(serverId, query, restrictAlbumIds);
const sort = albumSortClauses(query.sort);
const finish = (
albums: SubsonicAlbum[],
hasMore: boolean,
): AlbumBrowsePageResult => {
const scoped = filterAlbumsByScopedAllowlist(albums, ctx.scopedAllowlist);
const out = ctx.useServerStarredIds ? markServerStarredAlbums(scoped) : scoped;
const out = ctx.useServerStarredIds ? markServerStarredAlbums(albums) : albums;
return { albums: out, hasMore };
};
@@ -171,6 +161,18 @@ export async function searchSingleServerAlbumBrowse(
}
try {
if (albumBrowseIsPurePlain(query)) {
const resp = await libraryListAlbums({
serverId,
...ctx.scopeArgs,
restrictAlbumIds: ctx.invokeScope.restrictAlbumIds,
sort,
limit: pageSize,
offset,
});
if (resp.source !== 'local') return null;
return finish(resp.albums.map(albumToAlbum), resp.hasMore);
}
if (albumBrowseIsPureLossless(query)) {
const resp = await libraryListLosslessAlbums({
serverId,
+8
View File
@@ -3,6 +3,7 @@ import type { LibraryFilterClause } from '../../api/library';
import { albumIsCompilation, type AlbumCompFilter } from './albumCompilation';
import { albumYearFilterClauses, type AlbumYearBounds } from './albumYearFilter';
import type { AlbumBrowseQuery, GenreFilterOption } from './albumBrowseTypes';
import { isClusterMode } from '../serverCluster/clusterScope';
export function albumBrowseHasGenreFilter(query: AlbumBrowseQuery): boolean {
return query.genres.length > 0;
@@ -28,9 +29,16 @@ export function albumBrowseUseSliceCatalog(
libraryScopeActive = false,
): boolean {
if (libraryScopeActive) return false;
// Cluster merge SQL is too heavy for 200-album catalog chunks — use SQL pages instead.
if (isClusterMode()) return false;
return !albumBrowseHasServerFilters(query);
}
/** Plain All Albums — dedicated `library_list_albums` path (no Advanced Search). */
export function albumBrowseIsPurePlain(query: AlbumBrowseQuery): boolean {
return !albumBrowseHasServerFilters(query) && query.compFilter === 'all';
}
/** Lossless-only All Albums browse — dedicated `library_list_lossless_albums` path. */
export function albumBrowseIsPureLossless(query: AlbumBrowseQuery): boolean {
return query.losslessOnly
@@ -7,8 +7,11 @@ vi.mock('../../api/subsonicLibrary', () => ({
albumIdsInLibraryScope: (...args: unknown[]) => albumIdsInLibraryScope(...args),
}));
vi.mock('../serverCluster/clusterBrowse', () => ({
resolveClusterBrowseMembers: vi.fn(async () => ['srv-a', 'srv-b']),
vi.mock('../serverCluster/clusterAlbumBrowseMembers', () => ({
resolveClusterAlbumBrowseScopeContext: vi.fn(async () => ({
members: ['srv-a', 'srv-b'],
scopedMembers: ['srv-a'],
})),
}));
vi.mock('../musicLibraryFilter', () => ({
@@ -18,6 +21,7 @@ vi.mock('../musicLibraryFilter', () => ({
import {
filterAlbumsToServerLibraryScope,
filterClusterAlbumsToLibraryScope,
filterClusterAlbumsWithScopeContext,
intersectAlbumRestrictIds,
} from './albumBrowseLibraryScope';
@@ -36,7 +40,7 @@ describe('intersectAlbumRestrictIds', () => {
});
describe('filterAlbumsToServerLibraryScope', () => {
it('drops albums outside scoped allowlist', async () => {
it('drops albums outside network scope allowlist', async () => {
albumIdsInLibraryScope.mockResolvedValue(new Set(['keep']));
const out = await filterAlbumsToServerLibraryScope('srv-a', [
{ id: 'keep', name: 'a', artist: 'X', artistId: 'x', songCount: 1, duration: 1 },
@@ -46,7 +50,7 @@ describe('filterAlbumsToServerLibraryScope', () => {
});
});
describe('filterClusterAlbumsToLibraryScope', () => {
describe('filterClusterAlbumsWithScopeContext', () => {
const album = (id: string, clusterSeedServerId: string): SubsonicAlbum => ({
id,
name: id,
@@ -57,18 +61,20 @@ describe('filterClusterAlbumsToLibraryScope', () => {
clusterSeedServerId,
});
it('keeps only albums in scoped member allowlist', async () => {
albumIdsInLibraryScope.mockResolvedValue(new Set(['in-scope']));
const out = await filterClusterAlbumsToLibraryScope([
album('in-scope', 'srv-a'),
album('other', 'srv-a'),
album('any', 'srv-b'),
]);
expect(out.map(a => a.id)).toEqual(['in-scope']);
const scopeCtx = {
members: ['srv-a', 'srv-b'],
scopedMembers: ['srv-a'],
};
it('keeps only albums from scoped cluster members', () => {
const out = filterClusterAlbumsWithScopeContext([
album('a1', 'srv-a'),
album('b1', 'srv-b'),
], scopeCtx);
expect(out.map(a => a.id)).toEqual(['a1']);
});
it('drops albums from unscoped cluster members when another member is narrowed', async () => {
albumIdsInLibraryScope.mockResolvedValue(new Set(['a1']));
it('async wrapper uses scope context', async () => {
const out = await filterClusterAlbumsToLibraryScope([
album('a1', 'srv-a'),
album('b1', 'srv-b'),
+22 -61
View File
@@ -1,14 +1,12 @@
import { albumIdsInLibraryScope } from '../../api/subsonicLibrary';
import type { SubsonicAlbum } from '../../api/subsonicTypes';
import { libraryScopeIdsForServer } from '../musicLibraryFilter';
import { resolveClusterBrowseMembers } from '../serverCluster/clusterBrowse';
/** SQLite bind-parameter budget for `restrictAlbumIds` IN clauses. */
export const SQL_ALBUM_ALLOWLIST_MAX = 900;
import type { ClusterAlbumBrowseScopeContext } from '../serverCluster/clusterAlbumBrowseMembers';
/**
* Navidrome-scoped album ids from getAlbumList2 (per musicFolderId).
* Cached per server + filter version safe to call on every browse page.
* Navidrome-scoped album ids from getAlbumList2 **network fallback only**
* (`albumBrowseNetwork`, starred network pagination). Local index browse uses
* SQL `libraryScopeIds` on `library_id` in the synced catalog.
*/
export async function resolveScopedAlbumAllowlist(
serverId: string,
@@ -28,15 +26,7 @@ export async function resolveScopedAlbumRestrictIds(
return allowlist ? [...allowlist] : undefined;
}
export function filterAlbumsByScopedAllowlist(
albums: SubsonicAlbum[],
allowlist: Set<string> | null | undefined,
): SubsonicAlbum[] {
if (!allowlist?.size) return albums;
return albums.filter(a => allowlist.has(a.id));
}
/** Client-side scope filter (server getAlbumList2 ids). Idempotent after SQL restrict. */
/** Network post-filter after Subsonic `getAlbumList2` / starred REST reads. */
export async function filterAlbumsToServerLibraryScope(
serverId: string,
albums: SubsonicAlbum[],
@@ -59,56 +49,27 @@ export function intersectAlbumRestrictIds(
return primary.filter(id => allowed.has(id));
}
export function scopedSqlAlbumAllowlist(
allowlist: Set<string> | null | undefined,
): string[] | undefined {
if (!allowlist?.size) return undefined;
const ids = [...allowlist];
return ids.length > 0 && ids.length <= SQL_ALBUM_ALLOWLIST_MAX ? ids : undefined;
/** Cluster: drop albums from members outside the narrowed scope set (SQL handles folder ids). */
export function filterClusterAlbumsWithScopeContext(
albums: SubsonicAlbum[],
ctx: ClusterAlbumBrowseScopeContext,
): SubsonicAlbum[] {
const { scopedMembers } = ctx;
if (scopedMembers.length === 0) return albums;
return albums.filter(a => {
const seedServerId = a.clusterSeedServerId;
return seedServerId != null && scopedMembers.includes(seedServerId);
});
}
/** Per-server getAlbumList2 allowlists for cluster SQL `restrictAlbumIds` (≤900 ids). */
export async function buildClusterRestrictAlbumScopes(
memberIds: string[],
): Promise<Record<string, string[]> | undefined> {
const scopes: Record<string, string[]> = {};
await Promise.all(
memberIds.map(async sid => {
if (!libraryScopeIdsForServer(sid)?.length) return;
const allowlist = await resolveScopedAlbumAllowlist(sid);
const sql = scopedSqlAlbumAllowlist(allowlist);
if (sql?.length) scopes[sid] = sql;
}),
);
return Object.keys(scopes).length > 0 ? scopes : undefined;
}
/**
* Per-member scoped album ids for merged cluster browse.
* When any member is narrowed, albums from unscoped members are excluded.
*/
/** @deprecated Local paths use SQL scope; kept for callers that still async-wrap. */
export async function filterClusterAlbumsToLibraryScope(
albums: SubsonicAlbum[],
): Promise<SubsonicAlbum[]> {
const members = await resolveClusterBrowseMembers();
if (!members?.length) return albums;
const scopedMembers = members.filter(sid => libraryScopeIdsForServer(sid)?.length);
if (scopedMembers.length === 0) return albums;
const restrictByServer = new Map<string, Set<string>>();
await Promise.all(
scopedMembers.map(async sid => {
const allowlist = await resolveScopedAlbumAllowlist(sid);
if (allowlist?.size) restrictByServer.set(sid, allowlist);
}),
const { resolveClusterAlbumBrowseScopeContext } = await import(
'../serverCluster/clusterAlbumBrowseMembers'
);
if (restrictByServer.size === 0) return albums;
return albums.filter(a => {
const seedServerId = a.clusterSeedServerId;
if (!seedServerId || !scopedMembers.includes(seedServerId)) return false;
const allowed = restrictByServer.get(seedServerId);
return allowed?.has(a.id) ?? false;
});
const ctx = await resolveClusterAlbumBrowseScopeContext();
if (!ctx) return albums;
return filterClusterAlbumsWithScopeContext(albums, ctx);
}
+16 -2
View File
@@ -1,8 +1,13 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
vi.mock('../serverCluster/clusterScope', () => ({
isClusterMode: vi.fn(() => false),
}));
import type { SubsonicAlbum } from '../../api/subsonicTypes';
import {
albumBrowseHasGenreFilter,
albumBrowseHasServerFilters,
albumBrowseIsPurePlain,
albumBrowseIsPureLossless,
albumBrowseMultiGenreBrowse,
albumBrowseStarredNeedsLocalIntersect,
@@ -44,6 +49,12 @@ describe('albumBrowseLoad', () => {
expect(albumBrowseHasGenreFilter({ ...base, genres: ['Rock'] })).toBe(true);
});
it('detects pure plain browse', () => {
expect(albumBrowseIsPurePlain(base)).toBe(true);
expect(albumBrowseIsPurePlain({ ...base, genres: ['Rock'] })).toBe(false);
expect(albumBrowseIsPurePlain({ ...base, compFilter: 'only' })).toBe(false);
});
it('detects pure lossless browse', () => {
expect(albumBrowseIsPureLossless({ ...base, losslessOnly: true })).toBe(true);
expect(albumBrowseIsPureLossless({ ...base, losslessOnly: true, year: { from: 1990 } })).toBe(
@@ -54,7 +65,8 @@ describe('albumBrowseLoad', () => {
);
});
it('slice catalog only for plain browse', () => {
it('slice catalog only for plain browse', async () => {
const { isClusterMode } = await import('../serverCluster/clusterScope');
expect(albumBrowseUseSliceCatalog(base)).toBe(true);
expect(albumBrowseUseSliceCatalog({ ...base, compFilter: 'only' })).toBe(true);
expect(albumBrowseUseSliceCatalog({ ...base, genres: ['Rock'] })).toBe(false);
@@ -62,6 +74,8 @@ describe('albumBrowseLoad', () => {
expect(albumBrowseUseSliceCatalog({ ...base, losslessOnly: true })).toBe(false);
expect(albumBrowseUseSliceCatalog({ ...base, starredOnly: true })).toBe(false);
expect(albumBrowseUseSliceCatalog(base, true)).toBe(false);
vi.mocked(isClusterMode).mockReturnValueOnce(true);
expect(albumBrowseUseSliceCatalog(base)).toBe(false);
});
it('multi-genre disables offset pagination', () => {
+4 -1
View File
@@ -13,6 +13,7 @@ export {
albumBrowseHasGenreFilter,
albumBrowseHasServerFilters,
albumBrowseIsPureLossless,
albumBrowseIsPurePlain,
albumBrowseMultiGenreBrowse,
albumBrowseUseSliceCatalog,
filterAlbumsByCompilation,
@@ -30,6 +31,7 @@ import { fetchAlbumBrowseNetwork } from './albumBrowseNetwork';
import { fetchStarredAlbumBrowse } from './albumBrowseStarredFetch';
import { libraryGetGenreAlbumCounts } from '../../api/library';
import { libraryScopeInvokeArgs } from '../musicLibraryFilter';
import { resolveAlbumBrowseIndexServerId } from '../serverCluster/clusterAlbumBrowseMembers';
import { libraryIsReady } from './libraryReady';
import type {
AlbumBrowseFetchCallbacks,
@@ -60,11 +62,12 @@ export async function fetchLocalAlbumCatalogChunk(
/** Genres in albums matching all filters except genre (for combined-filter UI). */
export async function fetchAlbumBrowseGenreOptions(
serverId: string,
activeServerId: string,
indexEnabled: boolean,
query: AlbumBrowseQuery,
): Promise<GenreFilterOption[]> {
const withoutGenre: AlbumBrowseQuery = { ...query, genres: [] };
const serverId = resolveAlbumBrowseIndexServerId(activeServerId);
const scopeArgs = libraryScopeInvokeArgs(serverId);
const hasCombinedFilters =
albumBrowseHasServerFilters(withoutGenre) || query.compFilter !== 'all';
+27 -5
View File
@@ -3,7 +3,14 @@ import { dedupeById } from '../dedupeById';
import { albumToAlbum } from './advancedSearchLocal';
import { sharedServerFilters } from './albumBrowseFilters';
import { searchSingleServerAlbumBrowse } from './albumBrowseExecution';
import { filterClusterAlbumsToLibraryScope } from './albumBrowseLibraryScope';
import {
filterClusterAlbumsWithScopeContext,
} from './albumBrowseLibraryScope';
import {
narrowedClusterMemberIds,
resolveClusterAlbumBrowseScopeContext,
} from '../serverCluster/clusterAlbumBrowseMembers';
import { getActiveClusterMemberIds } from '../serverCluster/clusterScope';
import { albumSortClauses, sortSubsonicAlbums } from './albumBrowseSort';
import { libraryIsReady } from './libraryReady';
import type { AlbumBrowsePageResult, AlbumBrowseQuery } from './albumBrowseTypes';
@@ -36,8 +43,11 @@ async function runClusterAlbumBrowse(
const starredOnly = useServerStarredIds ? undefined : (query.starredOnly || undefined);
const sort = albumSortClauses(query.sort);
const finish = async (albums: SubsonicAlbum[], hasMore: boolean) => {
let out = await filterClusterAlbumsToLibraryScope(albums);
const scopeCtx = await resolveClusterAlbumBrowseScopeContext();
const finish = (albums: SubsonicAlbum[], hasMore: boolean) => {
let out = scopeCtx
? filterClusterAlbumsWithScopeContext(albums, scopeCtx)
: albums;
if (useServerStarredIds) out = markServerStarredAlbums(out);
return { albums: out, hasMore };
};
@@ -62,7 +72,7 @@ async function runClusterAlbumBrowse(
if (pages.some(p => !p)) return { albums: [], hasMore: false };
const merged = dedupeById(pages.flatMap(p => p!.albums.map(albumToAlbum)));
return {
albums: sortSubsonicAlbums((await finish(merged, false)).albums, query.sort),
albums: sortSubsonicAlbums(finish(merged, false).albums, query.sort),
hasMore: false,
};
}
@@ -83,7 +93,7 @@ async function runClusterAlbumBrowse(
skipTotals: true,
});
if (!resp) return { albums: [], hasMore: false };
return await finish(resp.albums.map(albumToAlbum), resp.albums.length === pageSize);
return finish(resp.albums.map(albumToAlbum), resp.albums.length === pageSize);
}
@@ -96,6 +106,18 @@ export async function runLocalAlbumBrowse(
pageSize: number,
restrictAlbumIds?: string[],
): Promise<AlbumBrowsePageResult | null> {
if (isClusterMode() && restrictAlbumIds == null) {
const narrowed = narrowedClusterMemberIds(getActiveClusterMemberIds());
if (narrowed.length === 1) {
const single = await searchSingleServerAlbumBrowse(
narrowed[0]!,
query,
offset,
pageSize,
);
if (single != null) return single;
}
}
if (canUseClusterAlbumBrowse(query, restrictAlbumIds)) {
const clusterPage = await clusterBrowseAlbumsPage(offset, pageSize);
if (clusterPage) return clusterPage;
@@ -3,8 +3,7 @@ import {
type LibraryAdvancedSearchResponse,
type LibraryClusterAdvancedSearchRequest,
} from '../../api/library';
import { buildClusterRestrictAlbumScopes } from './albumBrowseLibraryScope';
import { resolveClusterBrowseMembers } from '../serverCluster/clusterBrowse';
import { resolveClusterAlbumBrowseScopeContext } from '../serverCluster/clusterAlbumBrowseMembers';
import { buildClusterLibraryScopes } from '../serverCluster/clusterLibraryScopes';
import { isClusterMode } from '../serverCluster/clusterScope';
@@ -12,15 +11,14 @@ export async function clusterAdvancedSearchLocal(
request: Omit<LibraryClusterAdvancedSearchRequest, 'serversOrdered'>,
): Promise<LibraryAdvancedSearchResponse | null> {
if (!isClusterMode()) return null;
const members = await resolveClusterBrowseMembers();
if (!members?.length) return null;
const scopeCtx = await resolveClusterAlbumBrowseScopeContext();
if (!scopeCtx) return null;
const { members } = scopeCtx;
try {
const restrictAlbumScopes = await buildClusterRestrictAlbumScopes(members);
return await libraryClusterAdvancedSearch({
...request,
serversOrdered: members,
libraryScopes: buildClusterLibraryScopes(members),
restrictAlbumScopes,
});
} catch {
return null;
+13 -1
View File
@@ -7,6 +7,9 @@
import { libraryGetStatus, type SyncStateDto } from '../../api/library';
import { useLibraryIndexStore } from '../../store/libraryIndexStore';
const READY_CACHE_TTL_MS = 30_000;
const readyCache = new Map<string, { ready: boolean; expiresAt: number }>();
/** Spec §9.3 — shared by Live Search, Advanced Search, browse, … */
export function libraryStatusIsReady(status: SyncStateDto): boolean {
if (status.syncPhase === 'ready') return true;
@@ -53,12 +56,21 @@ export function librarySyncBlocksCoverWork(
return status.syncPhase === 'initial_sync' || status.syncPhase === 'probing';
}
export function invalidateLibraryReadyCache(serverId?: string): void {
if (serverId) readyCache.delete(serverId);
else readyCache.clear();
}
export async function libraryIsReady(serverId: string | null | undefined): Promise<boolean> {
if (!serverId) return false;
if (!useLibraryIndexStore.getState().isIndexEnabled(serverId)) return false;
const hit = readyCache.get(serverId);
if (hit && hit.expiresAt > Date.now()) return hit.ready;
try {
const status = await libraryGetStatus(serverId);
return libraryStatusIsReady(status);
const ready = libraryStatusIsReady(status);
readyCache.set(serverId, { ready, expiresAt: Date.now() + READY_CACHE_TTL_MS });
return ready;
} catch {
return false;
}
@@ -0,0 +1,67 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const resolveClusterBrowseMembers = vi.fn();
const filterReadyClusterMemberIds = vi.fn();
vi.mock('./clusterBrowse', () => ({
resolveClusterBrowseMembers: (...args: unknown[]) => resolveClusterBrowseMembers(...args),
}));
vi.mock('./representative', () => ({
filterReadyClusterMemberIds: (...args: unknown[]) => filterReadyClusterMemberIds(...args),
}));
vi.mock('./clusterScope', () => ({
getActiveClusterMemberIds: vi.fn(() => ['srv-a', 'srv-b', 'srv-c']),
isClusterMode: vi.fn(() => true),
}));
vi.mock('../musicLibraryFilter', () => ({
libraryScopeIdsForServer: vi.fn((sid: string) => (sid === 'srv-a' ? ['lib-1'] : undefined)),
}));
import {
invalidateClusterAlbumBrowseScopeCache,
narrowedClusterMemberIds,
resolveAlbumBrowseIndexServerId,
resolveClusterAlbumBrowseMembers,
resolveClusterAlbumBrowseScopeContext,
} from './clusterAlbumBrowseMembers';
beforeEach(() => {
vi.clearAllMocks();
invalidateClusterAlbumBrowseScopeCache();
resolveClusterBrowseMembers.mockResolvedValue(['srv-a', 'srv-b', 'srv-c']);
filterReadyClusterMemberIds.mockImplementation(async (ids: string[]) => ids);
});
describe('narrowedClusterMemberIds', () => {
it('returns only members with sidebar scope', () => {
expect(narrowedClusterMemberIds(['srv-a', 'srv-b'])).toEqual(['srv-a']);
});
});
describe('resolveClusterAlbumBrowseMembers', () => {
it('probes only narrowed members when scope is active', async () => {
const members = await resolveClusterAlbumBrowseMembers();
expect(members).toEqual(['srv-a']);
expect(filterReadyClusterMemberIds).toHaveBeenCalledWith(['srv-a']);
expect(resolveClusterBrowseMembers).not.toHaveBeenCalled();
});
});
describe('resolveAlbumBrowseIndexServerId', () => {
it('returns scoped member in cluster mode', () => {
expect(resolveAlbumBrowseIndexServerId('srv-rep')).toBe('srv-a');
});
});
describe('resolveClusterAlbumBrowseScopeContext', () => {
it('caches member lists for repeated calls', async () => {
const first = await resolveClusterAlbumBrowseScopeContext();
const second = await resolveClusterAlbumBrowseScopeContext();
expect(first?.members).toEqual(['srv-a']);
expect(second).toBe(first);
expect(filterReadyClusterMemberIds).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,71 @@
import { getActiveClusterMemberIds, isClusterMode } from './clusterScope';
import { libraryScopeIdsForServer } from '../musicLibraryFilter';
import { resolveClusterBrowseMembers } from './clusterBrowse';
import { filterReadyClusterMemberIds } from './representative';
/** Cluster members with a narrowed sidebar library selection. */
export function narrowedClusterMemberIds(memberIds: string[]): string[] {
return memberIds.filter(sid => libraryScopeIdsForServer(sid)?.length);
}
/**
* Server id for single-server index reads (genre counts, plain list_albums).
* In cluster mode with narrowed scope, use the scoped member not `activeServerId`.
*/
export function resolveAlbumBrowseIndexServerId(activeServerId: string): string {
if (!isClusterMode()) return activeServerId;
const narrowed = narrowedClusterMemberIds(getActiveClusterMemberIds());
if (narrowed.length >= 1) return narrowed[0]!;
return activeServerId;
}
/**
* Ready cluster members to query for All Albums.
* When any member is scoped, only scoped ready members are included (unscoped
* members are excluded from the merged view).
*/
export async function resolveClusterAlbumBrowseMembers(): Promise<string[] | null> {
const clusterMembers = getActiveClusterMemberIds();
const narrowed = narrowedClusterMemberIds(clusterMembers);
if (narrowed.length > 0) {
const ready = await filterReadyClusterMemberIds(narrowed);
return ready.length > 0 ? ready : null;
}
return resolveClusterBrowseMembers();
}
export type ClusterAlbumBrowseScopeContext = {
members: string[];
scopedMembers: string[];
};
let scopeContextCache: ClusterAlbumBrowseScopeContext | null = null;
/** Cached member lists for one browse request chain (local SQL scope only). */
export async function resolveClusterAlbumBrowseScopeContext(
members?: string[],
): Promise<ClusterAlbumBrowseScopeContext | null> {
if (!members && scopeContextCache) return scopeContextCache;
const resolved = members ?? await resolveClusterAlbumBrowseMembers();
if (!resolved?.length) return null;
const scopedMembers = narrowedClusterMemberIds(getActiveClusterMemberIds())
.filter(sid => resolved.includes(sid));
const ctx: ClusterAlbumBrowseScopeContext = {
members: resolved,
scopedMembers,
};
scopeContextCache = ctx;
return ctx;
}
export function invalidateClusterAlbumBrowseScopeCache(): void {
scopeContextCache = null;
}
/** @internal tests */
export function peekClusterAlbumBrowseScopeCache(): typeof scopeContextCache {
return scopeContextCache;
}
@@ -87,6 +87,6 @@ describe('clusterBrowse', () => {
expect(clusterAlbumBrowseNeedsAdvanced({ ...plain, compFilter: 'only' })).toBe(true);
expect(clusterAlbumBrowseNeedsAdvanced({ ...plain, genres: ['Rock'] })).toBe(true);
vi.mocked(isClusterLibraryScopeNarrowed).mockReturnValueOnce(true);
expect(clusterAlbumBrowseNeedsAdvanced(plain)).toBe(true);
expect(clusterAlbumBrowseNeedsAdvanced(plain)).toBe(false);
});
});
+24 -7
View File
@@ -14,8 +14,10 @@ import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from '../../api/subs
import { dedupeById } from '../dedupeById';
import { albumToAlbum, artistToArtist, trackToSong } from '../library/advancedSearchLocal';
import { albumBrowseHasServerFilters } from '../library/albumBrowseFilters';
import { searchSingleServerAlbumBrowse } from '../library/albumBrowseExecution';
import type { AlbumBrowsePageResult, AlbumBrowseQuery } from '../library/albumBrowseTypes';
import { filterClusterAlbumsToLibraryScope } from '../library/albumBrowseLibraryScope';
import { filterClusterAlbumsWithScopeContext } from '../library/albumBrowseLibraryScope';
import { resolveClusterAlbumBrowseScopeContext } from './clusterAlbumBrowseMembers';
import { buildClusterLibraryScopes, isClusterLibraryScopeNarrowed } from './clusterLibraryScopes';
import { getActiveClusterId, isClusterMode } from './clusterScope';
import { getClusterMergeMemberIds } from './representative';
@@ -32,7 +34,6 @@ export async function resolveClusterBrowseMembers(): Promise<string[] | null> {
export function clusterAlbumBrowseNeedsAdvanced(query: AlbumBrowseQuery): boolean {
if (albumBrowseHasServerFilters(query)) return true;
if (query.compFilter !== 'all') return true;
if (isClusterLibraryScopeNarrowed()) return true;
return false;
}
@@ -44,8 +45,6 @@ export function canUseClusterAlbumBrowse(
if (restrictAlbumIds != null) return false;
if (albumBrowseHasServerFilters(query)) return false;
if (query.compFilter !== 'all') return false;
// Merged list_tracks SQL ignores library_id on many indexes — use advanced search.
if (isClusterLibraryScopeNarrowed()) return false;
return true;
}
@@ -68,12 +67,30 @@ export async function clusterBrowseTracksPage(
}
}
const plainClusterAlbumQuery = {
sort: 'alphabeticalByName' as const,
genres: [] as string[],
losslessOnly: false,
starredOnly: false,
compFilter: 'all' as const,
};
export async function clusterBrowseAlbumsPage(
offset: number,
pageSize: number,
): Promise<AlbumBrowsePageResult | null> {
const members = await resolveClusterBrowseMembers();
if (!members) return null;
const scopeCtx = await resolveClusterAlbumBrowseScopeContext();
if (!scopeCtx) return null;
const { members } = scopeCtx;
if (members.length === 1) {
const page = await searchSingleServerAlbumBrowse(
members[0]!,
plainClusterAlbumQuery,
offset,
pageSize,
);
if (page != null) return page;
}
try {
const resp = await libraryClusterListAlbums({
serversOrdered: members,
@@ -83,7 +100,7 @@ export async function clusterBrowseAlbumsPage(
});
let albums = resp.albums.map(albumToAlbum);
if (isClusterLibraryScopeNarrowed()) {
albums = await filterClusterAlbumsToLibraryScope(albums);
albums = filterClusterAlbumsWithScopeContext(albums, scopeCtx);
}
return {
albums,
+43
View File
@@ -42,11 +42,31 @@ export async function recomputeClusterRepresentative(clusterId: string): Promise
}
}
const MERGE_MEMBER_CACHE_TTL_MS = 15_000;
let mergeMemberCache: {
clusterId: string;
memberKey: string;
ids: string[];
expiresAt: number;
} | null = null;
/** Available + index-ready members in priority order (async). */
export async function getClusterMergeMemberIds(clusterId: string): Promise<string[]> {
const { useAuthStore } = await import('../../store/authStore');
const cluster = useAuthStore.getState().clusters.find(c => c.id === clusterId);
if (!cluster) return [];
const memberKey = cluster.serverIds.join(',');
const now = Date.now();
if (
mergeMemberCache
&& mergeMemberCache.clusterId === clusterId
&& mergeMemberCache.memberKey === memberKey
&& mergeMemberCache.expiresAt > now
) {
return mergeMemberCache.ids;
}
const members = getClusterMemberProfiles(cluster);
const ready = await Promise.all(
members.map(async server => {
@@ -55,5 +75,28 @@ export async function getClusterMergeMemberIds(clusterId: string): Promise<strin
return server.id;
}),
);
const ids = ready.filter((id): id is string => id != null);
mergeMemberCache = {
clusterId,
memberKey,
ids,
expiresAt: now + MERGE_MEMBER_CACHE_TTL_MS,
};
return ids;
}
export function invalidateClusterMergeMemberCache(): void {
mergeMemberCache = null;
}
/** Ready subset in cluster priority order (skips full-cluster probe). */
export async function filterReadyClusterMemberIds(memberIds: string[]): Promise<string[]> {
const ready = await Promise.all(
memberIds.map(async id => {
if (!isServerLikelyReachable(id)) return null;
if (!(await libraryIsReady(id))) return null;
return id;
}),
);
return ready.filter((id): id is string => id != null);
}