mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-15 00:42:54 +01:00
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:
@@ -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,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user