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
File diff suppressed because it is too large Load Diff
@@ -162,12 +162,15 @@ interface DownloadDao {
@Query(
"SELECT d.sourceId AS sourceId, s.albumId AS albumId, s.artistId AS artistId, " +
"al.artistId AS albumArtistId, " +
"al.id AS albumRow, al.coverArt AS albumCoverArt, " +
"ar.id AS artistRow, ar.coverArt AS artistCoverArt " +
"ar.id AS artistRow, ar.coverArt AS artistCoverArt, " +
"aar.coverArt AS albumArtistCoverArt " +
"FROM song_downloads d " +
"LEFT JOIN songs s ON s.sourceId = d.sourceId AND s.id = d.songId " +
"LEFT JOIN albums al ON al.sourceId = s.sourceId AND al.id = s.albumId " +
"LEFT JOIN artists ar ON ar.sourceId = s.sourceId AND ar.id = s.artistId",
"LEFT JOIN artists ar ON ar.sourceId = s.sourceId AND ar.id = s.artistId " +
"LEFT JOIN artists aar ON aar.sourceId = al.sourceId AND aar.id = al.artistId",
)
suspend fun artwork(): List<DownloadArtwork>
@@ -124,32 +124,125 @@ internal const val ALBUM_CREDIT_ALBUM_IDS =
// Download visibility follows the credit path: an album credit includes the album when any song is
// downloaded, while a track credit includes it only when the credited track is downloaded.
private const val DOWNLOADED_ARTIST_ALBUMS =
"SELECT albums.id FROM albums " +
"WHERE albums.sourceId = :sourceId AND albums.artistId = artists.id " +
internal const val DOWNLOADED_ALBUM_ROWS =
"SELECT albums.id AS albumId, albums.artistId AS artistId FROM albums " +
"WHERE albums.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
"AND EXISTS (SELECT 1 FROM songs " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
"UNION " +
"SELECT albums.id FROM album_artists " +
"SELECT albums.id, album_artists.artistId FROM album_artists " +
"JOIN albums ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
"WHERE album_artists.sourceId = :sourceId AND album_artists.artistId = artists.id " +
"WHERE album_artists.sourceId = :sourceId " +
"AND EXISTS (SELECT 1 FROM songs " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
"UNION " +
"SELECT songs.albumId FROM songs " +
"SELECT songs.albumId, songs.artistId FROM songs " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
"WHERE songs.sourceId = :sourceId AND songs.artistId = artists.id " +
"AND sd.status = 'Completed' " +
"WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL AND sd.status = 'Completed' " +
"UNION " +
"SELECT songs.albumId FROM song_artists " +
"SELECT songs.albumId, song_artists.artistId FROM song_artists " +
"JOIN songs ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
"WHERE song_artists.sourceId = :sourceId AND song_artists.artistId = artists.id " +
"AND sd.status = 'Completed'"
"WHERE song_artists.sourceId = :sourceId AND sd.status = 'Completed'"
private const val DOWNLOADED_ARTIST_ALBUMS =
"SELECT albumId AS id FROM ($DOWNLOADED_ALBUM_ROWS) WHERE artistId = artists.id"
private const val DOWNLOADED_COVER_ALBUM_ROWS =
"SELECT albums.id AS albumId, albums.artistId AS artistId FROM albums " +
"WHERE albums.sourceId = :sourceId AND albums.artistId IS NOT NULL AND albums.coverArt IS NOT NULL " +
"AND EXISTS (SELECT 1 FROM songs " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
"UNION " +
"SELECT albums.id, album_artists.artistId FROM album_artists " +
"JOIN albums ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
"WHERE album_artists.sourceId = :sourceId AND albums.coverArt IS NOT NULL " +
"AND EXISTS (SELECT 1 FROM songs " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
"UNION " +
"SELECT songs.albumId, songs.artistId FROM songs " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
"WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL AND albums.coverArt IS NOT NULL " +
"AND sd.status = 'Completed' " +
"UNION " +
"SELECT songs.albumId, song_artists.artistId FROM song_artists " +
"JOIN songs ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
"WHERE song_artists.sourceId = :sourceId AND albums.coverArt IS NOT NULL AND sd.status = 'Completed'"
// Cover art only needs credited albums that actually have a cover, so filter before the window sort.
private const val ARTIST_COVER_CREDITS =
"SELECT albums.artistId AS artistId, albums.id AS albumId, 'album' AS creditType " +
"FROM albums WHERE albums.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
"AND albums.coverArt IS NOT NULL " +
"UNION ALL " +
"SELECT album_artists.artistId, albums.id, 'album' " +
"FROM album_artists JOIN albums " +
"ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
"WHERE album_artists.sourceId = :sourceId AND albums.coverArt IS NOT NULL " +
"UNION ALL " +
"SELECT songs.artistId, songs.albumId, 'track' " +
"FROM songs JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL AND albums.coverArt IS NOT NULL " +
"UNION ALL " +
"SELECT song_artists.artistId, songs.albumId, 'track' " +
"FROM song_artists JOIN songs " +
"ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
"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, " +
"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, " +
"ROW_NUMBER() OVER (PARTITION BY credits.artistId " +
"ORDER BY credits.creditRank, albums.year DESC, albums.sortName COLLATE NOCASE, albums.id) AS coverRank " +
"FROM credits JOIN albums ON albums.sourceId = :sourceId AND albums.id = credits.albumId " +
"WHERE albums.coverArt IS NOT NULL), " +
"unique_ranked AS (SELECT artistId, coverArt, MIN(coverRank) AS coverRank FROM ranked GROUP BY artistId, coverArt), " +
"covers AS (SELECT artistId, group_concat(coverArt, char(31)) AS coverArts FROM (" +
"SELECT artistId, coverArt, coverRank FROM unique_ranked WHERE coverRank <= 4 ORDER BY artistId, coverRank" +
") GROUP BY artistId), " +
"downloaded_albums AS (SELECT DISTINCT artistId, albumId FROM ($DOWNLOADED_COVER_ALBUM_ROWS)), " +
"downloaded AS (SELECT dl.artistId AS artistId, albums.coverArt AS coverArt, " +
"ROW_NUMBER() OVER (PARTITION BY dl.artistId " +
"ORDER BY albums.year DESC, albums.sortName COLLATE NOCASE, albums.id) AS coverRank " +
"FROM downloaded_albums dl JOIN albums ON albums.sourceId = :sourceId AND albums.id = dl.albumId " +
"WHERE albums.coverArt IS NOT NULL), " +
"unique_downloaded AS (SELECT artistId, coverArt, MIN(coverRank) AS coverRank FROM downloaded GROUP BY artistId, coverArt), " +
"downloaded_covers AS (SELECT artistId, group_concat(coverArt, char(31)) AS coverArts FROM (" +
"SELECT artistId, coverArt, coverRank FROM unique_downloaded WHERE coverRank <= 4 ORDER BY artistId, coverRank" +
") GROUP BY artistId) " +
"UPDATE artists SET " +
"coverArts = COALESCE((SELECT coverArts FROM covers WHERE covers.artistId = artists.id), ''), " +
"downloadedCoverArts = COALESCE((SELECT coverArts FROM downloaded_covers " +
"WHERE downloaded_covers.artistId = artists.id), '') " +
"WHERE sourceId = :sourceId"
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)), " +
"downloaded AS (SELECT dl.artistId AS artistId, albums.coverArt AS coverArt, " +
"ROW_NUMBER() OVER (PARTITION BY dl.artistId " +
"ORDER BY albums.year DESC, albums.sortName COLLATE NOCASE, albums.id) AS coverRank " +
"FROM downloaded_albums dl JOIN albums ON albums.sourceId = :sourceId AND albums.id = dl.albumId " +
"WHERE albums.coverArt IS NOT NULL), " +
"unique_downloaded AS (SELECT artistId, coverArt, MIN(coverRank) AS coverRank FROM downloaded GROUP BY artistId, coverArt), " +
"downloaded_covers AS (SELECT artistId, group_concat(coverArt, char(31)) AS coverArts FROM (" +
"SELECT artistId, coverArt, coverRank FROM unique_downloaded WHERE coverRank <= 4 ORDER BY artistId, coverRank" +
") GROUP BY artistId) " +
"UPDATE artists SET downloadedCoverArts = COALESCE((SELECT coverArts FROM downloaded_covers " +
"WHERE downloaded_covers.artistId = artists.id), '') " +
"WHERE sourceId = :sourceId AND id IN (:artistIds)"
internal const val ARTISTS_FILTER =
"FROM artists WHERE sourceId = :sourceId " +
@@ -164,40 +257,10 @@ internal const val ARTISTS_FILTER =
"OR (length(:search) < 3 AND (instr(lower(name), lower(:search)) > 0 " +
"OR instr(lower(sortName), lower(:search)) > 0))) "
internal const val ARTIST_ALBUM_CREDIT_ART =
"SELECT albums.coverArt FROM (" +
"SELECT albums.id, albums.coverArt, albums.year, albums.sortName FROM albums " +
"INDEXED BY index_albums_sourceId_artistId " +
"WHERE albums.sourceId = artists.sourceId AND albums.artistId = artists.id " +
"UNION ALL " +
"SELECT albums.id, albums.coverArt, albums.year, albums.sortName FROM album_artists " +
"JOIN albums ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
"WHERE album_artists.sourceId = artists.sourceId AND album_artists.artistId = artists.id" +
") albums ORDER BY albums.year DESC, albums.sortName COLLATE NOCASE, albums.id LIMIT 1"
internal const val ARTIST_TRACK_CREDIT_ART =
"SELECT albums.coverArt FROM (" +
"SELECT albums.id, albums.coverArt, albums.year, albums.sortName FROM songs " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE songs.sourceId = artists.sourceId AND songs.artistId = artists.id " +
"UNION ALL " +
"SELECT albums.id, albums.coverArt, albums.year, albums.sortName FROM song_artists " +
"JOIN songs ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE song_artists.sourceId = artists.sourceId AND song_artists.artistId = artists.id" +
") albums ORDER BY albums.year DESC, albums.sortName COLLATE NOCASE, albums.id LIMIT 1"
internal const val ARTIST_COVER_ART =
"COALESCE(artists.coverArt, CASE " +
"WHEN EXISTS (SELECT 1 FROM albums WHERE albums.sourceId = artists.sourceId AND albums.artistId = artists.id) " +
"OR EXISTS (SELECT 1 FROM album_artists " +
"WHERE album_artists.sourceId = artists.sourceId AND album_artists.artistId = artists.id) " +
"THEN ($ARTIST_ALBUM_CREDIT_ART) ELSE ($ARTIST_TRACK_CREDIT_ART) END)"
internal const val ARTIST_COLUMNS =
"artists.sourceId, artists.id, artists.name, artists.albumCount, artists.starred, " +
"$ARTIST_COVER_ART AS coverArt, artists.playCount, artists.played, artists.songCount, " +
"artists.sortName, artists.credited"
"artists.coverArt, artists.artistImageUrl, artists.coverArts, artists.downloadedCoverArts, " +
"artists.playCount, artists.played, artists.songCount, artists.sortName, artists.credited"
// With the Downloaded filter on, count the albums that actually have a download so the number under
// an artist matches the albums its detail screen lists.
@@ -206,8 +269,8 @@ internal const val ARTISTS_SELECT =
"CASE WHEN :downloadedFilter = 1 THEN (" +
"SELECT COUNT(*) FROM ($DOWNLOADED_ARTIST_ALBUMS)" +
") ELSE artists.albumCount END AS albumCount, " +
"artists.starred, $ARTIST_COVER_ART AS coverArt, artists.playCount, artists.played, artists.songCount, " +
"artists.sortName, artists.credited "
"artists.starred, artists.coverArt, artists.artistImageUrl, artists.coverArts, artists.downloadedCoverArts, " +
"artists.playCount, artists.played, artists.songCount, artists.sortName, artists.credited "
internal const val ARTIST_ORDER_BY_NAME = "sortName COLLATE NOCASE, id"
internal const val ARTIST_ORDER_BY_NAME_REVERSED = "sortName COLLATE NOCASE DESC, id DESC"
@@ -334,10 +397,20 @@ interface LibraryDao {
)
suspend fun recomputeArtistAggregates(sourceId: Long)
@Query(ARTIST_ART_UPDATE_SQL)
suspend fun recomputeArtistArt(sourceId: Long)
@Query(DOWNLOADED_ARTIST_ART_UPDATE_SQL)
suspend fun recomputeDownloadedArtistArt(
sourceId: Long,
artistIds: List<String>,
)
@Transaction
suspend fun recomputeAggregates(sourceId: Long) {
recomputeAlbumPlayData(sourceId)
recomputeArtistAggregates(sourceId)
recomputeArtistArt(sourceId)
}
@Query(
@@ -381,6 +454,12 @@ interface LibraryDao {
songId: String,
): String?
@Query("SELECT DISTINCT artistId FROM ($CREDITED_SONG_ROWS) WHERE songId IN (:songIds)")
suspend fun creditedArtistIdsForSongs(
sourceId: Long,
songIds: List<String>,
): List<String>
// Counted locally when a scrobble is submitted, so the play sorts move before the next sync; the
// sync then overwrites with the server's own number.
@Transaction
@@ -1070,26 +1149,6 @@ interface LibraryDao {
artistId: String,
): Flow<Artist?>
@Query("SELECT coverArt FROM artists WHERE sourceId = :sourceId AND id = :artistId")
suspend fun artistOwnCoverArt(
sourceId: Long,
artistId: String,
): String?
@Query(
"SELECT albums.coverArt FROM (" +
"SELECT albumId, MIN(CASE creditType WHEN 'album' THEN 0 ELSE 1 END) AS creditRank " +
"FROM ($CREDITED_ALBUM_ROWS) WHERE artistId = :artistId GROUP BY albumId" +
") credits " +
"JOIN albums ON albums.sourceId = :sourceId AND albums.id = credits.albumId " +
"WHERE albums.coverArt IS NOT NULL " +
"ORDER BY credits.creditRank, albums.year DESC, albums.sortName COLLATE NOCASE, albums.id LIMIT 4",
)
suspend fun artistFallbackCoverArts(
sourceId: Long,
artistId: String,
): List<String>
@Query("SELECT * FROM artist_bios WHERE sourceId = :sourceId AND artistId = :artistId")
fun artistBio(
sourceId: Long,
@@ -695,6 +695,29 @@ private suspend fun createSearchIndex(
connection.execSQL("INSERT INTO `$table`(`$table`) VALUES('rebuild')")
}
val MIGRATION_35_36 =
object : Migration(35, 36) {
override suspend fun migrate(connection: SQLiteConnection) {
connection.execSQL("ALTER TABLE `artists` ADD COLUMN `artistImageUrl` TEXT")
connection.execSQL("ALTER TABLE `artists` ADD COLUMN `coverArts` TEXT NOT NULL DEFAULT ''")
connection.execSQL("ALTER TABLE `artists` ADD COLUMN `downloadedCoverArts` TEXT NOT NULL DEFAULT ''")
backfillArtistArt(connection)
}
}
private suspend fun backfillArtistArt(connection: SQLiteConnection) {
val sourceIds = mutableListOf<Long>()
connection.prepare("SELECT id FROM sources").use { statement ->
while (statement.step()) sourceIds += statement.getLong(0)
}
sourceIds.forEach { sourceId ->
connection.prepare(ARTIST_ART_UPDATE_SQL).use { statement ->
statement.bindLong(1, sourceId)
statement.step()
}
}
}
val MIGRATIONS: Array<Migration> =
arrayOf(
MIGRATION_1_2,
@@ -731,4 +754,5 @@ val MIGRATIONS: Array<Migration> =
MIGRATION_32_33,
MIGRATION_33_34,
MIGRATION_34_35,
MIGRATION_35_36,
)
@@ -28,6 +28,7 @@ import com.subtracks.data.model.SongDownload
import com.subtracks.data.model.SongGenre
import com.subtracks.data.model.SongSearch
import com.subtracks.data.model.Source
import com.subtracks.data.model.StringListConverter
import com.subtracks.data.model.SubsonicSource
import com.subtracks.data.model.UpNextEntry
@@ -56,7 +57,7 @@ import com.subtracks.data.model.UpNextEntry
SongDownload::class,
PendingAction::class,
],
version = 35,
version = 36,
exportSchema = true,
)
@ColumnTypeConverters(
@@ -65,6 +66,7 @@ import com.subtracks.data.model.UpNextEntry
DownloadErrorConverter::class,
PendingActionKindConverter::class,
ArtistRefsConverter::class,
StringListConverter::class,
)
abstract class SubtracksDatabase : RoomDatabase() {
abstract fun libraryDao(): LibraryDao
@@ -10,3 +10,8 @@ fun coverArtKey(
coverArt: String,
thumbnail: Boolean,
): String = "$sourceId:$coverArt:$thumbnail"
fun artistImageKey(
sourceId: Long,
artistId: String,
): String = "$sourceId:artist-image:$artistId"
@@ -106,10 +106,12 @@ data class DownloadArtwork(
val sourceId: Long,
val albumId: String?,
val artistId: String?,
val albumArtistId: String?,
val albumRow: String?,
val artistRow: String?,
val albumCoverArt: String?,
val artistCoverArt: String?,
val albumArtistCoverArt: String?,
) {
val hasMissingLibraryRow: Boolean
get() = (albumId != null && albumRow == null) || (artistId != null && artistRow == null)
@@ -88,6 +88,9 @@ data class Artist(
val albumCount: Long,
val starred: Long?,
val coverArt: String? = null,
val artistImageUrl: String? = null,
@ColumnInfo(defaultValue = "''") val coverArts: List<String> = emptyList(),
@ColumnInfo(defaultValue = "''") val downloadedCoverArts: List<String> = emptyList(),
@ColumnInfo(defaultValue = "0") val playCount: Long = 0,
val played: Long? = null,
@ColumnInfo(defaultValue = "0") val songCount: Long = 0,
@@ -592,6 +595,16 @@ class ArtistRefsConverter {
fun toArtistRefs(value: String): List<ArtistRef> = ArtistRefsCodec.decode(value)
}
class StringListConverter {
@ColumnTypeConverter
fun fromList(values: List<String>): String = values.joinToString(LIST_SEPARATOR)
@ColumnTypeConverter
fun toList(value: String): List<String> = if (value.isEmpty()) emptyList() else value.split(LIST_SEPARATOR)
}
private const val LIST_SEPARATOR = "\u001F"
object ArtistRefsCodec {
fun encode(refs: List<ArtistRef>): String =
buildString {
@@ -11,6 +11,7 @@ import com.subtracks.data.download.EngineDownload
import com.subtracks.data.download.EngineRequest
import com.subtracks.data.download.EngineStatus
import com.subtracks.data.download.looksLikeAudio
import com.subtracks.data.model.Artist
import com.subtracks.data.model.ArtistBio
import com.subtracks.data.model.BulkDownloadAction
import com.subtracks.data.model.DownloadError
@@ -19,6 +20,7 @@ import com.subtracks.data.model.DownloadStatus
import com.subtracks.data.model.DownloadedSong
import com.subtracks.data.model.ListDownloadStatus
import com.subtracks.data.model.SongDownload
import com.subtracks.data.model.artistImageKey
import com.subtracks.data.model.coverArtKey
import com.subtracks.log.Log
import kotlinx.coroutines.CancellationException
@@ -350,6 +352,9 @@ class DownloadRepository(
rows.groupBy { it.sourceId }.forEach { (sourceId, group) ->
group.forEach { file(sourceId, it.songId).delete() }
group.map { it.songId }.chunked(DELETE_CHUNK).forEach { db.downloadDao().deleteSongs(sourceId, it) }
if (group.any { it.status == DownloadStatus.Completed }) {
refreshDownloadedArtistArt(sourceId, group.map { it.songId })
}
}
sweep(db.downloadDao().all())
}
@@ -413,13 +418,18 @@ class DownloadRepository(
) {
val song = db.libraryDao().songOnce(sourceId, songId)
val album = song?.albumId?.let { db.libraryDao().albumOnce(sourceId, it) }
val songArtist = song?.artistId?.let { db.libraryDao().artistOnce(sourceId, it) }
val albumArtist = album?.artistId?.let { db.libraryDao().artistOnce(sourceId, it) }
listOfNotNull(song?.artistId, album?.artistId).forEach(artistIds::add)
listOfNotNull(songArtist, albumArtist).forEach { storeArtistImage(sourceId, it) }
val artistFallbackCovers =
listOfNotNull(songArtist, albumArtist)
.filter { it.artistImageUrl.isNullOrBlank() }
.mapNotNull { it.coverArt }
val coverArt =
(
listOfNotNull(
album?.coverArt,
song?.artistId?.let { db.libraryDao().artistOnce(sourceId, it)?.coverArt },
) + db.downloadDao().playlistCoversForSong(sourceId, songId)
listOfNotNull(album?.coverArt) + artistFallbackCovers +
db.downloadDao().playlistCoversForSong(sourceId, songId)
).distinct()
coverArt.forEach { cover ->
storeArtwork(sourceId, cover, thumbnail = false)
@@ -427,6 +437,33 @@ class DownloadRepository(
}
}
private suspend fun storeArtistImage(
sourceId: Long,
artist: Artist,
) {
val imageUrl = artist.artistImageUrl
if (!imageUrl.isNullOrBlank()) {
val cacheKey = artistImageKey(sourceId, artist.id)
if (artworkStore.file(sourceId, cacheKey).exists()) return
val bytes =
try {
artworkFetcher.fetch(imageUrl)
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
null
}
if (bytes != null && bytes.isNotEmpty()) {
artworkStore.write(sourceId, cacheKey, bytes)
return
}
}
artist.coverArt?.let {
storeArtwork(sourceId, it, thumbnail = false)
storeArtwork(sourceId, it, thumbnail = true)
}
}
private suspend fun fetchArtistBio(
sourceId: Long,
artistId: String,
@@ -491,11 +528,15 @@ class DownloadRepository(
.mapValues { (sourceId, rows) ->
if (rows.any { it.hasMissingLibraryRow }) return@mapValues null
val covers =
rows.flatMap { row -> listOfNotNull(row.albumCoverArt, row.artistCoverArt) } +
playlistCovers[sourceId].orEmpty()
covers
.flatMap { cover -> listOf(false, true).map { thumbnail -> coverArtKey(sourceId, cover, thumbnail) } }
.toSet()
rows.flatMap { row ->
listOfNotNull(row.albumCoverArt, row.artistCoverArt, row.albumArtistCoverArt)
} + playlistCovers[sourceId].orEmpty()
val artistImages = rows.flatMap { row -> listOfNotNull(row.artistId, row.albumArtistId) }
(
covers.flatMap { cover ->
listOf(false, true).map { thumbnail -> coverArtKey(sourceId, cover, thumbnail) }
} + artistImages.map { artistImageKey(sourceId, it) }
).toSet()
}
}
@@ -545,6 +586,7 @@ class DownloadRepository(
row.engineId?.let { engine.cancel(listOf(it)) }
file(sourceId, songId).delete()
db.downloadDao().delete(sourceId, songId)
if (row.status == DownloadStatus.Completed) refreshDownloadedArtistArt(sourceId, listOf(songId))
return true
}
@@ -625,6 +667,11 @@ class DownloadRepository(
},
)
if (updated != row) db.downloadDao().upsert(updated)
if (updated.status != row.status &&
(updated.status == DownloadStatus.Completed || row.status == DownloadStatus.Completed)
) {
refreshDownloadedArtistArt(row.sourceId, listOf(row.songId))
}
if (updated.status != row.status) {
when (updated.status) {
DownloadStatus.Completed -> Log.i("download", "completed ${row.songId} (${updated.total} bytes)")
@@ -642,9 +689,22 @@ class DownloadRepository(
row.engineId?.let { engine.cancel(listOf(it)) }
file(row.sourceId, row.songId).delete()
db.downloadDao().upsert(row.copy(status = DownloadStatus.Failed, engineId = null, error = error))
if (row.status == DownloadStatus.Completed) {
refreshDownloadedArtistArt(row.sourceId, listOf(row.songId))
}
Log.w("download", "failed ${row.songId}: $error")
}
private suspend fun refreshDownloadedArtistArt(
sourceId: Long,
songIds: Collection<String>,
) {
songIds.chunked(500).forEach { songs ->
val artistIds = db.libraryDao().creditedArtistIdsForSongs(sourceId, songs).distinct()
artistIds.chunked(500).forEach { db.libraryDao().recomputeDownloadedArtistArt(sourceId, it) }
}
}
private fun ensurePolling() {
if (pollJob?.isActive == true) return
pollJob =
@@ -353,16 +353,6 @@ class LibraryRepository(
artistId: String,
): Flow<Artist?> = db.libraryDao().artist(sourceId, artistId)
suspend fun artistOwnCoverArt(
sourceId: Long,
artistId: String,
): String? = db.libraryDao().artistOwnCoverArt(sourceId, artistId)
suspend fun artistFallbackCoverArts(
sourceId: Long,
artistId: String,
): List<String> = db.libraryDao().artistFallbackCoverArts(sourceId, artistId)
fun artistName(
sourceId: Long,
artistId: String,
@@ -9,6 +9,7 @@ import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.Source
import com.subtracks.data.model.SubsonicConfig
import com.subtracks.data.model.SubsonicSource
import com.subtracks.data.model.artistImageKey
import com.subtracks.data.model.coverArtKey
import com.subtracks.data.net.NetworkMode
import com.subtracks.data.prefs.StreamQuality
@@ -143,6 +144,18 @@ class SourceRepository(
return CoverArtRef(url = url, cacheKey = cacheKey)
}
fun artistImage(
artistId: String,
url: String?,
): CoverArtRef? {
val current = active ?: return null
if (url.isNullOrBlank()) return null
val cacheKey = artistImageKey(current.id, artistId)
artworkStore.uri(current.id, cacheKey)?.let { return CoverArtRef(url = it, cacheKey = cacheKey) }
if (_offline.value) return null
return CoverArtRef(url = url, cacheKey = cacheKey)
}
fun networkCoverArt(
sourceId: Long,
coverArt: String,
@@ -32,6 +32,7 @@ object SubsonicXml {
albumCount = attrs.longAttr("albumCount") ?: 0L,
starred = attrs.dateAttr("starred"),
coverArt = attrs.attr("coverArt").ifEmpty { null },
artistImageUrl = attrs.attr("artistImageUrl").ifEmpty { null },
sortName = attrs.attr("sortName").takeIf { it.isNotBlank() } ?: attrs.attr("name"),
)
@@ -438,7 +438,7 @@ private fun discHash(
private val primaryKey = listOf("sourceId", "id")
private val artistColumns = listOf("sourceId", "id", "name", "albumCount", "starred", "coverArt", "sortName")
private val artistColumns = listOf("sourceId", "id", "name", "albumCount", "starred", "coverArt", "artistImageUrl", "sortName")
private val albumColumns =
listOf(
@@ -515,7 +515,7 @@ private val playlistSongColumns = listOf("sourceId", "playlistId", "songId", "po
private val playlistSongKey = listOf("sourceId", "playlistId", "position")
private fun Artist.values() = listOf<Any?>(sourceId, id, name, albumCount, starred, coverArt, sortName)
private fun Artist.values() = listOf<Any?>(sourceId, id, name, albumCount, starred, coverArt, artistImageUrl, sortName)
private fun Album.values() =
listOf<Any?>(
@@ -375,7 +375,15 @@ private fun MainNavigation() {
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
},
onArtistClick = { artist ->
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artist.id, Routes.artist(artist.id, artist.coverArt))
navigateDetail(
Routes.ARTIST_DETAIL,
"artistId",
artist.id,
Routes.artist(
artist.id,
artist.coverArt ?: artist.coverArts.firstOrNull(),
),
)
},
onPlaylistClick = { playlist ->
navigateDetail(Routes.PLAYLIST_DETAIL, "playlistId", playlist.id, Routes.playlist(playlist.id))
@@ -418,7 +426,15 @@ private fun MainNavigation() {
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
},
onArtistClick = { artist ->
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artist.id, Routes.artist(artist.id, artist.coverArt))
navigateDetail(
Routes.ARTIST_DETAIL,
"artistId",
artist.id,
Routes.artist(
artist.id,
artist.coverArt ?: artist.coverArts.firstOrNull(),
),
)
},
onPlaylistClick = { playlist ->
navigateDetail(Routes.PLAYLIST_DETAIL, "playlistId", playlist.id, Routes.playlist(playlist.id))
@@ -514,7 +530,15 @@ private fun MainNavigation() {
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
},
onArtistClick = { artist ->
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artist.id, Routes.artist(artist.id, artist.coverArt))
navigateDetail(
Routes.ARTIST_DETAIL,
"artistId",
artist.id,
Routes.artist(
artist.id,
artist.coverArt ?: artist.coverArts.firstOrNull(),
),
)
},
onViewAlbum = { albumId -> navigateDetail(Routes.ALBUM_DETAIL, "albumId", albumId, Routes.album(albumId)) },
onViewArtist = { artistId ->
@@ -538,7 +562,15 @@ private fun MainNavigation() {
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
},
onArtistClick = { artist ->
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artist.id, Routes.artist(artist.id, artist.coverArt))
navigateDetail(
Routes.ARTIST_DETAIL,
"artistId",
artist.id,
Routes.artist(
artist.id,
artist.coverArt ?: artist.coverArts.firstOrNull(),
),
)
},
onViewAlbum = { albumId -> navigateDetail(Routes.ALBUM_DETAIL, "albumId", albumId, Routes.album(albumId)) },
onViewArtist = { artistId ->
@@ -562,7 +594,15 @@ private fun MainNavigation() {
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
},
onArtistClick = { artist ->
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artist.id, Routes.artist(artist.id, artist.coverArt))
navigateDetail(
Routes.ARTIST_DETAIL,
"artistId",
artist.id,
Routes.artist(
artist.id,
artist.coverArt ?: artist.coverArts.firstOrNull(),
),
)
},
onViewAlbum = { albumId -> navigateDetail(Routes.ALBUM_DETAIL, "albumId", albumId, Routes.album(albumId)) },
onViewArtist = { artistId ->
@@ -588,7 +628,15 @@ private fun MainNavigation() {
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
},
onArtistClick = { artist ->
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artist.id, Routes.artist(artist.id, artist.coverArt))
navigateDetail(
Routes.ARTIST_DETAIL,
"artistId",
artist.id,
Routes.artist(
artist.id,
artist.coverArt ?: artist.coverArts.firstOrNull(),
),
)
},
onPlaylistClick = { playlist ->
navigateDetail(Routes.PLAYLIST_DETAIL, "playlistId", playlist.id, Routes.playlist(playlist.id))
@@ -7,7 +7,9 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
@@ -19,49 +21,120 @@ import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection
import com.subtracks.data.model.Artist
import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.repo.LibraryRepository
import com.subtracks.data.repo.SourceRepository
import kotlinx.coroutines.flow.first
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.min
import kotlin.math.sin
internal suspend fun loadArtistArtCovers(
library: LibraryRepository,
source: SourceRepository,
artistId: String,
): List<CoverArtRef> {
val sourceId = library.activeSourceId.first() ?: return emptyList()
val own = library.artistOwnCoverArt(sourceId, artistId)
val ids =
if (own != null) {
listOf(own)
} else {
library.artistFallbackCoverArts(sourceId, artistId)
}
return ids.take(4).mapNotNull { source.coverArt(it, thumbnail = true) }
data class ArtistArt(
val image: CoverArtRef? = null,
val covers: List<CoverArtRef> = emptyList(),
val fallback: ArtistArt? = null,
) {
val primary: CoverArtRef? get() = image ?: covers.firstOrNull()
}
fun SourceRepository.artistArt(
artist: Artist,
forIcon: Boolean = true,
): ArtistArt =
resolveArtistArt(
artist = artist,
coverArt = this::coverArt,
artistImage = this::artistImage,
offline = offline.value,
forIcon = forIcon,
)
internal fun resolveArtistArt(
artist: Artist,
coverArt: (String?, Boolean) -> CoverArtRef?,
artistImage: (String, String?) -> CoverArtRef?,
offline: Boolean,
forIcon: Boolean,
): ArtistArt {
val coverIds = if (offline) artist.downloadedCoverArts else artist.coverArts
val grid =
ArtistArt(
covers = coverIds.mapNotNull { coverArt(it, forIcon || coverIds.size > 1) },
)
val own =
artist.coverArt
?.let { coverArt(it, forIcon) }
?.let { ArtistArt(image = it, fallback = grid) }
return artist.artistImageUrl
?.let { artistImage(artist.id, it) }
?.let { ArtistArt(image = it, fallback = own ?: grid) }
?: own
?: grid
}
@Composable
fun ArtistIcon(
artist: Artist,
fallback: CoverArtRef?,
loadCovers: suspend (String) -> List<CoverArtRef>,
art: ArtistArt,
modifier: Modifier = Modifier,
) {
val covers by produceState(initialValue = emptyList<CoverArtRef>(), artist.id) {
value = loadCovers(artist.id)
var shown by remember(artist.id, art) { mutableStateOf(art) }
val showFallback = {
shown.fallback?.let { shown = it } ?: run { shown = shown.copy(image = null) }
}
if (covers.size >= 2) {
ArtistArtGrid(covers = covers, name = artist.name, modifier = modifier)
} else {
CoverArt(
ref = covers.firstOrNull() ?: fallback,
name = artist.name,
showPlaceholder = covers.isEmpty() && fallback == null,
modifier = modifier,
)
when {
shown.image != null -> {
CoverArt(
ref = shown.image,
name = artist.name,
modifier = modifier,
onError = showFallback,
)
}
shown.covers.size >= 2 -> {
ArtistArtGrid(covers = shown.covers, name = artist.name, modifier = modifier)
}
else -> {
CoverArt(ref = shown.covers.firstOrNull(), name = artist.name, modifier = modifier)
}
}
}
@Composable
fun ArtistHeroArt(
art: ArtistArt,
name: String,
thumbnail: CoverArtRef?,
modifier: Modifier = Modifier,
) {
var shown by remember(art) { mutableStateOf(art) }
val showFallback = {
shown.fallback?.let { shown = it } ?: run { shown = shown.copy(image = null) }
}
when {
shown.image != null -> {
CoverArt(
ref = shown.image,
name = name,
thumbnailRef = thumbnail,
showPlaceholder = false,
modifier = modifier,
onError = showFallback,
)
}
shown.covers.isNotEmpty() -> {
ArtistArtGrid(
covers = shown.covers,
name = name,
singleThumbnail = thumbnail,
modifier = modifier,
)
}
else -> {
CoverArt(ref = null, name = name, modifier = modifier)
}
}
}
@@ -85,10 +85,12 @@ fun CoverArt(
square: Boolean = true,
elevation: Dp = 0.dp,
colorFilter: ColorFilter? = null,
onError: (() -> Unit)? = null,
) {
val context = LocalPlatformContext.current
val refKey = ref?.cacheKey
val thumbnailKey = thumbnailRef?.cacheKey
val hasThumbnailFallback = thumbnailRef != null && thumbnailRef != ref
var failed by remember(refKey, thumbnailKey) { mutableStateOf(false) }
var thumbnailLoaded by remember(refKey, thumbnailKey) { mutableStateOf(false) }
var thumbnailRatio by remember(refKey, thumbnailKey) { mutableStateOf(thumbnailKey?.let(ArtworkRatioCache::get)) }
@@ -131,7 +133,10 @@ fun CoverArt(
.ratioOrNull()
?.let { ArtworkRatioCache.put(ref.cacheKey, it) }
},
onError = { failed = true },
onError = {
failed = true
if (!hasThumbnailFallback) onError?.invoke()
},
modifier = Modifier.fillMaxSize(),
)
}
@@ -160,7 +165,10 @@ fun CoverArt(
)
}
},
onError = { failed = true },
onError = {
failed = true
if (!hasThumbnailFallback) onError?.invoke()
},
contentScale = ContentScale.Fit,
filterQuality = FilterQuality.High,
)
@@ -57,6 +57,7 @@ import com.subtracks.data.model.Playlist
import com.subtracks.data.model.Song
import com.subtracks.data.source.StarType
import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.ArtistArt
import com.subtracks.ui.components.ArtistIcon
import com.subtracks.ui.components.ContextMenuHost
import com.subtracks.ui.components.CoverArt
@@ -109,7 +110,7 @@ fun HomeListRoute(
genres = genres,
decades = decades,
coverArt = viewModel::coverArt,
artistCovers = viewModel::artistArtCovers,
artistArt = { viewModel.artistArt(it) },
playingSongId = playback.item?.id,
onBack = onBack,
onAlbumClick = onAlbumClick,
@@ -135,7 +136,7 @@ fun HomeListScreen(
genres: List<String> = emptyList(),
decades: List<Long> = emptyList(),
coverArt: (String?, Boolean) -> CoverArtRef? = { _, _ -> null },
artistCovers: suspend (String) -> List<CoverArtRef> = { emptyList() },
artistArt: (Artist) -> ArtistArt = { ArtistArt() },
playingSongId: String? = null,
onBack: () -> Unit = {},
onAlbumClick: (Album) -> Unit = {},
@@ -194,8 +195,7 @@ fun HomeListScreen(
OfflineListKind.Artists -> {
ArtistsList(
artists = artists,
coverArt = coverArt,
artistCovers = artistCovers,
artistArt = artistArt,
info = { artistListInfo(request, it, now, resources) },
onArtistClick = onArtistClick,
onLongClick = onLongClick,
@@ -249,8 +249,7 @@ fun HomeListScreen(
HomeSection.RecentlyPlayedArtists, HomeSection.MostPlayedArtists -> {
ArtistsList(
artists = artists,
coverArt = coverArt,
artistCovers = artistCovers,
artistArt = artistArt,
info = { artistListInfo(request, it, now, resources) },
onArtistClick = onArtistClick,
onLongClick = onLongClick,
@@ -448,8 +447,7 @@ internal fun AlbumListRow(
@Composable
private fun ArtistsList(
artists: LazyPagingItems<Artist>,
coverArt: (String?, Boolean) -> CoverArtRef?,
artistCovers: suspend (String) -> List<CoverArtRef>,
artistArt: (Artist) -> ArtistArt,
info: (Artist) -> String?,
onArtistClick: (Artist) -> Unit,
onLongClick: (MenuTarget) -> Unit,
@@ -466,13 +464,13 @@ private fun ArtistsList(
items(count = artists.itemCount, key = artists.itemKey { it.id }) { index ->
val artist = artists[index]
if (artist != null) {
val art = artistArt(artist)
ArtistListRow(
artist = artist,
coverArt = coverArt,
artistCovers = artistCovers,
art = art,
info = info(artist),
onClick = { onArtistClick(artist) },
onLongClick = { onLongClick(MenuTarget.Artist(artist, coverArt(artist.coverArt, true))) },
onLongClick = { onLongClick(MenuTarget.Artist(artist, art.primary)) },
)
}
}
@@ -482,11 +480,10 @@ private fun ArtistsList(
@Composable
internal fun ArtistListRow(
artist: Artist,
coverArt: (String?, Boolean) -> CoverArtRef?,
art: ArtistArt,
info: String?,
onClick: () -> Unit,
onLongClick: () -> Unit,
artistCovers: suspend (String) -> List<CoverArtRef> = { emptyList() },
) {
Row(
modifier =
@@ -498,8 +495,7 @@ internal fun ArtistListRow(
) {
ArtistIcon(
artist = artist,
fallback = coverArt(artist.coverArt, true),
loadCovers = artistCovers,
art = art,
modifier = Modifier.size(ALBUM_ROW_COVER).clip(CircleShape),
)
Spacer(Modifier.width(12.dp))
@@ -16,7 +16,8 @@ import com.subtracks.data.prefs.PlaylistSort
import com.subtracks.data.repo.LibraryRepository
import com.subtracks.data.repo.SourceRepository
import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.loadArtistArtCovers
import com.subtracks.ui.components.ArtistArt
import com.subtracks.ui.components.artistArt
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
@@ -157,7 +158,10 @@ class HomeListViewModel(
thumbnail: Boolean,
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
suspend fun artistArtCovers(artistId: String): List<CoverArtRef> = loadArtistArtCovers(libraryRepository, sourceRepository, artistId)
fun artistArt(
artist: Artist,
forIcon: Boolean = true,
): ArtistArt = sourceRepository.artistArt(artist, forIcon)
fun play(song: Song) {
val genre = request.genre
@@ -72,6 +72,7 @@ import com.subtracks.data.model.Playlist
import com.subtracks.data.model.Song
import com.subtracks.data.source.StarType
import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.ArtistArt
import com.subtracks.ui.components.ArtistIcon
import com.subtracks.ui.components.ContextMenuHost
import com.subtracks.ui.components.CoverArt
@@ -138,7 +139,7 @@ fun HomeRoute(
playlists = viewModel.downloadedPlaylists.collectAsLazyPagingItems(),
songs = viewModel.downloadedSongs.collectAsStateWithLifecycle(initialValue = emptyList()).value,
coverArt = viewModel::coverArt,
artistCovers = viewModel::artistArtCovers,
artistArt = { viewModel.artistArt(it) },
playingSongId = playback.item?.id,
topInset = topInset,
bottomInset = bottomInset,
@@ -154,7 +155,7 @@ fun HomeRoute(
HomeScreen(
feed = feed,
coverArt = viewModel::coverArt,
artistCovers = viewModel::artistArtCovers,
artistArt = { viewModel.artistArt(it) },
playingSongId = playback.item?.id,
topInset = topInset,
bottomInset = bottomInset,
@@ -176,7 +177,7 @@ fun HomeScreen(
coverArt: (String?, Boolean) -> CoverArtRef?,
playingSongId: String?,
modifier: Modifier = Modifier,
artistCovers: suspend (String) -> List<CoverArtRef> = { emptyList() },
artistArt: (Artist) -> ArtistArt = { ArtistArt() },
topInset: Dp = 0.dp,
bottomInset: Dp = 0.dp,
onAlbumClick: (Album) -> Unit = {},
@@ -219,7 +220,7 @@ fun HomeScreen(
HomeSection.RecentlyPlayedArtists,
feed.recentlyPlayedArtists,
coverArt,
artistCovers,
artistArt,
onArtistClick,
onLongClick,
onMore,
@@ -254,7 +255,7 @@ fun HomeScreen(
HomeSection.MostPlayedArtists,
feed.mostPlayedArtists,
coverArt,
artistCovers,
artistArt,
onArtistClick,
onLongClick,
onMore,
@@ -302,7 +303,7 @@ fun OfflineHomeScreen(
coverArt: (String?, Boolean) -> CoverArtRef?,
playingSongId: String?,
modifier: Modifier = Modifier,
artistCovers: suspend (String) -> List<CoverArtRef> = { emptyList() },
artistArt: (Artist) -> ArtistArt = { ArtistArt() },
topInset: Dp = 0.dp,
bottomInset: Dp = 0.dp,
onAlbumClick: (Album) -> Unit = {},
@@ -319,7 +320,7 @@ fun OfflineHomeScreen(
) {
homeTitle()
downloadedAlbumRow(albums, coverArt, onAlbumClick, onLongClick) { onMore(OfflineListKind.Albums) }
downloadedArtistRow(artists, coverArt, artistCovers, onArtistClick, onLongClick) { onMore(OfflineListKind.Artists) }
downloadedArtistRow(artists, coverArt, artistArt, onArtistClick, onLongClick) { onMore(OfflineListKind.Artists) }
downloadedPlaylistRow(playlists, coverArt, onPlaylistClick, onLongClick) { onMore(OfflineListKind.Playlists) }
songList(
key = "downloaded-songs",
@@ -372,7 +373,7 @@ private fun LazyListScope.downloadedAlbumRow(
private fun LazyListScope.downloadedArtistRow(
artists: LazyPagingItems<Artist>,
coverArt: (String?, Boolean) -> CoverArtRef?,
artistCovers: suspend (String) -> List<CoverArtRef>,
artistArt: (Artist) -> ArtistArt,
onArtistClick: (Artist) -> Unit,
onLongClick: (MenuTarget) -> Unit,
onMore: () -> Unit,
@@ -390,13 +391,12 @@ private fun LazyListScope.downloadedArtistRow(
items(count = artists.itemCount, key = artists.itemKey { it.id }) { index ->
val artist = artists[index]
if (artist != null) {
val ref = coverArt(artist.coverArt, true)
val art = artistArt(artist)
ArtistTile(
artist = artist,
ref = ref,
artistCovers = artistCovers,
art = art,
onClick = { onArtistClick(artist) },
onLongClick = { onLongClick(MenuTarget.Artist(artist, ref)) },
onLongClick = { onLongClick(MenuTarget.Artist(artist, art.primary)) },
)
}
}
@@ -616,7 +616,7 @@ private fun LazyListScope.artistRow(
section: HomeSection,
artists: List<Artist>,
coverArt: (String?, Boolean) -> CoverArtRef?,
artistCovers: suspend (String) -> List<CoverArtRef>,
artistArt: (Artist) -> ArtistArt,
onArtistClick: (Artist) -> Unit,
onLongClick: (MenuTarget) -> Unit,
onMore: (HomeSection) -> Unit,
@@ -635,13 +635,12 @@ private fun LazyListScope.artistRow(
modifier = Modifier.padding(top = SECTION_CONTENT_TOP),
) {
items(artists, key = { it.id }) { artist ->
val ref = coverArt(artist.coverArt, true)
val art = artistArt(artist)
ArtistTile(
artist = artist,
ref = ref,
artistCovers = artistCovers,
art = art,
onClick = { onArtistClick(artist) },
onLongClick = { onLongClick(MenuTarget.Artist(artist, ref)) },
onLongClick = { onLongClick(MenuTarget.Artist(artist, art.primary)) },
)
}
}
@@ -790,8 +789,7 @@ private fun AlbumTile(
@Composable
private fun ArtistTile(
artist: Artist,
ref: CoverArtRef?,
artistCovers: suspend (String) -> List<CoverArtRef>,
art: ArtistArt,
onClick: () -> Unit,
onLongClick: () -> Unit,
) {
@@ -801,8 +799,7 @@ private fun ArtistTile(
) {
ArtistIcon(
artist = artist,
fallback = ref,
loadCovers = artistCovers,
art = art,
modifier = Modifier.size(ARTIST_TILE_SIZE).clip(CircleShape),
)
Spacer(Modifier.height(6.dp))
@@ -17,7 +17,8 @@ import com.subtracks.data.repo.rediscoverCutoff
import com.subtracks.data.sync.SyncManager
import com.subtracks.data.sync.SyncStatus
import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.loadArtistArtCovers
import com.subtracks.ui.components.ArtistArt
import com.subtracks.ui.components.artistArt
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
@@ -128,7 +129,10 @@ class HomeViewModel(
thumbnail: Boolean,
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
suspend fun artistArtCovers(artistId: String): List<CoverArtRef> = loadArtistArtCovers(libraryRepository, sourceRepository, artistId)
fun artistArt(
artist: Artist,
forIcon: Boolean = true,
): ArtistArt = sourceRepository.artistArt(artist, forIcon)
fun playStarred(song: Song) {
viewModelScope.playStarredList(libraryRepository, playbackController, song)
@@ -102,7 +102,8 @@ import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.ListDownloadStatus
import com.subtracks.data.source.StarType
import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.ArtistArtGrid
import com.subtracks.ui.components.ArtistArt
import com.subtracks.ui.components.ArtistHeroArt
import com.subtracks.ui.components.CloseWhenDownloadsGone
import com.subtracks.ui.components.ContextMenuHost
import com.subtracks.ui.components.CoverArt
@@ -205,7 +206,7 @@ fun ArtistDetailRoute(
LaunchedEffect(artistId) { viewModel.refreshBio() }
val art by viewModel.art.collectAsStateWithLifecycle()
val artThumbnail by viewModel.artThumbnail.collectAsStateWithLifecycle()
val artCovers by viewModel.artCovers.collectAsStateWithLifecycle()
val heroArt by viewModel.heroArt.collectAsStateWithLifecycle()
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
val offline by viewModel.offline.collectAsStateWithLifecycle()
CloseWhenDownloadsGone(downloadStatus.downloaded, offline, onBack)
@@ -245,7 +246,7 @@ fun ArtistDetailRoute(
bioExpanded = bioExpanded,
art = art,
artThumbnail = artThumbnail,
artCovers = artCovers,
heroArt = heroArt,
artwork = rememberArtworkColors(shortcutArt ?: artThumbnail ?: art, THEME_TRANSITION_MS, fallbackName = artistName),
coverArt = viewModel::coverArt,
onBack = onBack,
@@ -286,6 +287,7 @@ fun ArtistDetailScreen(
art: CoverArtRef?,
artThumbnail: CoverArtRef? = null,
artCovers: List<CoverArtRef> = emptyList(),
heroArt: ArtistArt? = null,
artwork: ArtworkColors? = null,
coverArt: (String?, Boolean) -> CoverArtRef?,
onBack: () -> Unit,
@@ -403,22 +405,16 @@ fun ArtistDetailScreen(
.onSizeChanged { imageHeightPx = it.height.toFloat() },
) {
Box(Modifier.fillMaxWidth()) {
if (artCovers.isNotEmpty()) {
ArtistArtGrid(
covers = artCovers,
name = displayName,
singleThumbnail = artThumbnail,
modifier = Modifier.fillMaxWidth().height(ART_HEIGHT),
)
} else {
CoverArt(
ref = art,
name = displayName,
thumbnailRef = artThumbnail,
showPlaceholder = art == null,
modifier = Modifier.fillMaxWidth().height(ART_HEIGHT),
)
}
ArtistHeroArt(
art =
heroArt ?: ArtistArt(
image = art.takeIf { artCovers.isEmpty() },
covers = artCovers,
),
name = displayName,
thumbnail = artThumbnail,
modifier = Modifier.fillMaxWidth().height(ART_HEIGHT),
)
if (nameScrimAlpha > 0f) {
Box(
modifier =
@@ -13,6 +13,8 @@ import com.subtracks.data.repo.DownloadRepository
import com.subtracks.data.repo.LibraryRepository
import com.subtracks.data.repo.SourceRepository
import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.ArtistArt
import com.subtracks.ui.components.artistArt
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
@@ -136,41 +138,22 @@ class ArtistDetailViewModel(
private val _artThumbnail = MutableStateFlow<CoverArtRef?>(null)
val artThumbnail: StateFlow<CoverArtRef?> = _artThumbnail
private val _artCovers = MutableStateFlow<List<CoverArtRef>>(emptyList())
val artCovers: StateFlow<List<CoverArtRef>> = _artCovers
private val _heroArt = MutableStateFlow<ArtistArt?>(null)
val heroArt: StateFlow<ArtistArt?> = _heroArt
init {
viewModelScope.launch {
artist
.filterNotNull()
combine(artist.filterNotNull(), sourceRepository.offline) { loaded, _ -> loaded }
.collectLatest { loaded ->
val (full, thumbnail, covers) = resolveArt(loaded)
_art.value = full
_artThumbnail.value = thumbnail
_artCovers.value = covers
val hero = sourceRepository.artistArt(loaded, forIcon = false)
val icon = sourceRepository.artistArt(loaded, forIcon = true)
_art.value = hero.image ?: hero.covers.firstOrNull()
_artThumbnail.value = icon.image ?: icon.covers.firstOrNull()
_heroArt.value = hero
}
}
}
private suspend fun resolveArt(artist: Artist): Triple<CoverArtRef?, CoverArtRef?, List<CoverArtRef>> {
val id = sourceId.first()
val own = libraryRepository.artistOwnCoverArt(id, artistId)
val coverIds =
if (own != null) {
listOf(own)
} else {
libraryRepository.artistFallbackCoverArts(id, artistId).distinct()
}
if (coverIds.isEmpty()) return Triple(null, null, emptyList())
val thumbnails = coverIds.mapNotNull { sourceRepository.coverArt(it, thumbnail = true) }
val fulls = coverIds.mapNotNull { sourceRepository.coverArt(it) }
val full = fulls.firstOrNull()
val thumbnail = thumbnails.firstOrNull()
val covers = if (coverIds.size == 1) listOfNotNull(full ?: thumbnail) else thumbnails
return Triple(full ?: thumbnail, thumbnail, covers)
}
fun coverArt(
coverArt: String?,
thumbnail: Boolean,
@@ -29,6 +29,7 @@ import com.subtracks.R
import com.subtracks.data.model.Artist
import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.ListDownloadStatus
import com.subtracks.ui.components.ArtistArt
import com.subtracks.ui.components.ArtistIcon
import com.subtracks.ui.components.EmptyState
import com.subtracks.ui.components.FilteredEmptyState
@@ -55,7 +56,7 @@ fun ArtistsContent(
onSync: () -> Unit = {},
downloadStatuses: Map<String, ListDownloadStatus> = emptyMap(),
scrollToTopKey: Any? = null,
artistCovers: suspend (String) -> List<CoverArtRef> = { emptyList() },
artistArt: (Artist) -> ArtistArt = { ArtistArt() },
) {
when {
items.itemCount == 0 && items.loadState.refresh is LoadState.Loading -> {
@@ -83,7 +84,8 @@ fun ArtistsContent(
items(count = items.itemCount, key = items.itemKey { it.id }) { index ->
val artist = items[index]
if (artist != null) {
PrefetchArtworkSeeds(coverArt(artist.coverArt, true))
val art = artistArt(artist)
PrefetchArtworkSeeds(art.primary)
ListItem(
headlineContent = {
Text(
@@ -107,8 +109,7 @@ fun ArtistsContent(
leadingContent = {
ArtistIcon(
artist = artist,
fallback = coverArt(artist.coverArt, true),
loadCovers = artistCovers,
art = art,
modifier = Modifier.size(48.dp).clip(CircleShape),
)
},
@@ -119,7 +120,7 @@ fun ArtistsContent(
Modifier.combinedClickable(
onClick = { onArtistClick(artist) },
onLongClick = {
onLongClick(MenuTarget.Artist(artist, coverArt(artist.coverArt, true), downloadStatuses[artist.id]))
onLongClick(MenuTarget.Artist(artist, art.primary, downloadStatuses[artist.id]))
},
),
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
@@ -120,6 +120,7 @@ import com.subtracks.data.prefs.PlaylistSort
import com.subtracks.data.prefs.StarredFilter
import com.subtracks.data.source.StarType
import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.ArtistArt
import com.subtracks.ui.components.ContextMenuHost
import com.subtracks.ui.components.DeleteDownloadsDialog
import com.subtracks.ui.components.DismissOnRequest
@@ -308,7 +309,7 @@ fun LibraryRoute(
artists = viewModel.artists,
playlists = viewModel.playlists,
coverArt = viewModel::coverArt,
artistCovers = viewModel::artistArtCovers,
artistArt = { viewModel.artistArt(it) },
onAlbumClick = onAlbumClick,
onArtistClick = onArtistClick,
onPlaylistClick = onPlaylistClick,
@@ -397,7 +398,7 @@ fun LibraryScreen(
onSync: () -> Unit,
onOpenSettings: () -> Unit,
modifier: Modifier = Modifier,
artistCovers: suspend (String) -> List<CoverArtRef> = { emptyList() },
artistArt: (Artist) -> ArtistArt = { ArtistArt() },
onOpenSearch: () -> Unit = {},
offline: Boolean = false,
onExitOffline: () -> Unit = {},
@@ -541,7 +542,7 @@ fun LibraryScreen(
onSync = onSync,
downloadStatuses = artistDownloads,
scrollToTopKey = scrollToTopKey,
artistCovers = artistCovers,
artistArt = artistArt,
)
}
@@ -23,7 +23,8 @@ import com.subtracks.data.repo.SourceRepository
import com.subtracks.data.sync.SyncManager
import com.subtracks.data.sync.SyncStatus
import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.loadArtistArtCovers
import com.subtracks.ui.components.ArtistArt
import com.subtracks.ui.components.artistArt
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
@@ -212,7 +213,10 @@ class LibraryViewModel(
thumbnail: Boolean,
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
suspend fun artistArtCovers(artistId: String): List<CoverArtRef> = loadArtistArtCovers(libraryRepository, sourceRepository, artistId)
fun artistArt(
artist: Artist,
forIcon: Boolean = true,
): ArtistArt = sourceRepository.artistArt(artist, forIcon)
fun sync() {
if (syncManager.status.value !is SyncStatus.Running) syncManager.requestSync()
@@ -46,6 +46,7 @@ import com.subtracks.data.model.Playlist
import com.subtracks.data.model.Song
import com.subtracks.data.source.StarType
import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.ArtistArt
import com.subtracks.ui.components.ContextMenuHost
import com.subtracks.ui.components.EmptyState
import com.subtracks.ui.components.ItemActions
@@ -106,7 +107,7 @@ fun SearchRoute(
if (next != 0) filterMask = next
},
coverArt = viewModel::coverArt,
artistCovers = viewModel::artistArtCovers,
artistArt = { viewModel.artistArt(it) },
playingSongId = playback.item?.id,
onBack = onBack,
onAlbumClick = {
@@ -147,7 +148,7 @@ fun SearchScreen(
filters: Set<SearchFilter> = ALL_FILTERS,
onToggleFilter: (SearchFilter) -> Unit = {},
coverArt: (String?, Boolean) -> CoverArtRef? = { _, _ -> null },
artistCovers: suspend (String) -> List<CoverArtRef> = { emptyList() },
artistArt: (Artist) -> ArtistArt = { ArtistArt() },
playingSongId: String? = null,
onBack: () -> Unit = {},
onAlbumClick: (Album) -> Unit = {},
@@ -217,7 +218,7 @@ fun SearchScreen(
contentPadding = PaddingValues(bottom = 24.dp),
) {
if (showPlaylists) playlistResults(results.playlists, playlistsLabel, coverArt, onPlaylistClick, onLongClick)
if (showArtists) artistResults(results.artists, artistsLabel, coverArt, artistCovers, onArtistClick, onLongClick)
if (showArtists) artistResults(results.artists, artistsLabel, artistArt, onArtistClick, onLongClick)
if (showAlbums) albumResults(results.albums, albumsLabel, coverArt, onAlbumClick, onLongClick)
if (showSongs) songResults(results.songs, songsLabel, coverArt, playingSongId, onSongClick, onLongClick)
}
@@ -310,21 +311,20 @@ private fun LazyListScope.albumResults(
private fun LazyListScope.artistResults(
artists: List<Artist>,
header: String,
coverArt: (String?, Boolean) -> CoverArtRef?,
artistCovers: suspend (String) -> List<CoverArtRef>,
artistArt: (Artist) -> ArtistArt,
onClick: (Artist) -> Unit,
onLongClick: (MenuTarget) -> Unit,
) {
if (artists.isEmpty()) return
resultHeader(header)
items(artists, key = { "artist-${it.id}" }) { artist ->
val art = artistArt(artist)
ArtistListRow(
artist = artist,
coverArt = coverArt,
artistCovers = artistCovers,
art = art,
info = null,
onClick = { onClick(artist) },
onLongClick = { onLongClick(MenuTarget.Artist(artist, coverArt(artist.coverArt, true))) },
onLongClick = { onLongClick(MenuTarget.Artist(artist, art.primary)) },
)
}
}
@@ -12,7 +12,8 @@ import com.subtracks.data.model.Song
import com.subtracks.data.repo.LibraryRepository
import com.subtracks.data.repo.SourceRepository
import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.loadArtistArtCovers
import com.subtracks.ui.components.ArtistArt
import com.subtracks.ui.components.artistArt
import com.subtracks.ui.home.playSongInContext
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
@@ -75,7 +76,10 @@ class SearchViewModel(
thumbnail: Boolean,
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
suspend fun artistArtCovers(artistId: String): List<CoverArtRef> = loadArtistArtCovers(libraryRepository, sourceRepository, artistId)
fun artistArt(
artist: Artist,
forIcon: Boolean = true,
): ArtistArt = sourceRepository.artistArt(artist, forIcon)
fun play(song: Song) {
viewModelScope.playSongInContext(libraryRepository, playbackController, song)
@@ -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,
)
}