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
+8
View File
@@ -192,6 +192,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* After applying an AutoEQ headphone profile, the preset picker now shows the profile name under an AutoEQ group instead of going blank, and the delete button no longer appears for AutoEQ profiles (where it did nothing).
### All Albums — compilation and favorites filters
**By [@cucadmuh](https://github.com/cucadmuh), reported by [@bcorporaal](https://github.com/bcorporaal), PR [#1151](https://github.com/Psychotoxical/psysonic/pull/1151)**, closes [#1143](https://github.com/Psychotoxical/psysonic/issues/1143)
* **Only compilations** no longer shows a handful of albums after the local index already filtered them — slice mode skips the redundant client pass that dropped rows without `isCompilation` on the DTO.
* **Favorites** on All Albums uses the same `getStarred2` catalog path as the Favorites page instead of the empty sparse `album` table browse.
* Pre-index compilation filtering auto-paginates again in network page mode; offline library aggregates set `isCompilation` from track tags.
## [1.48.1] - 2026-06-15
## Fixed
+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,
};
}