From 9323f5fee678479bc669f9d144095c4f20c08c70 Mon Sep 17 00:00:00 2001 From: cucadmuh <49571317+cucadmuh@users.noreply.github.com> Date: Mon, 22 Jun 2026 02:54:41 +0300 Subject: [PATCH] fix(albums): All Albums compilation and favorites filters (#1143) (#1151) --- CHANGELOG.md | 8 +++ src/config/settingsCredits.ts | 1 + src/hooks/useAlbumBrowseData.ts | 22 +++---- .../library/albumBrowseCatalogChunk.test.ts | 61 +++++++++++++++++++ src/utils/library/albumBrowseCatalogChunk.ts | 3 +- src/utils/library/albumBrowseFilters.ts | 34 +++++++++++ src/utils/library/albumBrowseLoad.test.ts | 55 +++++++++++++++++ src/utils/library/albumBrowseLoad.ts | 5 ++ src/utils/library/albumCompilation.test.ts | 34 +++++++++++ src/utils/library/albumCompilation.ts | 32 ++++++++++ src/utils/offline/offlineLocalBrowse.ts | 3 + 11 files changed, 245 insertions(+), 13 deletions(-) create mode 100644 src/utils/library/albumBrowseCatalogChunk.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d8b581f5..6db3da5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/config/settingsCredits.ts b/src/config/settingsCredits.ts index 95e5de01..b31d43f0 100644 --- a/src/config/settingsCredits.ts +++ b/src/config/settingsCredits.ts @@ -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)', ], }, { diff --git a/src/hooks/useAlbumBrowseData.ts b/src/hooks/useAlbumBrowseData.ts index 1550246c..346954c9 100644 --- a/src/hooks/useAlbumBrowseData.ts +++ b/src/hooks/useAlbumBrowseData.ts @@ -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, diff --git a/src/utils/library/albumBrowseCatalogChunk.test.ts b/src/utils/library/albumBrowseCatalogChunk.test.ts new file mode 100644 index 00000000..339f90ea --- /dev/null +++ b/src/utils/library/albumBrowseCatalogChunk.test.ts @@ -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(); + }); +}); diff --git a/src/utils/library/albumBrowseCatalogChunk.ts b/src/utils/library/albumBrowseCatalogChunk.ts index 9f915a54..aa5d2e19 100644 --- a/src/utils/library/albumBrowseCatalogChunk.ts +++ b/src/utils/library/albumBrowseCatalogChunk.ts @@ -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); } diff --git a/src/utils/library/albumBrowseFilters.ts b/src/utils/library/albumBrowseFilters.ts index 7fe5505f..bcba8796 100644 --- a/src/utils/library/albumBrowseFilters.ts +++ b/src/utils/library/albumBrowseFilters.ts @@ -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, +): 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, + starredOverrides: Record, + 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[], diff --git a/src/utils/library/albumBrowseLoad.test.ts b/src/utils/library/albumBrowseLoad.test.ts index cb23945d..2cbc75e4 100644 --- a/src/utils/library/albumBrowseLoad.test.ts +++ b/src/utils/library/albumBrowseLoad.test.ts @@ -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, diff --git a/src/utils/library/albumBrowseLoad.ts b/src/utils/library/albumBrowseLoad.ts index 779ad6db..c01da923 100644 --- a/src/utils/library/albumBrowseLoad.ts +++ b/src/utils/library/albumBrowseLoad.ts @@ -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 { + 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 }; diff --git a/src/utils/library/albumCompilation.test.ts b/src/utils/library/albumCompilation.test.ts index 638a2979..1835257e 100644 --- a/src/utils/library/albumCompilation.test.ts +++ b/src/utils/library/albumCompilation.test.ts @@ -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); + }); +}); diff --git a/src/utils/library/albumCompilation.ts b/src/utils/library/albumCompilation.ts index a8510723..6335508a 100644 --- a/src/utils/library/albumCompilation.ts +++ b/src/utils/library/albumCompilation.ts @@ -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 => + 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 & { 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[], diff --git a/src/utils/offline/offlineLocalBrowse.ts b/src/utils/offline/offlineLocalBrowse.ts index 32fe8845..cda28336 100644 --- a/src/utils/offline/offlineLocalBrowse.ts +++ b/src/utils/offline/offlineLocalBrowse.ts @@ -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, }; }