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.
This commit is contained in:
austinried
2026-10-21 10:14:01 +00:00
parent 8765c5d37b
commit 1dbfb027fe
4 changed files with 126 additions and 6 deletions
@@ -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<String>
@Query(ARTIST_COVER_ARTS_SQL)
fun artistCoverArtsFlow(
sourceId: Long,
artistId: String,
): Flow<List<String>>
@Query(DOWNLOADED_ARTIST_ART_UPDATE_SQL)
suspend fun recomputeDownloadedArtistArt(
sourceId: Long,
@@ -442,6 +442,11 @@ class LibraryRepository(
}
}
fun artistCoverArts(
sourceId: Long,
artistId: String,
): Flow<List<String>> = db.libraryDao().artistCoverArtsFlow(sourceId, artistId)
fun albumDiscs(
sourceId: Long,
albumId: String,
@@ -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<String>,
)
@@ -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 {