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