mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
This commit is contained in:
@@ -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)',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user