mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +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:
@@ -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