add primary artists filter

This commit is contained in:
Boof2015
2026-07-13 14:40:33 -04:00
parent e1559d2f50
commit 971dea8d53
7 changed files with 147 additions and 16 deletions
+1
View File
@@ -69,6 +69,7 @@
"test:desktop-remote": "node --experimental-strip-types --test src/services/desktopRemotePairing.test.mts",
"test:dynamic-playlists": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/shared/playlists/dynamicPlaylist.test.mts src/db/dynamicPlaylistSql.test.mts",
"test:album-grouping": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/shared/library/albumGrouping.test.mts src/shared/library/albumEligibility.test.mts src/library/albumIdentity.test.mts src/library/albumSummary.test.mts",
"test:artist-grouping": "node --experimental-strip-types --test src/library/artistGrouping.test.mts",
"test:desktop-sync": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/library/importMatching.test.mts src/services/desktopSyncPlaylistMerge.test.mts src/shared/sync/conflictPreview.test.mts",
"test:eq-share": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/audio/eqShare.test.mts",
"test:eq-math": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/audio/eq.test.mts",
+10 -2
View File
@@ -43,6 +43,7 @@ import { useRipple } from '@/theme/ripple';
import { useLibraryStore } from '@/stores/libraryStore';
import { usePlayerStore } from '@/stores/playerStore';
import { useSearchStore } from '@/stores/searchStore';
import { useSettingsStore } from '@/stores/settingsStore';
import {
enqueueEndMany,
enqueueTopMany,
@@ -66,6 +67,7 @@ import {
type ArtistSort
} from '@/lib/artistSort';
import { buildLetterIndex, resolveJumpIndex } from '@/lib/letterIndex';
import { filterArtistBrowseList } from '@/library/artistGrouping';
import type {
Album,
Artist,
@@ -92,6 +94,8 @@ export default function LibraryScreen() {
const setAlbumSort = useLibraryStore((s) => s.setAlbumSort);
const artistSort = useLibraryStore((s) => s.artistSort);
const setArtistSort = useLibraryStore((s) => s.setArtistSort);
const includeCollabArtists = useLibraryStore((s) => s.includeCollabArtists);
const artistGroupingMode = useSettingsStore((s) => s.artistGroupingMode);
const isScanning = useLibraryStore((s) => s.isScanning);
const scanError = useLibraryStore((s) => s.scanError);
const currentPath = usePlayerStore((s) => s.currentTrack?.path);
@@ -118,9 +122,13 @@ export default function LibraryScreen() {
() => (viewMode === 'albums' ? sortAlbums(albums, albumSort) : []),
[albumSort, albums, viewMode]
);
const visibleArtists = useMemo(
() => filterArtistBrowseList(artists, artistGroupingMode, includeCollabArtists),
[artistGroupingMode, artists, includeCollabArtists]
);
const sortedArtists = useMemo(
() => (viewMode === 'artists' ? sortArtists(artists, artistSort) : []),
[artistSort, artists, viewMode]
() => (viewMode === 'artists' ? sortArtists(visibleArtists, artistSort) : []),
[artistSort, viewMode, visibleArtists]
);
// Tap index is within sortedTracks so the tapped row is the track that plays.
@@ -315,6 +315,8 @@ export function LibrarySettingsPanel() {
const setArtistGroupingMode = useSettingsStore((s) => s.setArtistGroupingMode);
const includeSingles = useSettingsStore((s) => s.includeSingles);
const setIncludeSingles = useSettingsStore((s) => s.setIncludeSingles);
const includeCollabArtists = useLibraryStore((s) => s.includeCollabArtists);
const setIncludeCollabArtists = useLibraryStore((s) => s.setIncludeCollabArtists);
return (
<>
@@ -369,6 +371,16 @@ export function LibrarySettingsPanel() {
value={includeSingles}
onValueChange={(v) => void setIncludeSingles(v)}
/>
{groupingMode === 'astra' ? (
<View style={styles.toggleSpacing}>
<SettingsToggleRow
title="Show collaborator-only artists"
description="Include artists that appear only as collaborators in the Artists view."
value={includeCollabArtists}
onValueChange={setIncludeCollabArtists}
/>
</View>
) : null}
</SettingsCard>
</>
);
@@ -546,6 +558,9 @@ const useStyles = createThemedStyles((colors) => ({
cardSpacing: {
marginTop: spacing.sm,
},
toggleSpacing: {
marginTop: spacing.lg,
},
indent: {
marginTop: spacing.sm,
},
+64
View File
@@ -0,0 +1,64 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildArtistList,
filterArtistBrowseList,
type ArtistTrackLike,
} from './artistGrouping.ts';
let nextTimestamp = 1;
function createTrack(
overrides: Partial<ArtistTrackLike> & Pick<ArtistTrackLike, 'artist'>
): ArtistTrackLike {
const timestamp = nextTimestamp++;
return {
artist: overrides.artist,
album_artist: overrides.album_artist ?? null,
artwork_hash: overrides.artwork_hash ?? null,
year: overrides.year ?? null,
added_at: overrides.added_at ?? timestamp,
modified_at: overrides.modified_at ?? timestamp,
album_identity_key: overrides.album_identity_key ?? `album:${timestamp}`,
};
}
test('canonical artist records distinguish primary and collaborator-only artists', () => {
const artists = buildArtistList([
createTrack({ artist: 'Primary Artist feat. Guest Artist', album_identity_key: 'album:shared' }),
createTrack({ artist: 'Primary Artist', album_identity_key: 'album:shared' }),
], 'astra');
const primary = artists.find((artist) => artist.artist === 'Primary Artist');
const guest = artists.find((artist) => artist.artist === 'Guest Artist');
assert.ok(primary);
assert.equal(primary.track_count, 2);
assert.equal(primary.primary_track_count, 2);
assert.ok(guest);
assert.equal(guest.track_count, 1);
assert.equal(guest.primary_track_count, 0);
});
test('file-tags artist records count every indexed track as primary', () => {
const artists = buildArtistList([
createTrack({ artist: 'Primary Artist feat. Guest Artist' }),
createTrack({ artist: 'Primary Artist' }),
], 'fileTags');
assert.ok(artists.length > 0);
assert.ok(artists.every((artist) => artist.primary_track_count === artist.track_count));
});
test('artist browse filter defaults to primary artists and restores collab-only artists', () => {
const artists = buildArtistList([
createTrack({ artist: 'Primary Artist feat. Guest Artist' }),
], 'astra');
assert.deepEqual(
filterArtistBrowseList(artists, 'astra', false).map((artist) => artist.artist),
['Primary Artist']
);
assert.equal(filterArtistBrowseList(artists, 'astra', true), artists);
assert.equal(filterArtistBrowseList(artists, 'fileTags', false), artists);
});
+31 -5
View File
@@ -9,8 +9,9 @@
// so desktop's parsed-array paths collapse to splitCollaborators(artist) — which is
// the parsing heuristic. Everything here is derivable from artist + album_artist.
import { normalizeDisplay, normalizeKey, splitCollaborators } from '@/shared/library/albumGrouping';
import type { Artist, DbTrack } from '@/types/library';
// Runtime imports stay relative so this module can run under plain `node --test`.
import { normalizeDisplay, normalizeKey, splitCollaborators } from '../shared/library/albumGrouping.ts';
import type { Artist, DbTrack } from '../types/library';
// Shared with the album-identity port so artist and album grouping can never
// drift apart on normalization or collaborator splitting.
@@ -22,7 +23,7 @@ const UNKNOWN_ARTIST = 'Unknown Artist';
const VARIOUS_ARTISTS_KEY = 'various artists';
/** Track fields the grouping logic reads (subset of DbTrack, for testability). */
type ArtistTrackLike = Pick<
export type ArtistTrackLike = Pick<
DbTrack,
'artist' | 'album_artist' | 'artwork_hash' | 'year' | 'added_at' | 'modified_at' | 'album_identity_key'
>;
@@ -133,6 +134,7 @@ export function trackMatchesBrowseArtist(
interface ArtistAggregate {
artist: string;
track_count: number;
primary_track_count: number;
artwork_hash: string | null;
artworkYear: number;
artworkAddedAt: number;
@@ -151,6 +153,11 @@ export function buildArtistList(tracks: readonly ArtistTrackLike[], mode: Artist
const byKey = new Map<string, ArtistAggregate>();
for (const track of tracks) {
const primaryArtistKey = normalizeKey(
mode === 'fileTags'
? resolveStrictBrowseArtist(track)
: resolveCanonicalBrowseArtist(track)
);
const indexNames = mode === 'fileTags'
? [resolveStrictBrowseArtist(track)]
: getCanonicalArtistIndexNames(track);
@@ -166,6 +173,7 @@ export function buildArtistList(tracks: readonly ArtistTrackLike[], mode: Artist
aggregate = {
artist: name,
track_count: 0,
primary_track_count: 0,
artwork_hash: null,
artworkYear: -1,
artworkAddedAt: -1,
@@ -176,6 +184,7 @@ export function buildArtistList(tracks: readonly ArtistTrackLike[], mode: Artist
byKey.set(key, aggregate);
}
aggregate.track_count += 1;
if (key === primaryArtistKey) aggregate.primary_track_count += 1;
aggregate.albumKeys.add(track.album_identity_key);
if (!track.artwork_hash) continue;
@@ -198,18 +207,35 @@ export function buildArtistList(tracks: readonly ArtistTrackLike[], mode: Artist
}
return Array.from(byKey.values())
.map(({ artist, track_count, artwork_hash, albumKeys, albumArtwork }) => {
.map(({ artist, track_count, primary_track_count, artwork_hash, albumKeys, albumArtwork }) => {
// Primary artwork first, then one distinct cover per further album (max 4).
const artwork_hashes: string[] = artwork_hash ? [artwork_hash] : [];
for (const hash of albumArtwork.values()) {
if (artwork_hashes.length >= 4) break;
if (!artwork_hashes.includes(hash)) artwork_hashes.push(hash);
}
return { artist, track_count, artwork_hash, album_count: albumKeys.size, artwork_hashes };
return {
artist,
track_count,
primary_track_count,
artwork_hash,
album_count: albumKeys.size,
artwork_hashes,
};
})
.sort((a, b) => a.artist.localeCompare(b.artist, undefined, { sensitivity: 'base' }));
}
/** Apply the Artists-root collaborator preference without changing detail/search data. */
export function filterArtistBrowseList(
artists: Artist[],
mode: ArtistGroupingMode,
includeCollabArtists: boolean
): Artist[] {
if (mode !== 'astra' || includeCollabArtists) return artists;
return artists.filter((artist) => artist.primary_track_count > 0);
}
/** Tracks belonging to one artist under the active mode (preserves input order). */
export function filterTracksByArtist(
tracks: readonly DbTrack[],
+24 -9
View File
@@ -38,6 +38,7 @@ const VIEW_MODE_KEY = 'library_view_mode';
const TRACK_SORT_KEY = 'library_track_sort';
const ALBUM_SORT_KEY = 'library_album_sort';
const ARTIST_SORT_KEY = 'library_artist_sort';
const INCLUDE_COLLAB_ARTISTS_KEY = 'library_include_collab_artists';
// Bump when the album-identity algorithm changes to re-run the whole-library
// recompute at startup. '2' = the desktop three-tier grouping port (v15 schema).
@@ -94,6 +95,7 @@ interface LibraryStore {
trackSort: TrackSort;
albumSort: AlbumSort;
artistSort: ArtistSort;
includeCollabArtists: boolean;
isScanning: boolean;
scanProgress: ScanProgressState;
scanError: string | null;
@@ -107,6 +109,7 @@ interface LibraryStore {
setTrackSort: (sort: TrackSort) => void;
setAlbumSort: (sort: AlbumSort) => void;
setArtistSort: (sort: ArtistSort) => void;
setIncludeCollabArtists: (include: boolean) => void;
addFolder: () => Promise<void>;
removeFolder: (folderId: number) => Promise<void>;
rescan: () => Promise<void>;
@@ -149,6 +152,7 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
trackSort: 'title',
albumSort: 'name',
artistSort: 'name',
includeCollabArtists: false,
isScanning: false,
scanProgress: { ...IDLE_PROGRESS },
scanError: null,
@@ -172,25 +176,31 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
await setSetting(db, ALBUM_GROUPING_VERSION_KEY, ALBUM_GROUPING_VERSION);
}
// Restore view preferences before the first render of the library screen.
const [savedViewMode, savedTrackSort, savedAlbumSort, savedArtistSort] =
const [
savedViewMode,
savedTrackSort,
savedAlbumSort,
savedArtistSort,
savedIncludeCollabArtists,
] =
await Promise.all([
getSetting(db, VIEW_MODE_KEY),
getSetting(db, TRACK_SORT_KEY),
getSetting(db, ALBUM_SORT_KEY),
getSetting(db, ARTIST_SORT_KEY),
getSetting(db, INCLUDE_COLLAB_ARTISTS_KEY),
]);
const viewMode = parseViewMode(savedViewMode);
const trackSort = parseTrackSort(savedTrackSort);
const albumSort = parseAlbumSort(savedAlbumSort);
const artistSort = parseArtistSort(savedArtistSort);
if (viewMode || trackSort || albumSort || artistSort) {
set({
...(viewMode ? { viewMode } : null),
...(trackSort ? { trackSort } : null),
...(albumSort ? { albumSort } : null),
...(artistSort ? { artistSort } : null),
});
}
set({
...(viewMode ? { viewMode } : null),
...(trackSort ? { trackSort } : null),
...(albumSort ? { albumSort } : null),
...(artistSort ? { artistSort } : null),
includeCollabArtists: savedIncludeCollabArtists === 'true',
});
await get().refresh();
set({ initialized: true });
// One-time recovery: the v3 migration marks tracks stale (mtime = -1)
@@ -275,6 +285,11 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
persistSetting(ARTIST_SORT_KEY, artistSort);
},
setIncludeCollabArtists: (includeCollabArtists) => {
set({ includeCollabArtists });
persistSetting(INCLUDE_COLLAB_ARTISTS_KEY, includeCollabArtists ? 'true' : 'false');
},
addFolder: () => runScan(() => addFolderViaPicker({ onProgress })),
removeFolder: async (folderId) => {
+2
View File
@@ -81,6 +81,8 @@ export interface Album {
export interface Artist {
artist: string;
track_count: number;
/** Tracks where this artist is the resolved primary browse artist. */
primary_track_count: number;
artwork_hash: string | null;
album_count: number;
/** Primary hash first, then one distinct cover per further album (max 4) — grid mosaic. */