mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-12 05:10:52 +02:00
add primary artists filter
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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[],
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user