fix(browse): cluster and lossless library scope allowlists

Apply cached getAlbumList2 allowlists per server in SQL and post-filter
so narrowed sidebar scope does not leak albums from other libraries or
cluster members. Wire restrictAlbumScopes into cluster advanced search
and dedicated lossless browse; detect cluster scope in All Albums paging.
This commit is contained in:
Maxim Isaev
2026-06-05 20:29:57 +03:00
parent 496b39a4da
commit 26716e2fb0
16 changed files with 457 additions and 163 deletions
@@ -619,6 +619,14 @@ pub struct LibraryLosslessAlbumsRequest {
pub server_id: String,
#[serde(default)]
pub library_scope: Option<String>,
/// Multiple music-folder ids (OR). Preferred over `library_scope` when length > 1.
#[serde(default)]
pub library_scope_ids: Option<Vec<String>>,
#[serde(default)]
pub sort: Vec<LibrarySortClause>,
/// Navidrome-scoped album ids from getAlbumList2 (authoritative when track `library_id` is sparse).
#[serde(default)]
pub restrict_album_ids: Option<Vec<String>>,
#[serde(default = "default_lossless_limit")]
pub limit: u32,
#[serde(default)]
@@ -713,6 +721,9 @@ pub struct LibraryClusterAdvancedSearchRequest {
pub starred_only: Option<bool>,
#[serde(default)]
pub restrict_album_ids: Option<Vec<String>>,
/// Per-member album allowlists from getAlbumList2 (`server_id` → album ids).
#[serde(default)]
pub restrict_album_scopes: HashMap<String, Vec<String>>,
#[serde(default)]
pub query_album_title_only: Option<bool>,
#[serde(default)]
@@ -2,9 +2,12 @@
//!
//! Mirrors the frontend allowlist in `src/utils/library/losslessFormats.ts`.
use crate::dto::{LibraryAlbumDto, LibraryLosslessAlbumsRequest, LibraryLosslessAlbumsResponse};
use crate::dto::{
LibraryAlbumDto, LibraryLosslessAlbumsRequest, LibraryLosslessAlbumsResponse, LibrarySortClause,
SortDir,
};
use crate::lossless_formats::track_is_lossless_sql;
use crate::search::library_scope_equals_sql;
use crate::search::library_scope_filter_sql;
use crate::store::LibraryStore;
use rusqlite::types::Value as SqlValue;
use serde_json::Value;
@@ -15,6 +18,45 @@ fn trimmed_nonempty(s: Option<&str>) -> Option<String> {
.map(String::from)
}
fn effective_lossless_scope_ids(req: &LibraryLosslessAlbumsRequest) -> 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 lossless_album_order(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",
_ => continue,
};
let dir = match s.dir {
SortDir::Asc => "ASC",
SortDir::Desc => "DESC",
};
keys.push(format!("{col} {dir}"));
}
if keys.is_empty() {
return "ORDER BY la.max_bit_depth DESC, \
COALESCE(a.name, la.album_name) COLLATE NOCASE ASC, la.album_id ASC"
.to_string();
}
keys.push("la.album_id ASC".to_string());
format!("ORDER BY {}", keys.join(", "))
}
/// Paginated lossless albums for one server. Returns empty when the index has
/// no matching tracks — caller may fall back to the Navidrome song-stream walk.
pub fn list_lossless_albums(
@@ -37,12 +79,25 @@ pub fn list_lossless_albums(
];
let mut params: Vec<SqlValue> = vec![SqlValue::Text(req.server_id.clone())];
if let Some(scope) = trimmed_nonempty(req.library_scope.as_deref()) {
let clause = library_scope_equals_sql("t");
where_clauses.push(clause);
params.push(SqlValue::Text(scope));
let scope_ids = effective_lossless_scope_ids(req);
if !scope_ids.is_empty() {
let match_expr = crate::search::library_scope_match_sql("t");
where_clauses.push(format!("({match_expr}) IS NOT NULL AND TRIM({match_expr}) != ''"));
if let (Some(clause), scope_params) = library_scope_filter_sql("t", &scope_ids) {
where_clauses.push(clause);
for p in scope_params {
params.push(p);
}
}
}
push_album_id_allowlist(
&mut where_clauses,
&mut params,
"t.album_id",
req.restrict_album_ids.as_deref(),
);
let order_sql = lossless_album_order(&req.sort);
let where_sql = where_clauses.join(" AND ");
let sql = format!(
"SELECT \
@@ -81,9 +136,7 @@ pub fn list_lossless_albums(
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 BY la.max_bit_depth DESC, \
COALESCE(a.name, la.album_name) COLLATE NOCASE ASC, \
la.album_id ASC \
{order_sql} \
LIMIT ? OFFSET ?"
);
@@ -106,6 +159,26 @@ pub fn list_lossless_albums(
})
}
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()));
}
}
fn empty_response() -> LibraryLosslessAlbumsResponse {
LibraryLosslessAlbumsResponse {
albums: Vec::new(),
@@ -203,6 +276,9 @@ mod tests {
LibraryLosslessAlbumsRequest {
server_id: server.into(),
library_scope: None,
library_scope_ids: None,
sort: Vec::new(),
restrict_album_ids: None,
limit,
offset,
}
@@ -271,6 +347,79 @@ mod tests {
assert_eq!(resp.albums[0].id, "al1");
}
#[test]
fn library_scope_ids_union_narrows_results() {
let store = LibraryStore::open_in_memory();
let mut a = track_with_suffix("s1", "t1", "al1", "A", "flac", 16);
a.library_id = Some("lib1".into());
let mut b = track_with_suffix("s1", "t2", "al2", "B", "flac", 16);
b.library_id = Some("lib2".into());
let mut c = track_with_suffix("s1", "t3", "al3", "C", "flac", 16);
c.library_id = Some("lib3".into());
TrackRepository::new(&store)
.upsert_batch(&[a, b, c])
.unwrap();
let mut scoped = req("s1", 50, 0);
scoped.library_scope_ids = Some(vec!["lib1".into(), "lib3".into()]);
let resp = list_lossless_albums(&store, &scoped).unwrap();
assert_eq!(resp.albums.len(), 2);
assert_eq!(resp.albums[0].id, "al1");
assert_eq!(resp.albums[1].id, "al3");
}
#[test]
fn name_sort_overrides_bit_depth_default() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track_with_suffix("s1", "t1", "al_z", "Zulu", "flac", 24),
track_with_suffix("s1", "t2", "al_a", "Alpha", "flac", 16),
])
.unwrap();
let mut sorted = req("s1", 50, 0);
sorted.sort = vec![LibrarySortClause {
field: "name".into(),
dir: SortDir::Asc,
}];
let resp = list_lossless_albums(&store, &sorted).unwrap();
assert_eq!(resp.albums[0].id, "al_a");
assert_eq!(resp.albums[1].id, "al_z");
}
#[test]
fn matches_suffix_from_raw_json_when_column_null() {
let store = LibraryStore::open_in_memory();
let mut row = track_with_suffix("s1", "t1", "al_json", "Json", "mp3", 0);
row.suffix = None;
row.raw_json = r#"{"suffix":"flac","bitDepth":24}"#.into();
TrackRepository::new(&store)
.upsert_batch(&[row])
.unwrap();
let resp = list_lossless_albums(&store, &req("s1", 50, 0)).unwrap();
assert_eq!(resp.albums.len(), 1);
assert_eq!(resp.albums[0].id, "al_json");
}
#[test]
fn restrict_album_ids_narrows_lossless_results() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track_with_suffix("s1", "t1", "al_keep", "Keep", "flac", 24),
track_with_suffix("s1", "t2", "al_drop", "Drop", "flac", 24),
])
.unwrap();
let mut restricted = req("s1", 50, 0);
restricted.restrict_album_ids = Some(vec!["al_keep".into()]);
let resp = list_lossless_albums(&store, &restricted).unwrap();
assert_eq!(resp.albums.len(), 1);
assert_eq!(resp.albums[0].id, "al_keep");
}
#[test]
fn pagination_sets_has_more() {
let store = LibraryStore::open_in_memory();
@@ -7,14 +7,22 @@ pub const LOSSLESS_SUFFIXES: &[&str] = &[
"flac", "wav", "wave", "aiff", "aif", "dsf", "dff", "ape", "wv", "shn", "tta",
];
/// `LOWER(alias.suffix) IN ('flac', …)` for SQL WHERE clauses.
/// Effective suffix — hot `track.suffix`, then Navidrome `raw_json.suffix`.
pub fn track_suffix_expr(table_alias: &str) -> String {
format!(
"LOWER(COALESCE(NULLIF({table_alias}.suffix, ''), \
CAST(json_extract({table_alias}.raw_json, '$.suffix') AS TEXT), ''))"
)
}
/// `track_suffix_expr IN ('flac', …)` for SQL WHERE clauses.
pub fn track_is_lossless_sql(table_alias: &str) -> String {
let list = LOSSLESS_SUFFIXES
.iter()
.map(|s| format!("'{s}'"))
.collect::<Vec<_>>()
.join(", ");
format!("LOWER({table_alias}.suffix) IN ({list})")
format!("{} IN ({list})", track_suffix_expr(table_alias))
}
/// Album has at least one indexed lossless track (same allowlist as browse).
@@ -50,6 +58,6 @@ mod tests {
let sql = track_is_lossless_sql("t");
assert!(sql.contains("'flac'"));
assert!(sql.contains("'tta'"));
assert!(sql.starts_with("LOWER(t.suffix) IN ("));
assert!(sql.contains("json_extract(t.raw_json, '$.suffix')"));
}
}
@@ -48,7 +48,12 @@ pub fn run_cluster_advanced_search(
entity_types: req.entity_types.clone(),
filters: req.filters.clone(),
starred_only: req.starred_only,
restrict_album_ids: req.restrict_album_ids.clone(),
restrict_album_ids: req
.restrict_album_scopes
.get(server_id)
.filter(|ids| !ids.is_empty())
.cloned()
.or_else(|| req.restrict_album_ids.clone()),
query_album_title_only: req.query_album_title_only,
sort: req.sort.clone(),
limit: per_server_limit,
@@ -345,6 +350,7 @@ mod tests {
filters: Vec::new(),
starred_only: None,
restrict_album_ids: None,
restrict_album_scopes: HashMap::new(),
query_album_title_only: None,
sort: Vec::new(),
limit: 50,
@@ -383,6 +389,7 @@ mod tests {
filters: Vec::new(),
starred_only: None,
restrict_album_ids: None,
restrict_album_scopes: HashMap::new(),
query_album_title_only: None,
sort: Vec::new(),
limit: 1,
+9
View File
@@ -384,6 +384,9 @@ export function libraryLiveSearch(request: LibraryLiveSearchRequest): Promise<Li
export interface LibraryLosslessAlbumsRequest {
serverId: string;
libraryScope?: string | null;
libraryScopeIds?: string[] | null;
restrictAlbumIds?: string[] | null;
sort?: LibrarySortClause[];
limit?: number;
offset?: number;
}
@@ -403,6 +406,9 @@ export function libraryListLosslessAlbums(
request: {
serverId: indexKey,
libraryScope: request.libraryScope ?? undefined,
libraryScopeIds: request.libraryScopeIds ?? undefined,
restrictAlbumIds: request.restrictAlbumIds ?? undefined,
sort: request.sort,
limit: request.limit,
offset: request.offset,
},
@@ -710,6 +716,8 @@ export interface LibraryClusterAdvancedSearchRequest {
filters?: LibraryFilterClause[];
starredOnly?: boolean | null;
restrictAlbumIds?: string[] | null;
/** Per-member getAlbumList2 allowlists (`serverId` → album ids). */
restrictAlbumScopes?: Record<string, string[]>;
queryAlbumTitleOnly?: boolean | null;
sort?: LibrarySortClause[];
limit: number;
@@ -729,6 +737,7 @@ export function libraryClusterAdvancedSearch(
filters: request.filters ?? [],
starredOnly: request.starredOnly ?? undefined,
restrictAlbumIds: request.restrictAlbumIds ?? undefined,
restrictAlbumScopes: mapClusterLibraryScopesToIndexKeys(request.restrictAlbumScopes) ?? {},
queryAlbumTitleOnly: request.queryAlbumTitleOnly ?? undefined,
sort: request.sort ?? [],
limit: request.limit,
+6 -2
View File
@@ -29,6 +29,8 @@ import {
type GenreFilterOption,
} from '../utils/library/albumBrowseLoad';
import { libraryScopeIdsForServer } from '../api/subsonicClient';
import { isClusterLibraryScopeNarrowed } from '../utils/serverCluster/clusterLibraryScopes';
import { isClusterMode } from '../utils/serverCluster/clusterScope';
import {
ALBUM_YEAR_FILTER_DEBOUNCE_MS,
resolveAlbumYearBounds,
@@ -171,7 +173,9 @@ export function useAlbumBrowseData({
const genreFiltered = albumBrowseHasGenreFilter(browseQuery);
const multiGenreBrowse = albumBrowseMultiGenreBrowse(browseQuery);
const serverFilterActive = albumBrowseHasServerFilters(browseQuery);
const libraryScopeActive = libraryScopeIdsForServer(serverId) != null;
const libraryScopeActive = isClusterMode()
? isClusterLibraryScopeNarrowed()
: libraryScopeIdsForServer(serverId) != null;
const narrowGenreList = yearFilterActive || losslessOnly || starredOnly || compFilterActive;
/** When true, GenreFilterBar uses `genreCatalogOptions` instead of server `getGenres()`. */
const genreCatalogActive = narrowGenreList || (indexEnabled && libraryScopeActive);
@@ -336,7 +340,7 @@ export function useAlbumBrowseData({
);
if (cancelled || generation !== loadGenerationRef.current) return;
if (first != null) {
if (!albumBrowseUseSliceCatalog(browseQuery)) {
if (!albumBrowseUseSliceCatalog(browseQuery, libraryScopeActive)) {
setBrowseMode('page');
setAlbums(first.albums);
setHasMore(first.hasMore);
+92 -76
View File
@@ -1,50 +1,31 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { SubsonicAlbum } from '../../api/subsonicTypes';
const mockAdvancedSearch = vi.fn();
const mockListByGenre = vi.fn();
const mockFilterScope = vi.fn();
const mockResolveRestrict = vi.fn();
const mockListLossless = vi.fn();
const mockScopeArgs = vi.fn();
const mockScopedAllowlist = vi.fn();
vi.mock('../../api/library', () => ({
libraryAdvancedSearch: (...args: unknown[]) => mockAdvancedSearch(...args),
libraryListAlbumsByGenre: (...args: unknown[]) => mockListByGenre(...args),
libraryListLosslessAlbums: (...args: unknown[]) => mockListLossless(...args),
}));
vi.mock('../musicLibraryFilter', () => ({
libraryScopeInvokeArgs: (...args: unknown[]) => mockScopeArgs(...args),
}));
vi.mock('./albumBrowseLibraryScope', () => ({
resolveScopedAlbumRestrictIds: (...args: unknown[]) => mockResolveRestrict(...args),
intersectAlbumRestrictIds: (
primary: string[] | undefined,
scope: string[] | undefined,
) => {
if (!scope?.length) return primary;
if (!primary?.length) return scope;
const allowed = new Set(scope);
return primary.filter(id => allowed.has(id));
},
filterAlbumsToServerLibraryScope: (
_serverId: string,
albums: SubsonicAlbum[],
) => mockFilterScope(albums),
}));
vi.mock('./albumBrowseLibraryScope', async importOriginal => {
const actual = await importOriginal<typeof import('./albumBrowseLibraryScope')>();
return {
...actual,
resolveScopedAlbumAllowlist: (...args: unknown[]) => mockScopedAllowlist(...args),
};
});
import { searchSingleServerAlbumBrowse } from './albumBrowseExecution';
const album = (id: string, genre?: string): SubsonicAlbum => ({
id,
name: id,
artist: 'X',
artistId: 'x',
songCount: 1,
duration: 1,
genre,
});
const baseQuery = {
sort: 'alphabeticalByName' as const,
genres: [] as string[],
@@ -55,26 +36,96 @@ const baseQuery = {
beforeEach(() => {
vi.clearAllMocks();
mockScopeArgs.mockReturnValue({ libraryScopeIds: ['lib-1'] });
mockResolveRestrict.mockResolvedValue(['scoped-a', 'scoped-b']);
mockFilterScope.mockImplementation(async (albums: SubsonicAlbum[]) =>
albums.filter(a => a.id.startsWith('scoped')),
);
mockScopeArgs.mockReturnValue({ libraryScopeIds: ['lib-1'], libraryScope: 'lib-1' });
mockScopedAllowlist.mockResolvedValue(null);
});
describe('searchSingleServerAlbumBrowse', () => {
it('multi-genre union always runs library scope finalize', async () => {
it('pure lossless uses libraryListLosslessAlbums with scope and sort', async () => {
mockListLossless.mockResolvedValue({
source: 'local',
albums: [{ id: 'flac-1', name: 'Hi-Res', serverId: 's1' }],
hasMore: false,
});
const result = await searchSingleServerAlbumBrowse(
'srv-1',
{ ...baseQuery, losslessOnly: true },
0,
30,
);
expect(mockListLossless).toHaveBeenCalledWith({
serverId: 'srv-1',
libraryScopeIds: ['lib-1'],
libraryScope: 'lib-1',
sort: [{ field: 'name', dir: 'asc' }],
limit: 30,
offset: 0,
});
expect(mockAdvancedSearch).not.toHaveBeenCalled();
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',
albums: [{ id: 'a1', name: 'A', serverId: 's1' }],
});
await searchSingleServerAlbumBrowse(
'srv-1',
{ ...baseQuery, losslessOnly: true, year: { from: 1990 } },
0,
30,
);
expect(mockListLossless).not.toHaveBeenCalled();
expect(mockAdvancedSearch).toHaveBeenCalledWith(
expect.objectContaining({
filters: expect.arrayContaining([
{ field: 'lossless', op: 'is_true' },
expect.objectContaining({ field: 'year' }),
]),
}),
);
});
it('multi-genre union uses advanced search per genre', async () => {
mockAdvancedSearch
.mockResolvedValueOnce({
source: 'local',
albums: [{ id: 'scoped-a', name: 'A', serverId: 's1', genre: 'Rock' }],
albums: [{ id: 'r1', name: 'Rock', serverId: 's1', genre: 'Rock' }],
})
.mockResolvedValueOnce({
source: 'local',
albums: [
{ id: 'scoped-b', name: 'B', serverId: 's1', genre: 'Jazz' },
{ id: 'leak', name: 'L', serverId: 's1', genre: 'Jazz' },
],
albums: [{ id: 'j1', name: 'Jazz', serverId: 's1', genre: 'Jazz' }],
});
const result = await searchSingleServerAlbumBrowse(
@@ -85,41 +136,6 @@ describe('searchSingleServerAlbumBrowse', () => {
);
expect(mockAdvancedSearch).toHaveBeenCalledTimes(2);
expect(mockFilterScope).toHaveBeenCalled();
expect(result?.albums.map(a => a.id).sort()).toEqual(['scoped-a', 'scoped-b']);
expect(result?.hasMore).toBe(false);
});
it('single pure genre also finalizes scope', async () => {
mockListByGenre.mockResolvedValue({
source: 'local',
albums: [
{ id: 'scoped-a', name: 'A', serverId: 's1' },
{ id: 'leak', name: 'L', serverId: 's1' },
],
hasMore: false,
});
const result = await searchSingleServerAlbumBrowse(
'srv-1',
{ ...baseQuery, genres: ['Rock'] },
0,
30,
);
expect(mockListByGenre).toHaveBeenCalled();
expect(mockFilterScope).toHaveBeenCalled();
expect(result?.albums.map(a => a.id)).toEqual(['scoped-a']);
});
it('rejects offset pagination for multi-genre OR union', async () => {
const result = await searchSingleServerAlbumBrowse(
'srv-1',
{ ...baseQuery, genres: ['Rock', 'Jazz'] },
30,
30,
);
expect(result).toEqual({ albums: [], hasMore: false });
expect(mockAdvancedSearch).not.toHaveBeenCalled();
expect(result?.albums.map(a => a.id).sort()).toEqual(['j1', 'r1']);
});
});
+44 -45
View File
@@ -1,43 +1,41 @@
/**
* Album browse filter layering (every path must follow this order):
*
* 1. **Library scope** (sidebar picker) SQL `libraryScopeIds` and/or REST album allowlist
* 2. **Album attributes** (AND) year, lossless, compilation, starred*
* 3. **Genre** (OR union) one `genre = ?` query per selected genre, results merged
* 4. **Starred allowlist** `restrictAlbumIds` when intersecting server favorites
* 5. **Finalize** always re-apply library scope allowlist on album rows (REST fallback)
*
* *Starred uses step 4 when server favorite ids are supplied; otherwise step 2 SQL filter.
* 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)
*/
import {
libraryAdvancedSearch,
libraryListAlbumsByGenre,
libraryListLosslessAlbums,
type LibraryFilterClause,
} from '../../api/library';
import type { SubsonicAlbum } from '../../api/subsonicTypes';
import { libraryScopeInvokeArgs } from '../musicLibraryFilter';
import {
filterAlbumsToServerLibraryScope,
filterClusterAlbumsToLibraryScope,
filterAlbumsByScopedAllowlist,
intersectAlbumRestrictIds,
resolveScopedAlbumRestrictIds,
resolveScopedAlbumAllowlist,
scopedSqlAlbumAllowlist,
} from './albumBrowseLibraryScope';
import { dedupeById } from '../dedupeById';
import { albumToAlbum } from './advancedSearchLocal';
import { sharedServerFilters } from './albumBrowseFilters';
import { albumBrowseIsPureLossless, sharedServerFilters } from './albumBrowseFilters';
import { albumSortClauses, sortSubsonicAlbums } from './albumBrowseSort';
import type { AlbumBrowsePageResult, AlbumBrowseQuery } from './albumBrowseTypes';
import { GENRE_ALBUM_FETCH_LIMIT } from './albumBrowseTypes';
export type AlbumBrowseInvokeContext = {
scopeArgs: ReturnType<typeof libraryScopeInvokeArgs>;
effectiveRestrict: string[] | undefined;
/** Passed to `libraryAdvancedSearch` / `libraryListAlbumsByGenre`. */
invokeScope:
| { restrictAlbumIds: string[] }
| ReturnType<typeof libraryScopeInvokeArgs>;
invokeScope: ReturnType<typeof libraryScopeInvokeArgs> & {
restrictAlbumIds?: string[];
};
scopedAllowlist: Set<string> | null;
useServerStarredIds: boolean;
starredOnly: boolean | undefined;
/** Step 2 — year, lossless, compilation, starred (when not using allowlist). */
attributeFilters: LibraryFilterClause[];
};
@@ -47,39 +45,29 @@ export async function resolveAlbumBrowseInvokeContext(
restrictAlbumIds?: string[],
): Promise<AlbumBrowseInvokeContext> {
const scopeArgs = libraryScopeInvokeArgs(serverId);
const scopedRestrict = await resolveScopedAlbumRestrictIds(serverId);
const effectiveRestrict = intersectAlbumRestrictIds(restrictAlbumIds, scopedRestrict);
const scopedAllowlist = await resolveScopedAlbumAllowlist(serverId);
const allowlistArr = scopedAllowlist ? [...scopedAllowlist] : undefined;
const scopeSqlAllowlist = scopedSqlAlbumAllowlist(scopedAllowlist);
const useServerStarredIds = restrictAlbumIds != null;
const invokeScope = effectiveRestrict != null
? { restrictAlbumIds: effectiveRestrict }
: scopeArgs;
const favoriteAllowlist = useServerStarredIds
? intersectAlbumRestrictIds(restrictAlbumIds, allowlistArr)
: undefined;
const sqlRestrict = favoriteAllowlist ?? scopeSqlAllowlist;
const invokeScope = {
...scopeArgs,
...(sqlRestrict?.length ? { restrictAlbumIds: sqlRestrict } : {}),
};
return {
scopeArgs,
effectiveRestrict,
invokeScope,
scopedAllowlist,
useServerStarredIds,
starredOnly: useServerStarredIds ? undefined : (query.starredOnly || undefined),
attributeFilters: sharedServerFilters(query, useServerStarredIds),
};
}
/** Step 5 — enforce sidebar library scope on every album row. */
export async function finalizeSingleServerAlbumBrowse(
serverId: string,
albums: SubsonicAlbum[],
effectiveRestrict?: string[],
): Promise<SubsonicAlbum[]> {
return filterAlbumsToServerLibraryScope(serverId, albums, effectiveRestrict);
}
/** Step 5 (cluster) — per-member scoped allowlists. */
export async function finalizeClusterAlbumBrowse(
albums: SubsonicAlbum[],
): Promise<SubsonicAlbum[]> {
return filterClusterAlbumsToLibraryScope(albums);
}
function genreEqFilter(genre: string): LibraryFilterClause {
return { field: 'genre', op: 'eq', value: genre };
}
@@ -126,12 +114,12 @@ export async function searchSingleServerAlbumBrowse(
const ctx = await resolveAlbumBrowseInvokeContext(serverId, query, restrictAlbumIds);
const sort = albumSortClauses(query.sort);
const finish = async (
const finish = (
albums: SubsonicAlbum[],
hasMore: boolean,
): Promise<AlbumBrowsePageResult> => {
let out = await finalizeSingleServerAlbumBrowse(serverId, albums, ctx.effectiveRestrict);
if (ctx.useServerStarredIds) out = markServerStarredAlbums(out);
): AlbumBrowsePageResult => {
const scoped = filterAlbumsByScopedAllowlist(albums, ctx.scopedAllowlist);
const out = ctx.useServerStarredIds ? markServerStarredAlbums(scoped) : scoped;
return { albums: out, hasMore };
};
@@ -139,9 +127,8 @@ export async function searchSingleServerAlbumBrowse(
if (offset > 0) return { albums: [], hasMore: false };
try {
const merged = await fetchMultiGenreAlbumUnion(serverId, query, ctx);
const finished = await finish(merged, false);
return {
albums: sortSubsonicAlbums(finished.albums, query.sort),
albums: sortSubsonicAlbums(finish(merged, false).albums, query.sort),
hasMore: false,
};
} catch {
@@ -184,6 +171,18 @@ export async function searchSingleServerAlbumBrowse(
}
try {
if (albumBrowseIsPureLossless(query)) {
const resp = await libraryListLosslessAlbums({
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);
}
const resp = await libraryAdvancedSearch({
serverId,
...ctx.invokeScope,
+14 -1
View File
@@ -23,10 +23,23 @@ export function albumBrowseMultiGenreBrowse(query: AlbumBrowseQuery): boolean {
}
/** Lazy catalog slice mode — plain unfiltered browse (comp/year/genre/starred via server path). */
export function albumBrowseUseSliceCatalog(query: AlbumBrowseQuery): boolean {
export function albumBrowseUseSliceCatalog(
query: AlbumBrowseQuery,
libraryScopeActive = false,
): boolean {
if (libraryScopeActive) return false;
return !albumBrowseHasServerFilters(query);
}
/** Lossless-only All Albums browse — dedicated `library_list_lossless_albums` path. */
export function albumBrowseIsPureLossless(query: AlbumBrowseQuery): boolean {
return query.losslessOnly
&& !query.starredOnly
&& query.year == null
&& query.compFilter === 'all'
&& query.genres.length === 0;
}
/** Favorites need the local index when combined with lossless or genre (AND). */
export function albumBrowseStarredNeedsLocalIntersect(
query: AlbumBrowseQuery,
@@ -64,6 +64,15 @@ describe('filterClusterAlbumsToLibraryScope', () => {
album('other', 'srv-a'),
album('any', 'srv-b'),
]);
expect(out.map(a => a.id)).toEqual(['in-scope', 'any']);
expect(out.map(a => a.id)).toEqual(['in-scope']);
});
it('drops albums from unscoped cluster members when another member is narrowed', async () => {
albumIdsInLibraryScope.mockResolvedValue(new Set(['a1']));
const out = await filterClusterAlbumsToLibraryScope([
album('a1', 'srv-a'),
album('b1', 'srv-b'),
]);
expect(out.map(a => a.id)).toEqual(['a1']);
});
});
+57 -16
View File
@@ -3,22 +3,37 @@ 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;
/**
* Navidrome-scoped album ids from getAlbumList2 (per musicFolderId).
* Used when the local index has no reliable `library_id` on tracks SQL
* `libraryScope` alone would not narrow album browse.
* Cached per server + filter version safe to call on every browse page.
*/
export async function resolveScopedAlbumAllowlist(
serverId: string,
): Promise<Set<string> | null> {
if (!libraryScopeIdsForServer(serverId)?.length) return null;
try {
return await albumIdsInLibraryScope(serverId);
} catch {
return null;
}
}
export async function resolveScopedAlbumRestrictIds(
serverId: string,
): Promise<string[] | undefined> {
if (!libraryScopeIdsForServer(serverId)?.length) return undefined;
try {
const ids = await albumIdsInLibraryScope(serverId);
if (!ids) return undefined;
return [...ids];
} catch {
return undefined;
}
const allowlist = await resolveScopedAlbumAllowlist(serverId);
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. */
@@ -44,7 +59,34 @@ export function intersectAlbumRestrictIds(
return primary.filter(id => allowed.has(id));
}
/** Per-member scoped album ids for merged cluster browse. */
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;
}
/** 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.
*/
export async function filterClusterAlbumsToLibraryScope(
albums: SubsonicAlbum[],
): Promise<SubsonicAlbum[]> {
@@ -57,17 +99,16 @@ export async function filterClusterAlbumsToLibraryScope(
const restrictByServer = new Map<string, Set<string>>();
await Promise.all(
scopedMembers.map(async sid => {
const ids = await resolveScopedAlbumRestrictIds(sid);
if (ids?.length) restrictByServer.set(sid, new Set(ids));
const allowlist = await resolveScopedAlbumAllowlist(sid);
if (allowlist?.size) restrictByServer.set(sid, allowlist);
}),
);
if (restrictByServer.size === 0) return albums;
return albums.filter(a => {
const seedServerId = a.clusterSeedServerId;
if (!seedServerId) return true;
if (!seedServerId || !scopedMembers.includes(seedServerId)) return false;
const allowed = restrictByServer.get(seedServerId);
if (!allowed) return true;
return allowed.has(a.id);
return allowed?.has(a.id) ?? false;
});
}
+12
View File
@@ -3,6 +3,7 @@ import type { SubsonicAlbum } from '../../api/subsonicTypes';
import {
albumBrowseHasGenreFilter,
albumBrowseHasServerFilters,
albumBrowseIsPureLossless,
albumBrowseMultiGenreBrowse,
albumBrowseStarredNeedsLocalIntersect,
albumBrowseUseSliceCatalog,
@@ -43,6 +44,16 @@ describe('albumBrowseLoad', () => {
expect(albumBrowseHasGenreFilter({ ...base, genres: ['Rock'] })).toBe(true);
});
it('detects pure lossless browse', () => {
expect(albumBrowseIsPureLossless({ ...base, losslessOnly: true })).toBe(true);
expect(albumBrowseIsPureLossless({ ...base, losslessOnly: true, year: { from: 1990 } })).toBe(
false,
);
expect(albumBrowseIsPureLossless({ ...base, losslessOnly: true, genres: ['Rock'] })).toBe(
false,
);
});
it('slice catalog only for plain browse', () => {
expect(albumBrowseUseSliceCatalog(base)).toBe(true);
expect(albumBrowseUseSliceCatalog({ ...base, compFilter: 'only' })).toBe(true);
@@ -50,6 +61,7 @@ describe('albumBrowseLoad', () => {
expect(albumBrowseUseSliceCatalog({ ...base, year: { from: 1990 } })).toBe(false);
expect(albumBrowseUseSliceCatalog({ ...base, losslessOnly: true })).toBe(false);
expect(albumBrowseUseSliceCatalog({ ...base, starredOnly: true })).toBe(false);
expect(albumBrowseUseSliceCatalog(base, true)).toBe(false);
});
it('multi-genre disables offset pagination', () => {
+9 -1
View File
@@ -12,6 +12,7 @@ export type {
export {
albumBrowseHasGenreFilter,
albumBrowseHasServerFilters,
albumBrowseIsPureLossless,
albumBrowseMultiGenreBrowse,
albumBrowseUseSliceCatalog,
filterAlbumsByCompilation,
@@ -19,7 +20,11 @@ export {
} from './albumBrowseFilters';
export { runLocalAlbumBrowse } from './albumBrowseLocal';
import { albumBrowseHasServerFilters, countGenresFromAlbums, filterAlbumsByCompilation } from './albumBrowseFilters';
import {
albumBrowseHasServerFilters,
countGenresFromAlbums,
filterAlbumsByCompilation,
} from './albumBrowseFilters';
import { runLocalAlbumBrowse } from './albumBrowseLocal';
import { fetchAlbumBrowseNetwork } from './albumBrowseNetwork';
import { fetchStarredAlbumBrowse } from './albumBrowseStarredFetch';
@@ -113,6 +118,9 @@ export async function fetchAlbumBrowsePage(
if (indexEnabled && serverId) {
const local = await runLocalAlbumBrowse(serverId, query, offset, pageSize);
if (local != null) return local;
if (albumBrowseHasServerFilters(query)) {
return { albums: [], hasMore: false };
}
}
return fetchAlbumBrowseNetwork(query, offset, pageSize);
+7 -7
View File
@@ -2,10 +2,8 @@ import type { SubsonicAlbum } from '../../api/subsonicTypes';
import { dedupeById } from '../dedupeById';
import { albumToAlbum } from './advancedSearchLocal';
import { sharedServerFilters } from './albumBrowseFilters';
import {
finalizeClusterAlbumBrowse,
searchSingleServerAlbumBrowse,
} from './albumBrowseExecution';
import { searchSingleServerAlbumBrowse } from './albumBrowseExecution';
import { filterClusterAlbumsToLibraryScope } from './albumBrowseLibraryScope';
import { albumSortClauses, sortSubsonicAlbums } from './albumBrowseSort';
import { libraryIsReady } from './libraryReady';
import type { AlbumBrowsePageResult, AlbumBrowseQuery } from './albumBrowseTypes';
@@ -39,7 +37,7 @@ async function runClusterAlbumBrowse(
const sort = albumSortClauses(query.sort);
const finish = async (albums: SubsonicAlbum[], hasMore: boolean) => {
let out = await finalizeClusterAlbumBrowse(albums);
let out = await filterClusterAlbumsToLibraryScope(albums);
if (useServerStarredIds) out = markServerStarredAlbums(out);
return { albums: out, hasMore };
};
@@ -64,7 +62,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 finalizeClusterAlbumBrowse(merged), query.sort),
albums: sortSubsonicAlbums((await finish(merged, false)).albums, query.sort),
hasMore: false,
};
}
@@ -85,9 +83,11 @@ async function runClusterAlbumBrowse(
skipTotals: true,
});
if (!resp) return { albums: [], hasMore: false };
return finish(resp.albums.map(albumToAlbum), resp.albums.length === pageSize);
return await finish(resp.albums.map(albumToAlbum), resp.albums.length === pageSize);
}
/** Local index: layered filters — see `albumBrowseExecution.ts`. */
export async function runLocalAlbumBrowse(
serverId: string,
@@ -3,6 +3,7 @@ import {
type LibraryAdvancedSearchResponse,
type LibraryClusterAdvancedSearchRequest,
} from '../../api/library';
import { buildClusterRestrictAlbumScopes } from './albumBrowseLibraryScope';
import { resolveClusterBrowseMembers } from '../serverCluster/clusterBrowse';
import { buildClusterLibraryScopes } from '../serverCluster/clusterLibraryScopes';
import { isClusterMode } from '../serverCluster/clusterScope';
@@ -14,10 +15,12 @@ export async function clusterAdvancedSearchLocal(
const members = await resolveClusterBrowseMembers();
if (!members?.length) return null;
try {
const restrictAlbumScopes = await buildClusterRestrictAlbumScopes(members);
return await libraryClusterAdvancedSearch({
...request,
serversOrdered: members,
libraryScopes: buildClusterLibraryScopes(members),
restrictAlbumScopes,
});
} catch {
return null;
+6 -1
View File
@@ -15,6 +15,7 @@ import { dedupeById } from '../dedupeById';
import { albumToAlbum, artistToArtist, trackToSong } from '../library/advancedSearchLocal';
import { albumBrowseHasServerFilters } from '../library/albumBrowseFilters';
import type { AlbumBrowsePageResult, AlbumBrowseQuery } from '../library/albumBrowseTypes';
import { filterClusterAlbumsToLibraryScope } from '../library/albumBrowseLibraryScope';
import { buildClusterLibraryScopes, isClusterLibraryScopeNarrowed } from './clusterLibraryScopes';
import { getActiveClusterId, isClusterMode } from './clusterScope';
import { getClusterMergeMemberIds } from './representative';
@@ -80,8 +81,12 @@ export async function clusterBrowseAlbumsPage(
offset,
libraryScopes: buildClusterLibraryScopes(members),
});
let albums = resp.albums.map(albumToAlbum);
if (isClusterLibraryScopeNarrowed()) {
albums = await filterClusterAlbumsToLibraryScope(albums);
}
return {
albums: resp.albums.map(albumToAlbum),
albums,
hasMore: resp.hasMore,
};
} catch {