library: rank artist cover fallback like the materialized list

Reuse the materialized ranking CTE chain for the per-artist fallback so
duplicate covers keep the same album, exercise the four-row cap, and
test every credit path plus source isolation. Resolve the art input as a
pure function and key the artist and covers off the same source.
This commit is contained in:
austinried
2026-10-21 10:14:01 +00:00
parent 1dbfb027fe
commit 677596c21b
5 changed files with 191 additions and 66 deletions
@@ -204,8 +204,8 @@ private const val ARTIST_COVER_CREDITS =
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE song_artists.sourceId = :sourceId AND albums.coverArt IS NOT NULL"
internal const val ARTIST_ART_UPDATE_SQL =
"WITH credits AS (SELECT artistId, albumId, " +
private const val ARTIST_COVER_RANKS =
"credits AS (SELECT artistId, albumId, " +
"MIN(CASE creditType WHEN 'album' THEN 0 ELSE 1 END) AS creditRank " +
"FROM ($ARTIST_COVER_CREDITS) GROUP BY artistId, albumId), " +
"ranked AS (SELECT credits.artistId AS artistId, albums.coverArt AS coverArt, " +
@@ -215,7 +215,10 @@ internal const val ARTIST_ART_UPDATE_SQL =
"WHERE albums.coverArt IS NOT NULL), " +
"unique_ranked AS (SELECT artistId, coverArt, MIN(coverRank) AS coverRank FROM ranked GROUP BY artistId, coverArt), " +
"deduped AS (SELECT artistId, coverArt, " +
"ROW_NUMBER() OVER (PARTITION BY artistId ORDER BY coverRank) AS coverRank FROM unique_ranked), " +
"ROW_NUMBER() OVER (PARTITION BY artistId ORDER BY coverRank) AS coverRank FROM unique_ranked)"
internal const val ARTIST_ART_UPDATE_SQL =
"WITH $ARTIST_COVER_RANKS, " +
"covers AS (SELECT artistId, group_concat(coverArt, char(31)) AS coverArts FROM (" +
"SELECT artistId, coverArt, coverRank FROM deduped WHERE coverRank <= 4 ORDER BY artistId, coverRank" +
") GROUP BY artistId), " +
@@ -241,11 +244,8 @@ internal const val ARTIST_ART_UPDATE_SQL =
"(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"
"WITH $ARTIST_COVER_RANKS " +
"SELECT coverArt FROM deduped WHERE artistId = :artistId AND coverRank <= 4 ORDER BY coverRank"
internal const val DOWNLOADED_ARTIST_ART_UPDATE_SQL =
"WITH downloaded_albums AS (SELECT DISTINCT artistId, albumId FROM ($DOWNLOADED_COVER_ALBUM_ROWS) " +
@@ -433,13 +433,7 @@ interface LibraryDao {
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(
fun artistCoverArts(
sourceId: Long,
artistId: String,
): Flow<List<String>>
@@ -445,7 +445,7 @@ class LibraryRepository(
fun artistCoverArts(
sourceId: Long,
artistId: String,
): Flow<List<String>> = db.libraryDao().artistCoverArtsFlow(sourceId, artistId)
): Flow<List<String>> = db.libraryDao().artistCoverArts(sourceId, artistId)
fun albumDiscs(
sourceId: Long,
@@ -145,50 +145,16 @@ class ArtistDetailViewModel(
init {
viewModelScope.launch {
combine(
artist,
sourceRepository.offline,
sourceRepository.activeSource,
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)
}
}
}
sourceRepository.activeSource
.filterNotNull()
.flatMapLatest { sourceId ->
combine(
libraryRepository.artist(sourceId, artistId),
libraryRepository.artistCoverArts(sourceId, artistId),
sourceRepository.offline,
) { loaded, covers, offline -> ArtistArtInput(loaded, artistId, offline, sourceId, covers) }
}.collectLatest { input ->
val resolved = resolveArtistArtInput(input)
if (resolved == null) {
_art.value = null
_artThumbnail.value = null
@@ -216,9 +182,34 @@ class ArtistDetailViewModel(
}
}
private data class ArtistArtInput(
internal data class ArtistArtInput(
val artist: Artist?,
val artistId: String,
val offline: Boolean,
val sourceId: Long?,
val sourceId: Long,
val covers: List<String>,
)
internal fun resolveArtistArtInput(input: ArtistArtInput): Artist? {
val artist = input.artist
if (input.covers.isEmpty()) return artist
if (artist == null) {
return Artist(
sourceId = input.sourceId,
id = input.artistId,
name = "",
albumCount = 0,
starred = null,
coverArts = input.covers,
downloadedCoverArts = if (input.offline) input.covers else emptyList(),
)
}
return if (input.offline) {
artist.copy(
coverArts = input.covers,
downloadedCoverArts = artist.downloadedCoverArts.ifEmpty { input.covers },
)
} else {
artist.copy(coverArts = input.covers)
}
}