From 971dea8d536b3f034439074d1fce105043b657fb Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Mon, 13 Jul 2026 14:40:33 -0400 Subject: [PATCH] add primary artists filter --- package.json | 1 + src/app/(tabs)/library/index.tsx | 12 +++- src/components/settings/SettingsPanels.tsx | 15 +++++ src/library/artistGrouping.test.mts | 64 ++++++++++++++++++++++ src/library/artistGrouping.ts | 36 ++++++++++-- src/stores/libraryStore.ts | 33 ++++++++--- src/types/library.ts | 2 + 7 files changed, 147 insertions(+), 16 deletions(-) create mode 100644 src/library/artistGrouping.test.mts diff --git a/package.json b/package.json index da1b967..f34e565 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/(tabs)/library/index.tsx b/src/app/(tabs)/library/index.tsx index 761abc1..6b2e58a 100644 --- a/src/app/(tabs)/library/index.tsx +++ b/src/app/(tabs)/library/index.tsx @@ -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. diff --git a/src/components/settings/SettingsPanels.tsx b/src/components/settings/SettingsPanels.tsx index 29f45dc..48f698b 100644 --- a/src/components/settings/SettingsPanels.tsx +++ b/src/components/settings/SettingsPanels.tsx @@ -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' ? ( + + + + ) : null} ); @@ -546,6 +558,9 @@ const useStyles = createThemedStyles((colors) => ({ cardSpacing: { marginTop: spacing.sm, }, + toggleSpacing: { + marginTop: spacing.lg, + }, indent: { marginTop: spacing.sm, }, diff --git a/src/library/artistGrouping.test.mts b/src/library/artistGrouping.test.mts new file mode 100644 index 0000000..fc2f63e --- /dev/null +++ b/src/library/artistGrouping.test.mts @@ -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 & Pick +): 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); +}); diff --git a/src/library/artistGrouping.ts b/src/library/artistGrouping.ts index 7897fd9..6eec910 100644 --- a/src/library/artistGrouping.ts +++ b/src/library/artistGrouping.ts @@ -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(); 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[], diff --git a/src/stores/libraryStore.ts b/src/stores/libraryStore.ts index 6234265..7228e06 100644 --- a/src/stores/libraryStore.ts +++ b/src/stores/libraryStore.ts @@ -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; removeFolder: (folderId: number) => Promise; rescan: () => Promise; @@ -149,6 +152,7 @@ export const useLibraryStore = create((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((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((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) => { diff --git a/src/types/library.ts b/src/types/library.ts index 1b5461d..5ef8c7a 100644 --- a/src/types/library.ts +++ b/src/types/library.ts @@ -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. */