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. */