library: materialize artist art and provider images

Store the OpenSubsonic artistImageUrl and two materialized cover-id lists on each artist row, backfill them in the 35->36 migration, and refresh downloaded covers only for affected artists when downloads change. Icons and the artist hero now resolve provider art, server artist art, then the same materialized grid synchronously, with a definitive-failure fallback so a dead provider URL does not strand the letter placeholder.

Download artwork also caches the provider image for the track and album artists, falls back to the artist cover when that fetch fails, and keeps both keys across sweeps. Query timing for the materialization pass is down from roughly 3.4s to 1.9s on the 20k-song benchmark.
This commit is contained in:
austinried
2026-10-20 08:53:23 +00:00
parent 0d1118011f
commit 18118492c7
34 changed files with 2965 additions and 262 deletions
@@ -548,7 +548,7 @@ class LibraryDaoTest {
}
@Test
fun artistArtFallsBackToTheFirstAlbumCreditThenTheFirstTrackCredit() =
fun artistArtIsMaterializedFromAlbumCreditsThenTrackCredits() =
runTest {
val sourceId = source()
val dao = db.libraryDao()
@@ -615,40 +615,121 @@ class LibraryDaoTest {
}
}
dao.recomputeArtistArt(sourceId)
val artists = dao.artistsByName(sourceId, 0, "").page().associateBy { it.id }
assertEquals("own-art", artists.getValue("ar-own").coverArt)
assertEquals("new-art", artists.getValue("ar-album").coverArt)
assertEquals("track-art", artists.getValue("ar-track").coverArt)
assertEquals("album-ref-art", artists.getValue("ar-album-ref").coverArt)
assertEquals("track-ref-art", artists.getValue("ar-track-ref").coverArt)
assertNull(artists.getValue("ar-album").coverArt)
assertNull(artists.getValue("ar-track").coverArt)
assertNull(artists.getValue("ar-album-ref").coverArt)
assertNull(artists.getValue("ar-track-ref").coverArt)
assertNull(artists.getValue("ar-null-art").coverArt)
assertNull(artists.getValue("ar-none").coverArt)
assertEquals("new-art", dao.artistOnce(sourceId, "ar-album")!!.coverArt)
assertEquals(listOf("own-album"), artists.getValue("ar-own").coverArts)
assertEquals(listOf("new-art", "old-art"), artists.getValue("ar-album").coverArts)
assertEquals(listOf("track-art"), artists.getValue("ar-track").coverArts)
assertEquals(listOf("album-ref-art"), artists.getValue("ar-album-ref").coverArts)
assertEquals(listOf("track-ref-art"), artists.getValue("ar-track-ref").coverArts)
assertEquals(listOf("later-art"), artists.getValue("ar-null-art").coverArts)
assertEquals(emptyList<String>(), artists.getValue("ar-none").coverArts)
assertEquals(listOf("new-art", "old-art"), dao.artistOnce(sourceId, "ar-album")!!.coverArts)
assertEquals(
"album-ref-art",
listOf("album-ref-art"),
dao
.artistsPaging(artistsDefaultQuery(sourceId, 0, "", ArtistSort.Name, false))
.page()
.first { it.id == "ar-album-ref" }
.coverArt,
.coverArts,
)
}
@Test
fun artistArtFallbackFindsCreditsThroughArtistIndexes() =
fun downloadedArtistArtUsesOnlyDownloadedAlbums() =
runTest {
val plan = plan("SELECT $ARTIST_COLUMNS $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_NAME LIMIT 20")
assertTrue(plan, plan.contains("USING INDEX index_albums_sourceId_artistId (sourceId=? AND artistId=?)"))
assertTrue(
plan,
plan.contains("USING INDEX index_album_artists_sourceId_artistId (sourceId=? AND artistId=?)"),
val sourceId = source()
val dao = db.libraryDao()
dao.upsertArtists(listOf(artist(sourceId, "ar-1", "Artist", albumCount = 3)))
dao.upsertAlbums(
listOf(
album(sourceId, "al-1", "One", year = 2020, starred = null)
.copy(artistId = "ar-1", coverArt = "art-1"),
album(sourceId, "al-2", "Two", year = 2021, starred = null)
.copy(artistId = "ar-1", coverArt = "art-2"),
album(sourceId, "al-3", "Three", year = 2022, starred = null)
.copy(artistId = "ar-1", coverArt = "art-3"),
),
)
assertTrue(plan, plan.contains("USING INDEX index_songs_sourceId_artistId (sourceId=? AND artistId=?)"))
assertTrue(
plan,
plan.contains("USING INDEX index_song_artists_sourceId_artistId (sourceId=? AND artistId=?)"),
dao.upsertSongs(
listOf(
song(sourceId, "s1", "One", starred = null).copy(albumId = "al-1", artistId = "ar-1"),
song(sourceId, "s2", "Two", starred = null).copy(albumId = "al-2", artistId = "ar-1"),
song(sourceId, "s3", "Three", starred = null).copy(albumId = "al-3", artistId = "ar-1"),
),
)
db.downloadDao().upsert(SongDownload(sourceId, "s1", DownloadStatus.Completed))
db.downloadDao().upsert(SongDownload(sourceId, "s2", DownloadStatus.Queued))
dao.recomputeArtistArt(sourceId)
val artist = dao.artistOnce(sourceId, "ar-1")!!
assertEquals(listOf("art-3", "art-2", "art-1"), artist.coverArts)
assertEquals(listOf("art-1"), artist.downloadedCoverArts)
db.downloadDao().upsert(SongDownload(sourceId, "s2", DownloadStatus.Completed))
dao.recomputeDownloadedArtistArt(sourceId, listOf("ar-1"))
assertEquals(listOf("art-2", "art-1"), dao.artistOnce(sourceId, "ar-1")!!.downloadedCoverArts)
}
@Test
fun artistArtMaterializationUsesTheCreditIndexes() =
runTest {
val plan = plan(ARTIST_ART_UPDATE_SQL)
assertTrue(plan, plan.contains("index_albums_sourceId_artistId"))
assertTrue(plan, plan.contains("index_album_artists_sourceId_artistId"))
assertTrue(plan, plan.contains("index_songs_sourceId_artistId"))
assertTrue(plan, plan.contains("index_song_artists_sourceId_artistId"))
}
@Test
fun creditedSongArtistsIncludeTheAlbumArtistAndTrackCredits() =
runTest {
val sourceId = source()
val dao = db.libraryDao()
dao.upsertArtists(
listOf(
artist(sourceId, "ar-album", "Album Artist", albumCount = 1),
artist(sourceId, "ar-track", "Track Artist", albumCount = 0),
artist(sourceId, "ar-guest", "Guest", albumCount = 0),
),
)
dao.upsertAlbums(
listOf(
album(sourceId, "al-1", "Album", year = null, starred = null)
.copy(artistId = "ar-album"),
),
)
dao.upsertSongs(
listOf(
song(sourceId, "s1", "Song", starred = null)
.copy(albumId = "al-1", artistId = "ar-track"),
),
)
db.useWriterConnection { connection ->
connection.usePrepared(
"INSERT INTO song_artists (sourceId, songId, position, artistId, name) " +
"VALUES (?, 's1', 0, 'ar-guest', 'Guest')",
) { statement ->
statement.bindLong(1, sourceId)
statement.step()
}
}
assertEquals(
setOf("ar-album", "ar-track", "ar-guest"),
dao.creditedArtistIdsForSongs(sourceId, listOf("s1")).toSet(),
)
}
@@ -153,6 +153,70 @@ class MigrationsTest {
}
}
@Test
fun theArtistArtColumnsDefaultToEmptyAndAcceptValues() =
runTest {
driver.open(":memory:").use { migrated ->
applySchema(migrated, 35)
migrated.execSQL(
"INSERT INTO sources (id, name, address, isActive, createdAt) VALUES (1, 's', 'http://x', 1, 0)",
)
migrated.execSQL(
"INSERT INTO artists (sourceId, id, name, albumCount, starred) " +
"VALUES (1, 'ar1', 'Artist', 0, NULL)",
)
MIGRATION_35_36.migrate(migrated)
assertEquals(1L, long(migrated, "SELECT artistImageUrl IS NULL FROM artists WHERE id = 'ar1'"))
assertEquals("", text(migrated, "SELECT coverArts FROM artists WHERE id = 'ar1'"))
assertEquals("", text(migrated, "SELECT downloadedCoverArts FROM artists WHERE id = 'ar1'"))
migrated.execSQL(
"UPDATE artists SET artistImageUrl = 'http://img', coverArts = 'a\u001fb', " +
"downloadedCoverArts = 'c' WHERE id = 'ar1'",
)
assertEquals("http://img", text(migrated, "SELECT artistImageUrl FROM artists WHERE id = 'ar1'"))
assertEquals("a\u001fb", text(migrated, "SELECT coverArts FROM artists WHERE id = 'ar1'"))
assertEquals("c", text(migrated, "SELECT downloadedCoverArts FROM artists WHERE id = 'ar1'"))
}
}
@Test
fun theArtistArtMigrationBackfillsExistingLibraries() =
runTest {
driver.open(":memory:").use { migrated ->
applySchema(migrated, 35)
migrated.execSQL(
"INSERT INTO sources (id, name, address, isActive, createdAt) VALUES (1, 's', 'http://x', 1, 0)",
)
migrated.execSQL(
"INSERT INTO artists (sourceId, id, name, albumCount, starred) " +
"VALUES (1, 'ar1', 'Artist', 3, NULL)",
)
migrated.execSQL(
"INSERT INTO albums (sourceId, id, artistId, name, albumArtist, created, coverArt, genre, " +
"year, starred, songCount) VALUES " +
"(1, 'al1', 'ar1', 'Shared', 'Artist', 0, 'shared', NULL, 2020, NULL, 1), " +
"(1, 'al2', 'ar1', 'Shared Newer', 'Artist', 0, 'shared', NULL, 2021, NULL, 1), " +
"(1, 'al3', 'ar1', 'Other', 'Artist', 0, 'other', NULL, 2019, NULL, 1)",
)
migrated.execSQL(
"INSERT INTO songs (sourceId, id, albumId, artistId, title) VALUES " +
"(1, 's1', 'al1', 'ar1', 'One'), (1, 's2', 'al2', 'ar1', 'Two'), " +
"(1, 's3', 'al3', 'ar1', 'Three')",
)
migrated.execSQL(
"INSERT INTO song_downloads (sourceId, songId, status, bytes, total) VALUES " +
"(1, 's1', 'Completed', 1, 1), (1, 's2', 'Completed', 1, 1)",
)
MIGRATION_35_36.migrate(migrated)
assertEquals("shared\u001fother", text(migrated, "SELECT coverArts FROM artists WHERE id = 'ar1'"))
assertEquals("shared", text(migrated, "SELECT downloadedCoverArts FROM artists WHERE id = 'ar1'"))
}
}
@Test
fun theGenreBackfillTrimsAndSkipsBlankSingularGenres() =
runTest {
@@ -26,6 +26,7 @@ import com.subtracks.data.model.Song
import com.subtracks.data.model.SongDownload
import com.subtracks.data.model.Source
import com.subtracks.data.model.SubsonicSource
import com.subtracks.data.model.artistImageKey
import com.subtracks.data.model.coverArtKey
import com.subtracks.data.prefs.fakeUserPreferences
import kotlinx.coroutines.CoroutineScope
@@ -70,11 +71,12 @@ class DownloadRepositoryTest {
private val requestedArt = CopyOnWriteArrayList<String>()
private val artFetchedAfterTheEngineRequest = CopyOnWriteArrayList<Boolean>()
private var failArtworkFetch = false
private val failingArtUrls = CopyOnWriteArrayList<String>()
private val fetcher =
ArtworkFetcher { url ->
requestedArt += url
artFetchedAfterTheEngineRequest += engine.requests.isNotEmpty()
if (failArtworkFetch) throw IOException("boom")
if (failArtworkFetch || url in failingArtUrls) throw IOException("boom")
byteArrayOf(1, 2, 3)
}
@@ -381,6 +383,95 @@ class DownloadRepositoryTest {
}
}
@Test
fun startingADownloadStoresTheProviderArtistImageAndKeepsItUntilTheDownloadGoes() =
runTest {
seedLibrary()
runBlocking {
db.libraryDao().upsertArtists(listOf(artist(ARTIST_ART, artistImageUrl = "https://img.example/ar1.jpg")))
}
runBlocking { repository.download(1, "s1") }
assertTrue(requestedArt.contains("https://img.example/ar1.jpg"))
val key = artistImageKey(1, "ar1")
assertNotNull(artwork.uri(1, key))
runBlocking { repository.reconcile() }
assertNotNull(artwork.uri(1, key))
runBlocking { repository.remove(1, "s1") }
assertNull(artwork.uri(1, key))
}
@Test
fun providerArtworkForTheAlbumArtistSurvivesASweepAndGoesWithTheDownload() =
runTest {
seedLibrary()
runBlocking {
db.libraryDao().upsertArtists(
listOf(
artist(ARTIST_ART, "https://img.example/track.jpg").copy(id = "ar-track"),
artist(ARTIST_ART, "https://img.example/album.jpg").copy(id = "ar-album"),
),
)
db.libraryDao().upsertAlbums(listOf(album(ALBUM_ART).copy(artistId = "ar-album")))
db.libraryDao().upsertSongs(listOf(db.libraryDao().songOnce(1, "s1")!!.copy(artistId = "ar-track")))
}
runBlocking { repository.download(1, "s1") }
val trackKey = artistImageKey(1, "ar-track")
val albumKey = artistImageKey(1, "ar-album")
assertNotNull(artwork.uri(1, trackKey))
assertNotNull(artwork.uri(1, albumKey))
runBlocking { repository.reconcile() }
assertNotNull(artwork.uri(1, trackKey))
assertNotNull(artwork.uri(1, albumKey))
runBlocking { repository.remove(1, "s1") }
assertNull(artwork.uri(1, trackKey))
assertNull(artwork.uri(1, albumKey))
}
@Test
fun aFailedProviderImageFetchCachesTheArtistCoverFallback() =
runTest {
seedLibrary()
runBlocking {
db.libraryDao().upsertArtists(listOf(artist(ARTIST_ART, artistImageUrl = "https://img.example/ar1.jpg")))
}
failingArtUrls += "https://img.example/ar1.jpg"
runBlocking { repository.download(1, "s1") }
assertNull(artwork.uri(1, artistImageKey(1, "ar1")))
assertNotNull(artwork.uri(1, coverArtKey(1, ARTIST_ART, false)))
assertNotNull(artwork.uri(1, coverArtKey(1, ARTIST_ART, true)))
}
@Test
fun completingADownloadMaterializesOnlyItsAlbumForOfflineArtistArt() =
runTest {
seedLibrary()
repository.start()
runBlocking { repository.download(1, "s1") }
val id = engine.requests.single().first
engine.complete(id, total = 100)
writeFile(1, "s1", "audio")
runBlocking { repository.reconcile() }
await { row(1, "s1")?.status == DownloadStatus.Completed }
assertEquals(
listOf(ALBUM_ART),
db.libraryDao().artistOnce(1, "ar1")!!.downloadedCoverArts,
)
runBlocking { repository.remove(1, "s1") }
assertEquals(emptyList<String>(), db.libraryDao().artistOnce(1, "ar1")!!.downloadedCoverArts)
}
@Test
fun downloadingASongAlsoStoresTheArtworkOfPlaylistsThatContainIt() =
runTest {
@@ -1282,8 +1373,19 @@ class DownloadRepositoryTest {
songCount = 2,
)
private fun artist(coverArt: String?) =
Artist(sourceId = 1, id = "ar1", name = "Artist", albumCount = 1, starred = null, coverArt = coverArt, songCount = 2)
private fun artist(
coverArt: String?,
artistImageUrl: String? = null,
) = Artist(
sourceId = 1,
id = "ar1",
name = "Artist",
albumCount = 1,
starred = null,
coverArt = coverArt,
artistImageUrl = artistImageUrl,
songCount = 2,
)
private fun seedLibrary(address: String = "http://localhost:4533/") {
runBlocking {
@@ -11,6 +11,7 @@ import com.subtracks.cancelAndJoinBlocking
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.download.ArtworkStore
import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.artistImageKey
import com.subtracks.data.model.coverArtKey
import com.subtracks.data.prefs.StreamQuality
import com.subtracks.data.prefs.UserPreferences
@@ -33,6 +34,7 @@ import okhttp3.mockwebserver.RecordedRequest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@@ -283,6 +285,26 @@ class SourceRepositoryTest {
assertTrue("expected the stored file, was ${ref.url}", ref.url.startsWith("file:"))
}
@Test
fun artistImageUsesTheNetworkOnlineAndOnlyCachedFilesOffline() =
runBlocking {
repository.addSource("nav", "http://a.example/", "u", "p", false)
val sourceId = awaitActiveSourceId()
val url = "https://img.example/ar1.jpg"
val online = withTimeout(TEST_TIMEOUT_MS) { repository.artistImage("ar1", url) }
assertEquals(url, online?.url)
val key = artistImageKey(sourceId, "ar1")
artwork.write(sourceId, key, byteArrayOf(1, 2, 3))
assertEquals(artwork.uri(sourceId, key), repository.artistImage("ar1", url)?.url)
repository.setOfflineMode(true)
withTimeout(TEST_TIMEOUT_MS) { repository.offline.first { it } }
assertEquals(artwork.uri(sourceId, key), repository.artistImage("ar1", url)?.url)
assertNull(repository.artistImage("ar2", "https://img.example/ar2.jpg"))
}
private suspend fun awaitActiveSourceId(): Long = withTimeout(TEST_TIMEOUT_MS) { repository.activeSourceId().first { it != null }!! }
private suspend fun awaitCoverArt(coverArt: String): CoverArtRef =
@@ -74,7 +74,8 @@ class SubsonicXmlTest {
"""
<subsonic-response status="ok">
<artists>
<artist id="ar1" name="Radiohead" albumCount="9" starred="2020-01-02T03:04:05.000Z"/>
<artist id="ar1" name="Radiohead" albumCount="9" starred="2020-01-02T03:04:05.000Z"
artistImageUrl="https://img.example/radiohead.jpg"/>
<artist id="ar2" name="Portishead" albumCount="3"/>
</artists>
</subsonic-response>
@@ -88,6 +89,8 @@ class SubsonicXmlTest {
assertEquals(7L, artists[0].sourceId)
assertEquals(9L, artists[0].albumCount)
assertEquals(1577934245L, artists[0].starred)
assertEquals("https://img.example/radiohead.jpg", artists[0].artistImageUrl)
assertEquals(null, artists[1].artistImageUrl)
assertEquals(null, artists[1].starred)
}
@@ -48,6 +48,7 @@ import com.subtracks.data.prefs.StreamQuality
import com.subtracks.playback.PlaybackState
import com.subtracks.playback.QueueItem
import com.subtracks.playback.RepeatMode
import com.subtracks.ui.components.ArtistArt
import com.subtracks.ui.components.DOWNLOADING_INDICATOR_TAG
import com.subtracks.ui.library.ALBUM_COVER_TAG
import com.subtracks.ui.library.ARTIST_NAME_SCRIM_TAG
@@ -613,12 +614,15 @@ class AppScreenshotTest {
artists = remember { flowOf(PagingData.from(Fixtures.artists)) },
playlists = remember { flowOf(PagingData.from(Fixtures.playlists)) },
coverArt = { id, _ -> id?.let { CoverArtRef(it, "test:$it") } },
artistCovers = { id ->
Fixtures.albums
.filter { it.artistId == id }
.take(4)
.mapNotNull { it.coverArt }
.map { CoverArtRef(it, "test:$it") }
artistArt = { artist ->
ArtistArt(
covers =
Fixtures.albums
.filter { it.artistId == artist.id }
.take(4)
.mapNotNull { it.coverArt }
.map { CoverArtRef(it, "test:$it") },
)
},
onAlbumClick = {},
onArtistClick = {},
@@ -0,0 +1,174 @@
package com.subtracks.ui.components
import com.subtracks.data.model.Artist
import com.subtracks.data.model.CoverArtRef
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class ArtistArtTest {
@Test
fun providerImageWinsOverArtistAndAlbumArt() {
val calls = mutableListOf<String>()
val art =
resolveArtistArt(
artist = artist(artistImageUrl = "https://img/artist"),
coverArt = { id, _ -> id?.let { CoverArtRef(it, "cache:$it") } },
artistImage = { id, url ->
calls += "image:$id:$url"
CoverArtRef("https://img/artist", "cache:image:$id")
},
offline = false,
forIcon = true,
)
assertEquals(CoverArtRef("https://img/artist", "cache:image:ar1"), art.image)
assertTrue(art.covers.isEmpty())
assertEquals(listOf("image:ar1:https://img/artist"), calls)
}
@Test
fun artistCoverArtWinsOverMaterializedAlbumArt() {
val thumbnails = mutableListOf<Boolean>()
val art =
resolveArtistArt(
artist = artist(coverArt = "own", coverArts = listOf("a", "b")),
coverArt = { id, thumbnail ->
thumbnails += thumbnail
id?.let { CoverArtRef(it, "cache:$it:$thumbnail") }
},
artistImage = { _, _ -> null },
offline = false,
forIcon = true,
)
assertEquals(CoverArtRef("own", "cache:own:true"), art.image)
assertTrue(art.covers.isEmpty())
assertTrue(thumbnails.all { it })
}
@Test
fun iconUsesThumbnailsAndLimitsTheGridToFourCovers() {
val thumbnails = mutableListOf<Boolean>()
val art =
resolveArtistArt(
artist = artist(coverArts = listOf("a", "b", "c", "d", "e")),
coverArt = { id, thumbnail ->
thumbnails += thumbnail
id?.let { CoverArtRef(it, "cache:$it") }
},
artistImage = { _, _ -> null },
offline = false,
forIcon = true,
)
assertEquals(listOf("a", "b", "c", "d"), art.covers.map { it.url })
assertEquals(listOf(true, true, true, true), thumbnails)
}
@Test
fun aSingleHeroCoverLoadsFullSizeWhileMultipleCoversUseThumbnails() {
val thumbnails = mutableListOf<Boolean>()
val coverArt = { id: String?, thumbnail: Boolean ->
thumbnails += thumbnail
id?.let { CoverArtRef(it, "cache:$it") }
}
val artistImage: (String, String?) -> CoverArtRef? = { _, _ -> null }
resolveArtistArt(artist(coverArts = listOf("a")), coverArt, artistImage, offline = false, forIcon = false)
assertEquals(listOf(false), thumbnails)
thumbnails.clear()
resolveArtistArt(artist(coverArts = listOf("a", "b")), coverArt, artistImage, offline = false, forIcon = false)
assertEquals(listOf(true, true), thumbnails)
}
@Test
fun offlineUsesDownloadedCoversAndNeverOnlineOnlyOnes() {
val requested = mutableListOf<String>()
val art =
resolveArtistArt(
artist =
artist(
coverArt = "online-own",
coverArts = listOf("online-a", "online-b"),
downloadedCoverArts = listOf("downloaded-a", "downloaded-b"),
),
coverArt = { id, _ ->
requested += id ?: "null"
id?.takeIf { it.startsWith("downloaded") }?.let { CoverArtRef(it, "cache:$it") }
},
artistImage = { _, _ -> null },
offline = true,
forIcon = true,
)
assertEquals(listOf("downloaded-a", "downloaded-b"), art.covers.map { it.url })
assertEquals(setOf("online-own", "downloaded-a", "downloaded-b"), requested.toSet())
}
@Test
fun theProviderAndArtistImagesCarryTheNextSourceAsAFallback() {
val provider =
resolveArtistArt(
artist = artist(coverArt = "own", artistImageUrl = "https://img/artist", coverArts = listOf("a", "b")),
coverArt = { id, _ -> id?.let { CoverArtRef(it, "cache:$it") } },
artistImage = { _, _ -> CoverArtRef("https://img/artist", "cache:image") },
offline = false,
forIcon = true,
)
assertEquals(CoverArtRef("own", "cache:own"), provider.fallback?.image)
assertEquals(
listOf("a", "b"),
provider.fallback
?.fallback
?.covers
?.map { it.url },
)
val own =
resolveArtistArt(
artist = artist(coverArt = "own", coverArts = listOf("a", "b")),
coverArt = { id, _ -> id?.let { CoverArtRef(it, "cache:$it") } },
artistImage = { _, _ -> null },
offline = false,
forIcon = true,
)
assertEquals(listOf("a", "b"), own.fallback?.covers?.map { it.url })
}
@Test
fun noArtResolvesToAnEmptySelectionInsteadOfAStaleFallback() {
val art =
resolveArtistArt(
artist = artist(coverArt = "online-own", coverArts = listOf("a")),
coverArt = { _, _ -> null },
artistImage = { _, _ -> null },
offline = true,
forIcon = true,
)
assertNull(art.image)
assertTrue(art.covers.isEmpty())
}
private fun artist(
coverArt: String? = null,
artistImageUrl: String? = null,
coverArts: List<String> = emptyList(),
downloadedCoverArts: List<String> = emptyList(),
) = Artist(
sourceId = 1,
id = "ar1",
name = "Artist",
albumCount = 0,
starred = null,
coverArt = coverArt,
artistImageUrl = artistImageUrl,
coverArts = coverArts,
downloadedCoverArts = downloadedCoverArts,
)
}