fix(albums): All Albums compilation and favorites filters (#1143) (#1151)

This commit is contained in:
cucadmuh
2026-06-22 02:54:41 +03:00
committed by GitHub
parent d162c2557b
commit 9323f5fee6
11 changed files with 245 additions and 13 deletions
+1
View File
@@ -171,6 +171,7 @@ const CONTRIBUTOR_ENTRIES = [
'Play queue sync — manual pull via connection indicator, idle auto-pull, multi-server push filter, flush-on-server-switch (PR #1131)',
'Niri compositor tiling WM detection (PR #1127)',
'Local library index: Navidrome ignored-articles artist/composer letter buckets (name_sort + server ignoredArticles), idempotent migration with safe open/swap and poisoned-lock recovery (PR #1145)',
'All Albums browse: compilation and favorites filters in slice mode — skip redundant client comp filter, route favorites through getStarred2, pre-index page scan, offline isCompilation from tracks (PR #1151)',
],
},
{
+10 -12
View File
@@ -13,16 +13,15 @@ import {
} from '../cover/coverTraffic';
import { coverEnsureQueueBacklog, coverEnsureResumePump, coverEnsureSubscribeBacklogDrain } from '../cover/ensureQueue';
import { dedupeById } from '../utils/dedupeById';
import { albumBrowseCompScanComplete } from '../utils/library/albumCompilation';
import { albumBrowseCompScanComplete, albumBrowseCompFilterClientOnly } from '../utils/library/albumCompilation';
import type { AlbumCompFilter } from '../utils/library/albumCompilation';
import {
albumBrowseHasGenreFilter,
albumBrowseHasServerFilters,
applyAlbumBrowseClientFilters,
fetchAlbumBrowseGenreOptions,
fetchAlbumBrowsePage,
fetchLocalAlbumCatalogChunk,
filterAlbumsByCompilation,
filterAlbumsByStarred,
type AlbumBrowseQuery,
type GenreFilterOption,
} from '../utils/library/albumBrowseLoad';
@@ -137,15 +136,12 @@ export function useAlbumBrowseData({
}), [sort, yearFilterActive, yearFilterBounds, losslessOnly, starredOnly, compFilter]);
const compFilterActive = compFilter !== 'all';
const compFilterClientOnly = compFilterActive && !indexEnabled;
const compFilterClientOnly = albumBrowseCompFilterClientOnly(compFilter, browseMode);
const visibleAlbums = useMemo(() => {
let out = compFilterActive
? filterAlbumsByCompilation(albums, compFilter)
: albums;
if (starredOnly) out = filterAlbumsByStarred(out, starredOverrides);
return out;
}, [albums, compFilter, compFilterActive, starredOnly, starredOverrides]);
const visibleAlbums = useMemo(
() => applyAlbumBrowseClientFilters(albums, browseQuery, starredOverrides, browseMode),
[albums, browseQuery, starredOverrides, browseMode],
);
const {
visibleCount,
@@ -243,6 +239,7 @@ export function useAlbumBrowseData({
try {
const chunk = await fetchAlbumBrowseCatalogChunk(
serverId,
indexEnabled,
query,
offset,
CATALOG_CHUNK_SIZE,
@@ -261,7 +258,7 @@ export function useAlbumBrowseData({
setCatalogLoadingMore(false);
}
}
}, [offlineBrowseActive, serverId, starredOverrides]);
}, [indexEnabled, offlineBrowseActive, serverId, starredOverrides]);
const loadBrowse = useCallback(async (
query: AlbumBrowseQuery,
@@ -359,6 +356,7 @@ export function useAlbumBrowseData({
try {
const first = await fetchLocalAlbumCatalogChunk(
serverId,
indexEnabled,
browseQuery,
0,
CATALOG_CHUNK_SIZE,
@@ -0,0 +1,61 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { AlbumBrowseQuery } from './albumBrowseTypes';
const runLocalAlbumBrowse = vi.fn();
const fetchStarredAlbumBrowse = vi.fn();
vi.mock('./albumBrowseLocal', () => ({
runLocalAlbumBrowse: (...args: unknown[]) => runLocalAlbumBrowse(...args),
}));
vi.mock('./albumBrowseStarredFetch', () => ({
fetchStarredAlbumBrowse: (...args: unknown[]) => fetchStarredAlbumBrowse(...args),
}));
vi.mock('./albumBrowseNetwork', () => ({
fetchAlbumBrowseNetwork: vi.fn(),
}));
const { fetchLocalAlbumCatalogChunk } = await import('./albumBrowseLoad');
describe('fetchLocalAlbumCatalogChunk', () => {
const base: AlbumBrowseQuery = {
sort: 'alphabeticalByName',
genres: [],
losslessOnly: false,
starredOnly: false,
compFilter: 'all',
};
beforeEach(() => {
runLocalAlbumBrowse.mockReset();
fetchStarredAlbumBrowse.mockReset();
runLocalAlbumBrowse.mockResolvedValue({ albums: [], hasMore: false });
fetchStarredAlbumBrowse.mockResolvedValue({ albums: [], hasMore: false });
});
it('routes starredOnly through fetchStarredAlbumBrowse, not runLocalAlbumBrowse', async () => {
await fetchLocalAlbumCatalogChunk(
's1',
true,
{ ...base, starredOnly: true },
0,
50,
);
expect(fetchStarredAlbumBrowse).toHaveBeenCalledWith('s1', true, expect.objectContaining({
starredOnly: true,
}), 0, 50, undefined);
expect(runLocalAlbumBrowse).not.toHaveBeenCalled();
});
it('uses runLocalAlbumBrowse for non-starred catalog chunks', async () => {
await fetchLocalAlbumCatalogChunk('s1', true, { ...base, compFilter: 'only' }, 0, 50);
expect(runLocalAlbumBrowse).toHaveBeenCalledWith(
's1',
expect.objectContaining({ compFilter: 'only' }),
0,
50,
);
expect(fetchStarredAlbumBrowse).not.toHaveBeenCalled();
});
});
+2 -1
View File
@@ -25,6 +25,7 @@ export function mergeAlbumCatalogChunk(
/** Local-index or offline-bytes catalog chunk for the albums grid. */
export async function fetchAlbumBrowseCatalogChunk(
serverId: string,
indexEnabled: boolean,
query: AlbumBrowseQuery,
offset: number,
chunkSize: number,
@@ -39,5 +40,5 @@ export async function fetchAlbumBrowseCatalogChunk(
starredOverrides,
);
}
return fetchLocalAlbumCatalogChunk(serverId, query, offset, chunkSize);
return fetchLocalAlbumCatalogChunk(serverId, indexEnabled, query, offset, chunkSize);
}
+34
View File
@@ -62,6 +62,17 @@ export function filterAlbumsByStarred(
});
}
/** Slice favorites grid: fetch is authoritative; only apply optimistic star/unstar overrides. */
export function applyStarredOverridesInSlice(
albums: SubsonicAlbum[],
starredOverrides: Record<string, boolean>,
): SubsonicAlbum[] {
return albums.filter(a => {
if (a.id in starredOverrides) return starredOverrides[a.id];
return true;
});
}
export function filterAlbumsByYearBounds(
albums: SubsonicAlbum[],
bounds: AlbumYearBounds,
@@ -83,6 +94,29 @@ export function filterAlbumsByCompilation(
return albums;
}
/**
* Client post-filters for the All Albums grid. Slice mode (local index or offline
* catalog) already applied compilation / favorites in SQL or `applyAlbumBrowseQuery`.
*/
export function applyAlbumBrowseClientFilters(
albums: SubsonicAlbum[],
query: Pick<AlbumBrowseQuery, 'compFilter' | 'starredOnly'>,
starredOverrides: Record<string, boolean>,
browseMode: 'slice' | 'page',
): SubsonicAlbum[] {
const fetchAlreadyFiltered = browseMode === 'slice';
let out = albums;
if (query.compFilter !== 'all' && !fetchAlreadyFiltered) {
out = filterAlbumsByCompilation(out, query.compFilter);
}
if (query.starredOnly && fetchAlreadyFiltered) {
out = applyStarredOverridesInSlice(out, starredOverrides);
} else if (query.starredOnly) {
out = filterAlbumsByStarred(out, starredOverrides);
}
return out;
}
export function filterAlbumsByGenres(
albums: SubsonicAlbum[],
genres: string[],
+55
View File
@@ -4,12 +4,14 @@ import {
albumBrowseHasGenreFilter,
albumBrowseHasServerFilters,
albumBrowseStarredNeedsLocalIntersect,
applyAlbumBrowseClientFilters,
compilationFilterClauses,
countGenresFromAlbums,
filterAlbumsByNameTextQuery,
filterAlbumsByStarred,
filterAlbumsByYearBounds,
} from './albumBrowseFilters';
import { albumBrowseCompFilterClientOnly } from './albumCompilation';
import type { AlbumBrowseQuery } from './albumBrowseTypes';
describe('albumBrowseLoad', () => {
@@ -95,6 +97,59 @@ describe('compilationFilterClauses', () => {
});
});
describe('applyAlbumBrowseClientFilters', () => {
const base: AlbumBrowseQuery = {
sort: 'alphabeticalByName',
genres: [],
losslessOnly: false,
starredOnly: false,
compFilter: 'all',
};
const sqlMatchedComp: SubsonicAlbum = {
id: 'c1',
name: 'Greatest Hits',
artist: 'Various Artists',
artistId: 'va',
songCount: 12,
duration: 3600,
};
const studio: SubsonicAlbum = {
id: 'r1',
name: 'Studio',
artist: 'Band',
artistId: 'b',
songCount: 8,
duration: 2400,
};
it('does not re-filter compilations in slice mode after SQL pre-filter', () => {
const query = { ...base, compFilter: 'only' as const };
expect(applyAlbumBrowseClientFilters([sqlMatchedComp, studio], query, {}, 'slice')).toHaveLength(2);
});
it('filters compilations client-side in page (network) mode', () => {
const query = { ...base, compFilter: 'only' as const };
expect(applyAlbumBrowseClientFilters(
[{ ...sqlMatchedComp, isCompilation: true }, studio],
query,
{},
'page',
)).toEqual([{ ...sqlMatchedComp, isCompilation: true }]);
});
it('does not re-filter favorites in slice mode', () => {
const query = { ...base, starredOnly: true };
const starred = { ...studio, starred: '2024-01-01' };
expect(applyAlbumBrowseClientFilters([starred], query, {}, 'slice')).toHaveLength(1);
});
it('applies optimistic unstar overrides in slice favorites mode', () => {
const query = { ...base, starredOnly: true };
const starred = { ...studio, starred: '2024-01-01' };
expect(applyAlbumBrowseClientFilters([starred], query, { r1: false }, 'slice')).toHaveLength(0);
});
});
describe('countGenresFromAlbums', () => {
const album = (id: string, genre?: string): SubsonicAlbum => ({
id,
+5
View File
@@ -12,6 +12,7 @@ export type {
export {
albumBrowseHasGenreFilter,
albumBrowseHasServerFilters,
applyAlbumBrowseClientFilters,
filterAlbumsByCompilation,
filterAlbumsByStarred,
} from './albumBrowseFilters';
@@ -35,10 +36,14 @@ import { GENRE_ALBUM_FETCH_LIMIT } from './albumBrowseTypes';
/** One local-index chunk for lazy catalog loading (All Albums slice mode). */
export async function fetchLocalAlbumCatalogChunk(
serverId: string,
indexEnabled: boolean,
query: AlbumBrowseQuery,
offset: number,
chunkSize: number,
): Promise<AlbumBrowsePageResult | null> {
if (query.starredOnly) {
return fetchAlbumBrowsePage(serverId, indexEnabled, query, offset, chunkSize);
}
const singleGenre = query.genres.length === 1;
if (query.genres.length > 1 && offset > 0) {
return { albums: [], hasMore: false };
@@ -1,8 +1,10 @@
import { describe, expect, it } from 'vitest';
import type { SubsonicAlbum } from '../../api/subsonicTypes';
import {
albumBrowseCompFilterClientOnly,
albumBrowseCompScanComplete,
albumIsCompilation,
albumIsCompilationFromTrackDtos,
ALBUM_COMP_FILTER_MAX_SCAN_ALBUMS,
} from './albumCompilation';
import { filterAlbumsByCompilation } from './albumBrowseFilters';
@@ -57,3 +59,35 @@ describe('albumBrowseCompScanComplete', () => {
expect(albumBrowseCompScanComplete([album()], 'only', true)).toBe(false);
});
});
describe('albumBrowseCompFilterClientOnly', () => {
it('matches page mode only', () => {
expect(albumBrowseCompFilterClientOnly('only', 'page')).toBe(true);
expect(albumBrowseCompFilterClientOnly('only', 'slice')).toBe(false);
});
});
describe('albumIsCompilationFromTrackDtos', () => {
it('detects compilation flag in track rawJson', () => {
expect(albumIsCompilationFromTrackDtos([{
serverId: 's1',
id: 't1',
title: 'Hit',
album: 'Comp Album',
albumId: 'al1',
durationSec: 1,
syncedAt: 1,
rawJson: { compilation: true },
}])).toBe(true);
expect(albumIsCompilationFromTrackDtos([{
serverId: 's1',
id: 't2',
title: 'Song',
album: 'Studio',
albumId: 'al2',
durationSec: 1,
syncedAt: 1,
rawJson: {},
}])).toBe(false);
});
});
+32
View File
@@ -1,7 +1,11 @@
import type { LibraryTrackDto } from '../../api/library';
import type { SubsonicAlbum } from '../../api/subsonicTypes';
export type AlbumCompFilter = 'all' | 'only' | 'hide';
const isObject = (v: unknown): v is Record<string, unknown> =>
typeof v === 'object' && v !== null && !Array.isArray(v);
/** Max albums to scan client-side for compilation filter before showing empty. */
export const ALBUM_COMP_FILTER_MAX_SCAN_ALBUMS = 500;
@@ -21,6 +25,34 @@ export function albumIsCompilation(a: SubsonicAlbum): boolean {
|| VARIOUS_ARTISTS.test(albumArtist);
}
/** Any track in a grouped album matches compilation signals (offline / local aggregate). */
export function albumIsCompilationFromTrackDtos(tracks: LibraryTrackDto[]): boolean {
for (const t of tracks) {
const raw = isObject(t.rawJson) ? t.rawJson : {};
const loose = raw as Partial<SubsonicAlbum> & { compilation?: boolean; albumArtist?: string };
const probe: SubsonicAlbum = {
id: t.albumId ?? '',
name: t.album ?? '',
artist: t.albumArtist ?? t.artist ?? '',
artistId: t.artistId ?? '',
songCount: 0,
duration: 0,
...loose,
displayArtist: typeof loose.displayArtist === 'string' ? loose.displayArtist : undefined,
};
if (albumIsCompilation(probe)) return true;
}
return false;
}
/** Network page mode: compilation filter runs client-side on each getAlbumList2 page. */
export function albumBrowseCompFilterClientOnly(
compFilter: AlbumCompFilter,
browseMode: 'slice' | 'page',
): boolean {
return compFilter !== 'all' && browseMode === 'page';
}
/** Stop paginating when the catalog tail is reached or the scan budget is spent. */
export function albumBrowseCompScanComplete(
loadedAlbums: SubsonicAlbum[],
+3
View File
@@ -10,6 +10,7 @@ import {
resolveTrackCoverArtId,
trackToSong,
} from '../library/advancedSearchLocal';
import { albumIsCompilationFromTrackDtos } from '../library/albumCompilation';
import {
filterAlbumsByCompilation,
filterAlbumsByGenres,
@@ -60,6 +61,7 @@ export function buildAlbumFromTracks(
const songs = tracks.map(trackToSong).map(s => ({ ...s, serverId }));
const first = tracks[0];
const starred = tracks.some(t => t.starredAt != null);
const isCompilation = albumIsCompilationFromTrackDtos(tracks);
return {
id: albumId,
name: first.album ?? albumId,
@@ -71,6 +73,7 @@ export function buildAlbumFromTracks(
songCount: songs.length,
duration: songs.reduce((sum, s) => sum + (s.duration ?? 0), 0),
starred: starred ? new Date().toISOString() : undefined,
isCompilation: isCompilation || undefined,
serverId,
};
}