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
+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 {