diff --git a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/CatalogDatabase.kt b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/CatalogDatabase.kt index c1f1c2a..ecb3a9a 100644 --- a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/CatalogDatabase.kt +++ b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/CatalogDatabase.kt @@ -906,6 +906,10 @@ interface CatalogDao { groupingMode: String, ): List + // 'song' only: an album the artist merely guests on (a Various Artists + // compilation carrying one of their tracks) is not one of their albums. Those + // tracks are already reachable through the detail page's "Appears On" section, + // which reads the 'appearance' half of the same index. @Query( """ SELECT DISTINCT a.* @@ -916,6 +920,7 @@ interface CatalogDao { AND i.revision = :revision AND i.grouping_mode = :groupingMode AND i.artist_key = :artistKey + AND i.relationship = 'song' ORDER BY a.latest_added_at DESC, a.name_sort_key, a.identity_key LIMIT :limit OFFSET :offset """, @@ -936,6 +941,7 @@ interface CatalogDao { WHERE i.revision = :revision AND i.grouping_mode = :groupingMode AND i.artist_key = :artistKey + AND i.relationship = 'song' """, ) suspend fun countArtistAlbums( diff --git a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/CatalogReadModelBuilder.kt b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/CatalogReadModelBuilder.kt index 6f2d8c7..fad9f41 100644 --- a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/CatalogReadModelBuilder.kt +++ b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/CatalogReadModelBuilder.kt @@ -260,13 +260,20 @@ object CatalogReadModelBuilder { } else { canonicalArtistNames(track) } + val primaryKey = normalizeKey(primary) for (name in names) { val key = normalizeKey(name) if (key.isEmpty()) continue + val isPrimary = key == primaryKey val aggregate = aggregates.getOrPut(key) { Aggregate(name) } aggregate.trackCount += 1 - if (key == normalizeKey(primary)) aggregate.primaryCount += 1 - aggregate.albumKeys += row.identityKey + if (isPrimary) { + aggregate.primaryCount += 1 + // Albums the artist only guests on are not their albums; counting + // them here credited a Various Artists compilation to every featured + // performer. Artwork stays ungated so those artists keep a mosaic. + aggregate.albumKeys += row.identityKey + } val current = aggregate.artworkTrack if (track.artworkHash != null && (current == null || newerArtwork(track, current))) { aggregate.artworkTrack = track @@ -277,7 +284,7 @@ object CatalogReadModelBuilder { groupingMode = mode, artistKey = key, trackId = track.id, - relationship = if (key == normalizeKey(primary)) "song" else "appearance", + relationship = if (isPrimary) "song" else "appearance", ) } } diff --git a/src/app/(tabs)/library/artist/[name].tsx b/src/app/(tabs)/library/artist/[name].tsx index d258ff9..a459984 100644 --- a/src/app/(tabs)/library/artist/[name].tsx +++ b/src/app/(tabs)/library/artist/[name].tsx @@ -127,8 +127,14 @@ export default function ArtistScreen() { ]); const listItems = useMemo( - () => buildListItems(detail, songsPage.totalCount, appearancesPage.totalCount), - [appearancesPage.totalCount, detail, songsPage.totalCount] + () => + buildListItems( + detail, + albumsPage.totalCount, + songsPage.totalCount, + appearancesPage.totalCount + ), + [albumsPage.totalCount, appearancesPage.totalCount, detail, songsPage.totalCount] ); const playTrackListFrom = ( @@ -331,11 +337,13 @@ export default function ArtistScreen() { title={name} heroMeta={ - {(allPage.summary?.album_count ?? detail.albums.length) > 0 ? ( - + {/* + The album page's own count, not summary.album_count: the stored + summary counts albums the artist only appears on, and only settles + on the next rescan. + */} + {albumsPage.totalCount > 0 ? ( + ) : null} {detail.totalDuration > 0 ? ( @@ -376,6 +384,7 @@ export default function ArtistScreen() { function buildListItems( detail: ArtistDetail, + albumCount: number, songCount: number, appearanceCount: number ): ArtistPageItem[] { @@ -391,22 +400,26 @@ function buildListItems( key: 'section-albums', type: 'section', title: 'Albums', - trailing: formatCount(detail.albums.length, 'album'), + trailing: formatCount(albumCount, 'album'), target: 'albums', }); items.push({ key: 'albums', type: 'albums' }); } - items.push({ - key: 'section-songs', - type: 'section', - title: 'Songs', - trailing: formatCount(songCount, 'track'), - target: 'songs', - }); - detail.songTracks.slice(0, SONG_PREVIEW_LIMIT).forEach((track, index) => { - items.push({ key: `song-${track.id}`, type: 'track', track, section: 'songs', index }); - }); + // An artist who only guests on other people's albums has no songs of their + // own, and a bare "Songs · 0 tracks" header reads as a loading failure. + if (songCount > 0) { + items.push({ + key: 'section-songs', + type: 'section', + title: 'Songs', + trailing: formatCount(songCount, 'track'), + target: 'songs', + }); + detail.songTracks.slice(0, SONG_PREVIEW_LIMIT).forEach((track, index) => { + items.push({ key: `song-${track.id}`, type: 'track', track, section: 'songs', index }); + }); + } if (detail.showAppearances) { items.push({ diff --git a/src/library/artistDetail.ts b/src/library/artistDetail.ts index c67f7e6..8aa2456 100644 --- a/src/library/artistDetail.ts +++ b/src/library/artistDetail.ts @@ -2,7 +2,6 @@ import { filterTracksByArtist, normalizeKey, resolveCanonicalBrowseArtist, - splitAlbumArtistCollaborators, type ArtistGroupingMode, } from '@/library/artistGrouping'; import type { DbTrack } from '@/types/library'; @@ -60,14 +59,16 @@ export function buildArtistDetail( }; } +/** + * "Main" means the album is theirs, so the test is the canonical primary artist + * and nothing else — the same rule the native read model uses to mark an index + * row 'song' rather than 'appearance'. Matching on the raw track artist as well + * made a Various Artists compilation count as a main album for every featured + * performer on it. + */ function isMainArtistTrack(track: DbTrack, artistKey: string): boolean { if (!artistKey) return false; - if (normalizeKey(resolveCanonicalBrowseArtist(track)) === artistKey) return true; - if (normalizeKey(track.artist) === artistKey) return true; - if (normalizeKey(track.album_artist ?? '') === artistKey) return true; - return splitAlbumArtistCollaborators(track.album_artist ?? '').some( - (name) => normalizeKey(name) === artistKey - ); + return normalizeKey(resolveCanonicalBrowseArtist(track)) === artistKey; } function buildArtistAlbums(tracks: readonly DbTrack[]): ArtistAlbum[] { diff --git a/src/library/artistGrouping.test.mts b/src/library/artistGrouping.test.mts index 78be22c..45ad475 100644 --- a/src/library/artistGrouping.test.mts +++ b/src/library/artistGrouping.test.mts @@ -83,6 +83,45 @@ test('file-tags artist records count every indexed track as primary', () => { assert.ok(artists.every((artist) => artist.primary_track_count === artist.track_count)); }); +test('a compilation counts as an album only for its album artist', () => { + const compilation = { + album_artist: 'Various Artists', + album_identity_key: 'album:comp', + artwork_hash: 'comp-art', + }; + const artists = buildArtistList([ + createTrack({ artist: 'Yoko Takahashi', ...compilation }), + createTrack({ artist: 'Megumi Hayashibara', ...compilation }), + createTrack({ + artist: 'Yoko Takahashi', + album_artist: 'Yoko Takahashi', + album_identity_key: 'album:own', + artwork_hash: 'own-art', + }), + ], 'astra'); + + const byName = new Map(artists.map((artist) => [artist.artist, artist])); + + // Featured on the compilation, but it is not one of her albums — only the + // record she is the album artist of counts. Her track total still includes it. + const featured = byName.get('Yoko Takahashi'); + assert.ok(featured); + assert.equal(featured.track_count, 2); + assert.equal(featured.album_count, 1); + + // Nothing but the compilation: no albums, but the cover still feeds the mosaic. + const appearanceOnly = byName.get('Megumi Hayashibara'); + assert.ok(appearanceOnly); + assert.equal(appearanceOnly.track_count, 1); + assert.equal(appearanceOnly.primary_track_count, 0); + assert.equal(appearanceOnly.album_count, 0); + assert.deepEqual(appearanceOnly.artwork_hashes, ['comp-art']); + + const various = byName.get('Various Artists'); + assert.ok(various); + assert.equal(various.album_count, 1); +}); + test('artist browse filter defaults to primary artists and restores collab-only artists', () => { const artists = buildArtistList([ createTrack({ artist: 'Primary Artist feat. Guest Artist' }), diff --git a/src/library/artistGrouping.ts b/src/library/artistGrouping.ts index c516273..bab4984 100644 --- a/src/library/artistGrouping.ts +++ b/src/library/artistGrouping.ts @@ -209,8 +209,13 @@ 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 (key === primaryArtistKey) { + aggregate.primary_track_count += 1; + // Albums the artist only guests on are not their albums; counting them + // here credited a Various Artists compilation to every featured + // performer. albumArtwork below stays ungated so they keep a mosaic. + aggregate.albumKeys.add(track.album_identity_key); + } if (!track.artwork_hash) continue; if (!aggregate.albumArtwork.has(track.album_identity_key)) {