From 1dbfb027fe24a628e25a4a2a192056f20dac3a1e Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Wed, 21 Oct 2026 02:51:33 +0000 Subject: [PATCH] library: resolve artist art from live credits Artist detail only used the materialized artist cover list, so credited track artists without an artists row, or with an empty or stale cover list, always fell back to the letter placeholder even though their appearances albums had art. Observe the credit-derived cover query and use it for the detail art, preferring the cached downloaded covers when offline. The query covers album, album_artists, song and song_artists credits. --- .../com/subtracks/data/db/LibraryDao.kt | 19 ++++++ .../subtracks/data/repo/LibraryRepository.kt | 5 ++ .../ui/library/ArtistDetailViewModel.kt | 62 +++++++++++++++++-- .../com/subtracks/data/db/LibraryDaoTest.kt | 46 ++++++++++++++ 4 files changed, 126 insertions(+), 6 deletions(-) diff --git a/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt b/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt index f818f7ff..bd17ad0a 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt @@ -240,6 +240,13 @@ internal const val ARTIST_ART_UPDATE_SQL = "FROM desired WHERE artists.rowid = desired.rowid AND " + "(artists.coverArts != desired.coverArts OR artists.downloadedCoverArts != desired.downloadedCoverArts)" +internal const val ARTIST_COVER_ARTS_SQL = + "SELECT DISTINCT albums.coverArt FROM ($ARTIST_COVER_CREDITS) credits " + + "JOIN albums ON albums.sourceId = :sourceId AND albums.id = credits.albumId " + + "WHERE credits.artistId = :artistId AND albums.coverArt IS NOT NULL " + + "ORDER BY CASE credits.creditType WHEN 'album' THEN 0 ELSE 1 END, albums.year DESC, " + + "albums.sortName COLLATE NOCASE, albums.id LIMIT 4" + internal const val DOWNLOADED_ARTIST_ART_UPDATE_SQL = "WITH downloaded_albums AS (SELECT DISTINCT artistId, albumId FROM ($DOWNLOADED_COVER_ALBUM_ROWS) " + "WHERE artistId IN (:artistIds)), " + @@ -425,6 +432,18 @@ interface LibraryDao { @Query(ARTIST_ART_UPDATE_SQL) suspend fun recomputeArtistArt(sourceId: Long) + @Query(ARTIST_COVER_ARTS_SQL) + suspend fun artistCoverArts( + sourceId: Long, + artistId: String, + ): List + + @Query(ARTIST_COVER_ARTS_SQL) + fun artistCoverArtsFlow( + sourceId: Long, + artistId: String, + ): Flow> + @Query(DOWNLOADED_ARTIST_ART_UPDATE_SQL) suspend fun recomputeDownloadedArtistArt( sourceId: Long, diff --git a/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt b/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt index b8a8f866..f752c60f 100644 --- a/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt +++ b/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt @@ -442,6 +442,11 @@ class LibraryRepository( } } + fun artistCoverArts( + sourceId: Long, + artistId: String, + ): Flow> = db.libraryDao().artistCoverArtsFlow(sourceId, artistId) + fun albumDiscs( sourceId: Long, albumId: String, diff --git a/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailViewModel.kt index 7dd6ef24..5356d4ea 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailViewModel.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailViewModel.kt @@ -146,14 +146,57 @@ class ArtistDetailViewModel( init { viewModelScope.launch { combine( - artist.filterNotNull(), + artist, sourceRepository.offline, sourceRepository.activeSource, - ) { loaded, _, activeSource -> loaded to activeSource } - .collectLatest { (loaded, activeSource) -> - if (activeSource == null) return@collectLatest - val hero = sourceRepository.artistArt(loaded, forIcon = false) - val icon = sourceRepository.artistArt(loaded, forIcon = true) + sourceRepository.activeSource.filterNotNull().flatMapLatest { + libraryRepository.artistCoverArts(it, artistId) + }, + ) { loaded, offline, activeSource, covers -> ArtistArtInput(loaded, offline, activeSource, covers) } + .collectLatest { input -> + if (input.sourceId == null) return@collectLatest + val resolved = + if (input.covers.isEmpty()) { + input.artist + } else { + when { + input.artist == null -> { + Artist( + sourceId = input.sourceId, + id = artistId, + name = "", + albumCount = 0, + starred = null, + coverArts = input.covers, + downloadedCoverArts = if (input.offline) input.covers else emptyList(), + ) + } + + input.offline -> { + if (input.artist.downloadedCoverArts.isNullOrEmpty()) { + input.artist.copy(downloadedCoverArts = input.covers) + } else { + input.artist + } + } + + input.artist.coverArts == input.covers -> { + input.artist + } + + else -> { + input.artist.copy(coverArts = input.covers) + } + } + } + if (resolved == null) { + _art.value = null + _artThumbnail.value = null + _heroArt.value = null + return@collectLatest + } + val hero = sourceRepository.artistArt(resolved, forIcon = false) + val icon = sourceRepository.artistArt(resolved, forIcon = true) _art.value = hero.image ?: hero.covers.firstOrNull() _artThumbnail.value = icon.image ?: icon.covers.firstOrNull() _heroArt.value = hero @@ -172,3 +215,10 @@ class ArtistDetailViewModel( } } } + +private data class ArtistArtInput( + val artist: Artist?, + val offline: Boolean, + val sourceId: Long?, + val covers: List, +) diff --git a/app/src/test/kotlin/com/subtracks/data/db/LibraryDaoTest.kt b/app/src/test/kotlin/com/subtracks/data/db/LibraryDaoTest.kt index 0776694b..85321bdc 100644 --- a/app/src/test/kotlin/com/subtracks/data/db/LibraryDaoTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/db/LibraryDaoTest.kt @@ -655,6 +655,52 @@ class LibraryDaoTest { ) } + @Test + fun coverArtFallbackFindsTrackCreditsWithoutAnArtistRow() = + runTest { + val sourceId = source() + val dao = db.libraryDao() + dao.upsertAlbums( + listOf( + album(sourceId, "al-1", "Album", year = 2020, starred = null) + .copy(artistId = "ar-other", coverArt = "track-art"), + album(sourceId, "al-2", "Featured", year = 2021, starred = null) + .copy(artistId = "ar-other", coverArt = "featured-art"), + ), + ) + dao.upsertSongs( + listOf( + song(sourceId, "s-1", "Song", starred = null) + .copy(albumId = "al-1", artistId = "ar-missing"), + song(sourceId, "s-2", "Featured", starred = null) + .copy(albumId = "al-2", artistId = "ar-other"), + ), + ) + db.useWriterConnection { connection -> + connection.usePrepared( + "INSERT INTO album_artists (sourceId, albumId, position, artistId, name) " + + "VALUES (?, 'al-2', 0, 'ar-featured', 'Featured')", + ) { statement -> + statement.bindLong(1, sourceId) + statement.step() + } + connection.usePrepared( + "INSERT INTO song_artists (sourceId, songId, position, artistId, name) " + + "VALUES (?, 's-2', 0, 'ar-guest', 'Guest')", + ) { statement -> + statement.bindLong(1, sourceId) + statement.step() + } + } + + assertNull(dao.artistOnce(sourceId, "ar-missing")) + assertNull(dao.artistOnce(sourceId, "ar-featured")) + assertNull(dao.artistOnce(sourceId, "ar-guest")) + assertEquals(listOf("track-art"), dao.artistCoverArts(sourceId, "ar-missing")) + assertEquals(listOf("featured-art"), dao.artistCoverArts(sourceId, "ar-featured")) + assertEquals(listOf("featured-art"), dao.artistCoverArts(sourceId, "ar-guest")) + } + @Test fun downloadedArtistArtUsesOnlyDownloadedAlbums() = runTest {