mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 23:35:44 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user