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( @Query(
"SELECT d.sourceId AS sourceId, s.albumId AS albumId, s.artistId AS artistId, " + "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, " + "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 " + "FROM song_downloads d " +
"LEFT JOIN songs s ON s.sourceId = d.sourceId AND s.id = d.songId " + "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 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> 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 // 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. // downloaded, while a track credit includes it only when the credited track is downloaded.
private const val DOWNLOADED_ARTIST_ALBUMS = internal const val DOWNLOADED_ALBUM_ROWS =
"SELECT albums.id FROM albums " + "SELECT albums.id AS albumId, albums.artistId AS artistId FROM albums " +
"WHERE albums.sourceId = :sourceId AND albums.artistId = artists.id " + "WHERE albums.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
"AND EXISTS (SELECT 1 FROM songs " + "AND EXISTS (SELECT 1 FROM songs " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " + "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') " + "WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
"UNION " + "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 " + "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 " + "AND EXISTS (SELECT 1 FROM songs " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " + "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') " + "WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
"UNION " + "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 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 " + "JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
"WHERE songs.sourceId = :sourceId AND songs.artistId = artists.id " + "WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL AND sd.status = 'Completed' " +
"AND sd.status = 'Completed' " +
"UNION " + "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 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 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 " + "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 " + "WHERE song_artists.sourceId = :sourceId AND sd.status = 'Completed'"
"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 = internal const val ARTISTS_FILTER =
"FROM artists WHERE sourceId = :sourceId " + "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 (length(:search) < 3 AND (instr(lower(name), lower(:search)) > 0 " +
"OR instr(lower(sortName), 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 = internal const val ARTIST_COLUMNS =
"artists.sourceId, artists.id, artists.name, artists.albumCount, artists.starred, " + "artists.sourceId, artists.id, artists.name, artists.albumCount, artists.starred, " +
"$ARTIST_COVER_ART AS coverArt, artists.playCount, artists.played, artists.songCount, " + "artists.coverArt, artists.artistImageUrl, artists.coverArts, artists.downloadedCoverArts, " +
"artists.sortName, artists.credited" "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 // 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. // an artist matches the albums its detail screen lists.
@@ -206,8 +269,8 @@ internal const val ARTISTS_SELECT =
"CASE WHEN :downloadedFilter = 1 THEN (" + "CASE WHEN :downloadedFilter = 1 THEN (" +
"SELECT COUNT(*) FROM ($DOWNLOADED_ARTIST_ALBUMS)" + "SELECT COUNT(*) FROM ($DOWNLOADED_ARTIST_ALBUMS)" +
") ELSE artists.albumCount END AS albumCount, " + ") ELSE artists.albumCount END AS albumCount, " +
"artists.starred, $ARTIST_COVER_ART AS coverArt, artists.playCount, artists.played, artists.songCount, " + "artists.starred, artists.coverArt, artists.artistImageUrl, artists.coverArts, artists.downloadedCoverArts, " +
"artists.sortName, artists.credited " "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 = "sortName COLLATE NOCASE, id"
internal const val ARTIST_ORDER_BY_NAME_REVERSED = "sortName COLLATE NOCASE DESC, id DESC" 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) 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 @Transaction
suspend fun recomputeAggregates(sourceId: Long) { suspend fun recomputeAggregates(sourceId: Long) {
recomputeAlbumPlayData(sourceId) recomputeAlbumPlayData(sourceId)
recomputeArtistAggregates(sourceId) recomputeArtistAggregates(sourceId)
recomputeArtistArt(sourceId)
} }
@Query( @Query(
@@ -381,6 +454,12 @@ interface LibraryDao {
songId: String, songId: String,
): 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 // 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. // sync then overwrites with the server's own number.
@Transaction @Transaction
@@ -1070,26 +1149,6 @@ interface LibraryDao {
artistId: String, artistId: String,
): Flow<Artist?> ): 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") @Query("SELECT * FROM artist_bios WHERE sourceId = :sourceId AND artistId = :artistId")
fun artistBio( fun artistBio(
sourceId: Long, sourceId: Long,
@@ -695,6 +695,29 @@ private suspend fun createSearchIndex(
connection.execSQL("INSERT INTO `$table`(`$table`) VALUES('rebuild')") 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> = val MIGRATIONS: Array<Migration> =
arrayOf( arrayOf(
MIGRATION_1_2, MIGRATION_1_2,
@@ -731,4 +754,5 @@ val MIGRATIONS: Array<Migration> =
MIGRATION_32_33, MIGRATION_32_33,
MIGRATION_33_34, MIGRATION_33_34,
MIGRATION_34_35, 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.SongGenre
import com.subtracks.data.model.SongSearch import com.subtracks.data.model.SongSearch
import com.subtracks.data.model.Source import com.subtracks.data.model.Source
import com.subtracks.data.model.StringListConverter
import com.subtracks.data.model.SubsonicSource import com.subtracks.data.model.SubsonicSource
import com.subtracks.data.model.UpNextEntry import com.subtracks.data.model.UpNextEntry
@@ -56,7 +57,7 @@ import com.subtracks.data.model.UpNextEntry
SongDownload::class, SongDownload::class,
PendingAction::class, PendingAction::class,
], ],
version = 35, version = 36,
exportSchema = true, exportSchema = true,
) )
@ColumnTypeConverters( @ColumnTypeConverters(
@@ -65,6 +66,7 @@ import com.subtracks.data.model.UpNextEntry
DownloadErrorConverter::class, DownloadErrorConverter::class,
PendingActionKindConverter::class, PendingActionKindConverter::class,
ArtistRefsConverter::class, ArtistRefsConverter::class,
StringListConverter::class,
) )
abstract class SubtracksDatabase : RoomDatabase() { abstract class SubtracksDatabase : RoomDatabase() {
abstract fun libraryDao(): LibraryDao abstract fun libraryDao(): LibraryDao
@@ -10,3 +10,8 @@ fun coverArtKey(
coverArt: String, coverArt: String,
thumbnail: Boolean, thumbnail: Boolean,
): String = "$sourceId:$coverArt:$thumbnail" ): 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 sourceId: Long,
val albumId: String?, val albumId: String?,
val artistId: String?, val artistId: String?,
val albumArtistId: String?,
val albumRow: String?, val albumRow: String?,
val artistRow: String?, val artistRow: String?,
val albumCoverArt: String?, val albumCoverArt: String?,
val artistCoverArt: String?, val artistCoverArt: String?,
val albumArtistCoverArt: String?,
) { ) {
val hasMissingLibraryRow: Boolean val hasMissingLibraryRow: Boolean
get() = (albumId != null && albumRow == null) || (artistId != null && artistRow == null) get() = (albumId != null && albumRow == null) || (artistId != null && artistRow == null)
@@ -88,6 +88,9 @@ data class Artist(
val albumCount: Long, val albumCount: Long,
val starred: Long?, val starred: Long?,
val coverArt: String? = null, 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, @ColumnInfo(defaultValue = "0") val playCount: Long = 0,
val played: Long? = null, val played: Long? = null,
@ColumnInfo(defaultValue = "0") val songCount: Long = 0, @ColumnInfo(defaultValue = "0") val songCount: Long = 0,
@@ -592,6 +595,16 @@ class ArtistRefsConverter {
fun toArtistRefs(value: String): List<ArtistRef> = ArtistRefsCodec.decode(value) 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 { object ArtistRefsCodec {
fun encode(refs: List<ArtistRef>): String = fun encode(refs: List<ArtistRef>): String =
buildString { buildString {
@@ -11,6 +11,7 @@ import com.subtracks.data.download.EngineDownload
import com.subtracks.data.download.EngineRequest import com.subtracks.data.download.EngineRequest
import com.subtracks.data.download.EngineStatus import com.subtracks.data.download.EngineStatus
import com.subtracks.data.download.looksLikeAudio import com.subtracks.data.download.looksLikeAudio
import com.subtracks.data.model.Artist
import com.subtracks.data.model.ArtistBio import com.subtracks.data.model.ArtistBio
import com.subtracks.data.model.BulkDownloadAction import com.subtracks.data.model.BulkDownloadAction
import com.subtracks.data.model.DownloadError 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.DownloadedSong
import com.subtracks.data.model.ListDownloadStatus import com.subtracks.data.model.ListDownloadStatus
import com.subtracks.data.model.SongDownload import com.subtracks.data.model.SongDownload
import com.subtracks.data.model.artistImageKey
import com.subtracks.data.model.coverArtKey import com.subtracks.data.model.coverArtKey
import com.subtracks.log.Log import com.subtracks.log.Log
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
@@ -350,6 +352,9 @@ class DownloadRepository(
rows.groupBy { it.sourceId }.forEach { (sourceId, group) -> rows.groupBy { it.sourceId }.forEach { (sourceId, group) ->
group.forEach { file(sourceId, it.songId).delete() } group.forEach { file(sourceId, it.songId).delete() }
group.map { it.songId }.chunked(DELETE_CHUNK).forEach { db.downloadDao().deleteSongs(sourceId, it) } 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()) sweep(db.downloadDao().all())
} }
@@ -413,13 +418,18 @@ class DownloadRepository(
) { ) {
val song = db.libraryDao().songOnce(sourceId, songId) val song = db.libraryDao().songOnce(sourceId, songId)
val album = song?.albumId?.let { db.libraryDao().albumOnce(sourceId, it) } 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(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 = val coverArt =
( (
listOfNotNull( listOfNotNull(album?.coverArt) + artistFallbackCovers +
album?.coverArt, db.downloadDao().playlistCoversForSong(sourceId, songId)
song?.artistId?.let { db.libraryDao().artistOnce(sourceId, it)?.coverArt },
) + db.downloadDao().playlistCoversForSong(sourceId, songId)
).distinct() ).distinct()
coverArt.forEach { cover -> coverArt.forEach { cover ->
storeArtwork(sourceId, cover, thumbnail = false) 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( private suspend fun fetchArtistBio(
sourceId: Long, sourceId: Long,
artistId: String, artistId: String,
@@ -491,11 +528,15 @@ class DownloadRepository(
.mapValues { (sourceId, rows) -> .mapValues { (sourceId, rows) ->
if (rows.any { it.hasMissingLibraryRow }) return@mapValues null if (rows.any { it.hasMissingLibraryRow }) return@mapValues null
val covers = val covers =
rows.flatMap { row -> listOfNotNull(row.albumCoverArt, row.artistCoverArt) } + rows.flatMap { row ->
playlistCovers[sourceId].orEmpty() listOfNotNull(row.albumCoverArt, row.artistCoverArt, row.albumArtistCoverArt)
covers } + playlistCovers[sourceId].orEmpty()
.flatMap { cover -> listOf(false, true).map { thumbnail -> coverArtKey(sourceId, cover, thumbnail) } } val artistImages = rows.flatMap { row -> listOfNotNull(row.artistId, row.albumArtistId) }
.toSet() (
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)) } row.engineId?.let { engine.cancel(listOf(it)) }
file(sourceId, songId).delete() file(sourceId, songId).delete()
db.downloadDao().delete(sourceId, songId) db.downloadDao().delete(sourceId, songId)
if (row.status == DownloadStatus.Completed) refreshDownloadedArtistArt(sourceId, listOf(songId))
return true return true
} }
@@ -625,6 +667,11 @@ class DownloadRepository(
}, },
) )
if (updated != row) db.downloadDao().upsert(updated) 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) { if (updated.status != row.status) {
when (updated.status) { when (updated.status) {
DownloadStatus.Completed -> Log.i("download", "completed ${row.songId} (${updated.total} bytes)") DownloadStatus.Completed -> Log.i("download", "completed ${row.songId} (${updated.total} bytes)")
@@ -642,9 +689,22 @@ class DownloadRepository(
row.engineId?.let { engine.cancel(listOf(it)) } row.engineId?.let { engine.cancel(listOf(it)) }
file(row.sourceId, row.songId).delete() file(row.sourceId, row.songId).delete()
db.downloadDao().upsert(row.copy(status = DownloadStatus.Failed, engineId = null, error = error)) 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") 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() { private fun ensurePolling() {
if (pollJob?.isActive == true) return if (pollJob?.isActive == true) return
pollJob = pollJob =
@@ -353,16 +353,6 @@ class LibraryRepository(
artistId: String, artistId: String,
): Flow<Artist?> = db.libraryDao().artist(sourceId, artistId) ): 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( fun artistName(
sourceId: Long, sourceId: Long,
artistId: String, artistId: String,
@@ -9,6 +9,7 @@ import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.Source import com.subtracks.data.model.Source
import com.subtracks.data.model.SubsonicConfig import com.subtracks.data.model.SubsonicConfig
import com.subtracks.data.model.SubsonicSource import com.subtracks.data.model.SubsonicSource
import com.subtracks.data.model.artistImageKey
import com.subtracks.data.model.coverArtKey import com.subtracks.data.model.coverArtKey
import com.subtracks.data.net.NetworkMode import com.subtracks.data.net.NetworkMode
import com.subtracks.data.prefs.StreamQuality import com.subtracks.data.prefs.StreamQuality
@@ -143,6 +144,18 @@ class SourceRepository(
return CoverArtRef(url = url, cacheKey = cacheKey) 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( fun networkCoverArt(
sourceId: Long, sourceId: Long,
coverArt: String, coverArt: String,
@@ -32,6 +32,7 @@ object SubsonicXml {
albumCount = attrs.longAttr("albumCount") ?: 0L, albumCount = attrs.longAttr("albumCount") ?: 0L,
starred = attrs.dateAttr("starred"), starred = attrs.dateAttr("starred"),
coverArt = attrs.attr("coverArt").ifEmpty { null }, coverArt = attrs.attr("coverArt").ifEmpty { null },
artistImageUrl = attrs.attr("artistImageUrl").ifEmpty { null },
sortName = attrs.attr("sortName").takeIf { it.isNotBlank() } ?: attrs.attr("name"), 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 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 = private val albumColumns =
listOf( listOf(
@@ -515,7 +515,7 @@ private val playlistSongColumns = listOf("sourceId", "playlistId", "songId", "po
private val playlistSongKey = listOf("sourceId", "playlistId", "position") 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() = private fun Album.values() =
listOf<Any?>( listOf<Any?>(
@@ -375,7 +375,15 @@ private fun MainNavigation() {
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt)) navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
}, },
onArtistClick = { artist -> 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 -> onPlaylistClick = { playlist ->
navigateDetail(Routes.PLAYLIST_DETAIL, "playlistId", playlist.id, Routes.playlist(playlist.id)) 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)) navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
}, },
onArtistClick = { artist -> 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 -> onPlaylistClick = { playlist ->
navigateDetail(Routes.PLAYLIST_DETAIL, "playlistId", playlist.id, Routes.playlist(playlist.id)) 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)) navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
}, },
onArtistClick = { artist -> 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)) }, onViewAlbum = { albumId -> navigateDetail(Routes.ALBUM_DETAIL, "albumId", albumId, Routes.album(albumId)) },
onViewArtist = { artistId -> onViewArtist = { artistId ->
@@ -538,7 +562,15 @@ private fun MainNavigation() {
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt)) navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
}, },
onArtistClick = { artist -> 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)) }, onViewAlbum = { albumId -> navigateDetail(Routes.ALBUM_DETAIL, "albumId", albumId, Routes.album(albumId)) },
onViewArtist = { artistId -> onViewArtist = { artistId ->
@@ -562,7 +594,15 @@ private fun MainNavigation() {
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt)) navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
}, },
onArtistClick = { artist -> 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)) }, onViewAlbum = { albumId -> navigateDetail(Routes.ALBUM_DETAIL, "albumId", albumId, Routes.album(albumId)) },
onViewArtist = { artistId -> onViewArtist = { artistId ->
@@ -588,7 +628,15 @@ private fun MainNavigation() {
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt)) navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
}, },
onArtistClick = { artist -> 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 -> onPlaylistClick = { playlist ->
navigateDetail(Routes.PLAYLIST_DETAIL, "playlistId", playlist.id, Routes.playlist(playlist.id)) 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.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue 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.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
@@ -19,49 +21,120 @@ import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.LayoutDirection
import com.subtracks.data.model.Artist import com.subtracks.data.model.Artist
import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.repo.LibraryRepository
import com.subtracks.data.repo.SourceRepository import com.subtracks.data.repo.SourceRepository
import kotlinx.coroutines.flow.first
import kotlin.math.PI import kotlin.math.PI
import kotlin.math.cos import kotlin.math.cos
import kotlin.math.min import kotlin.math.min
import kotlin.math.sin import kotlin.math.sin
internal suspend fun loadArtistArtCovers( data class ArtistArt(
library: LibraryRepository, val image: CoverArtRef? = null,
source: SourceRepository, val covers: List<CoverArtRef> = emptyList(),
artistId: String, val fallback: ArtistArt? = null,
): List<CoverArtRef> { ) {
val sourceId = library.activeSourceId.first() ?: return emptyList() val primary: CoverArtRef? get() = image ?: covers.firstOrNull()
val own = library.artistOwnCoverArt(sourceId, artistId) }
val ids =
if (own != null) { fun SourceRepository.artistArt(
listOf(own) artist: Artist,
} else { forIcon: Boolean = true,
library.artistFallbackCoverArts(sourceId, artistId) ): ArtistArt =
} resolveArtistArt(
return ids.take(4).mapNotNull { source.coverArt(it, thumbnail = true) } 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 @Composable
fun ArtistIcon( fun ArtistIcon(
artist: Artist, artist: Artist,
fallback: CoverArtRef?, art: ArtistArt,
loadCovers: suspend (String) -> List<CoverArtRef>,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val covers by produceState(initialValue = emptyList<CoverArtRef>(), artist.id) { var shown by remember(artist.id, art) { mutableStateOf(art) }
value = loadCovers(artist.id) val showFallback = {
shown.fallback?.let { shown = it } ?: run { shown = shown.copy(image = null) }
} }
if (covers.size >= 2) { when {
ArtistArtGrid(covers = covers, name = artist.name, modifier = modifier) shown.image != null -> {
} else { CoverArt(
CoverArt( ref = shown.image,
ref = covers.firstOrNull() ?: fallback, name = artist.name,
name = artist.name, modifier = modifier,
showPlaceholder = covers.isEmpty() && fallback == null, onError = showFallback,
modifier = modifier, )
) }
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, square: Boolean = true,
elevation: Dp = 0.dp, elevation: Dp = 0.dp,
colorFilter: ColorFilter? = null, colorFilter: ColorFilter? = null,
onError: (() -> Unit)? = null,
) { ) {
val context = LocalPlatformContext.current val context = LocalPlatformContext.current
val refKey = ref?.cacheKey val refKey = ref?.cacheKey
val thumbnailKey = thumbnailRef?.cacheKey val thumbnailKey = thumbnailRef?.cacheKey
val hasThumbnailFallback = thumbnailRef != null && thumbnailRef != ref
var failed by remember(refKey, thumbnailKey) { mutableStateOf(false) } var failed by remember(refKey, thumbnailKey) { mutableStateOf(false) }
var thumbnailLoaded 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)) } var thumbnailRatio by remember(refKey, thumbnailKey) { mutableStateOf(thumbnailKey?.let(ArtworkRatioCache::get)) }
@@ -131,7 +133,10 @@ fun CoverArt(
.ratioOrNull() .ratioOrNull()
?.let { ArtworkRatioCache.put(ref.cacheKey, it) } ?.let { ArtworkRatioCache.put(ref.cacheKey, it) }
}, },
onError = { failed = true }, onError = {
failed = true
if (!hasThumbnailFallback) onError?.invoke()
},
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
} }
@@ -160,7 +165,10 @@ fun CoverArt(
) )
} }
}, },
onError = { failed = true }, onError = {
failed = true
if (!hasThumbnailFallback) onError?.invoke()
},
contentScale = ContentScale.Fit, contentScale = ContentScale.Fit,
filterQuality = FilterQuality.High, filterQuality = FilterQuality.High,
) )
@@ -57,6 +57,7 @@ import com.subtracks.data.model.Playlist
import com.subtracks.data.model.Song import com.subtracks.data.model.Song
import com.subtracks.data.source.StarType import com.subtracks.data.source.StarType
import com.subtracks.playback.PlaybackController import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.ArtistArt
import com.subtracks.ui.components.ArtistIcon import com.subtracks.ui.components.ArtistIcon
import com.subtracks.ui.components.ContextMenuHost import com.subtracks.ui.components.ContextMenuHost
import com.subtracks.ui.components.CoverArt import com.subtracks.ui.components.CoverArt
@@ -109,7 +110,7 @@ fun HomeListRoute(
genres = genres, genres = genres,
decades = decades, decades = decades,
coverArt = viewModel::coverArt, coverArt = viewModel::coverArt,
artistCovers = viewModel::artistArtCovers, artistArt = { viewModel.artistArt(it) },
playingSongId = playback.item?.id, playingSongId = playback.item?.id,
onBack = onBack, onBack = onBack,
onAlbumClick = onAlbumClick, onAlbumClick = onAlbumClick,
@@ -135,7 +136,7 @@ fun HomeListScreen(
genres: List<String> = emptyList(), genres: List<String> = emptyList(),
decades: List<Long> = emptyList(), decades: List<Long> = emptyList(),
coverArt: (String?, Boolean) -> CoverArtRef? = { _, _ -> null }, coverArt: (String?, Boolean) -> CoverArtRef? = { _, _ -> null },
artistCovers: suspend (String) -> List<CoverArtRef> = { emptyList() }, artistArt: (Artist) -> ArtistArt = { ArtistArt() },
playingSongId: String? = null, playingSongId: String? = null,
onBack: () -> Unit = {}, onBack: () -> Unit = {},
onAlbumClick: (Album) -> Unit = {}, onAlbumClick: (Album) -> Unit = {},
@@ -194,8 +195,7 @@ fun HomeListScreen(
OfflineListKind.Artists -> { OfflineListKind.Artists -> {
ArtistsList( ArtistsList(
artists = artists, artists = artists,
coverArt = coverArt, artistArt = artistArt,
artistCovers = artistCovers,
info = { artistListInfo(request, it, now, resources) }, info = { artistListInfo(request, it, now, resources) },
onArtistClick = onArtistClick, onArtistClick = onArtistClick,
onLongClick = onLongClick, onLongClick = onLongClick,
@@ -249,8 +249,7 @@ fun HomeListScreen(
HomeSection.RecentlyPlayedArtists, HomeSection.MostPlayedArtists -> { HomeSection.RecentlyPlayedArtists, HomeSection.MostPlayedArtists -> {
ArtistsList( ArtistsList(
artists = artists, artists = artists,
coverArt = coverArt, artistArt = artistArt,
artistCovers = artistCovers,
info = { artistListInfo(request, it, now, resources) }, info = { artistListInfo(request, it, now, resources) },
onArtistClick = onArtistClick, onArtistClick = onArtistClick,
onLongClick = onLongClick, onLongClick = onLongClick,
@@ -448,8 +447,7 @@ internal fun AlbumListRow(
@Composable @Composable
private fun ArtistsList( private fun ArtistsList(
artists: LazyPagingItems<Artist>, artists: LazyPagingItems<Artist>,
coverArt: (String?, Boolean) -> CoverArtRef?, artistArt: (Artist) -> ArtistArt,
artistCovers: suspend (String) -> List<CoverArtRef>,
info: (Artist) -> String?, info: (Artist) -> String?,
onArtistClick: (Artist) -> Unit, onArtistClick: (Artist) -> Unit,
onLongClick: (MenuTarget) -> Unit, onLongClick: (MenuTarget) -> Unit,
@@ -466,13 +464,13 @@ private fun ArtistsList(
items(count = artists.itemCount, key = artists.itemKey { it.id }) { index -> items(count = artists.itemCount, key = artists.itemKey { it.id }) { index ->
val artist = artists[index] val artist = artists[index]
if (artist != null) { if (artist != null) {
val art = artistArt(artist)
ArtistListRow( ArtistListRow(
artist = artist, artist = artist,
coverArt = coverArt, art = art,
artistCovers = artistCovers,
info = info(artist), info = info(artist),
onClick = { onArtistClick(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 @Composable
internal fun ArtistListRow( internal fun ArtistListRow(
artist: Artist, artist: Artist,
coverArt: (String?, Boolean) -> CoverArtRef?, art: ArtistArt,
info: String?, info: String?,
onClick: () -> Unit, onClick: () -> Unit,
onLongClick: () -> Unit, onLongClick: () -> Unit,
artistCovers: suspend (String) -> List<CoverArtRef> = { emptyList() },
) { ) {
Row( Row(
modifier = modifier =
@@ -498,8 +495,7 @@ internal fun ArtistListRow(
) { ) {
ArtistIcon( ArtistIcon(
artist = artist, artist = artist,
fallback = coverArt(artist.coverArt, true), art = art,
loadCovers = artistCovers,
modifier = Modifier.size(ALBUM_ROW_COVER).clip(CircleShape), modifier = Modifier.size(ALBUM_ROW_COVER).clip(CircleShape),
) )
Spacer(Modifier.width(12.dp)) 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.LibraryRepository
import com.subtracks.data.repo.SourceRepository import com.subtracks.data.repo.SourceRepository
import com.subtracks.playback.PlaybackController 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.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
@@ -157,7 +158,10 @@ class HomeListViewModel(
thumbnail: Boolean, thumbnail: Boolean,
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail) ): 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) { fun play(song: Song) {
val genre = request.genre val genre = request.genre
@@ -72,6 +72,7 @@ import com.subtracks.data.model.Playlist
import com.subtracks.data.model.Song import com.subtracks.data.model.Song
import com.subtracks.data.source.StarType import com.subtracks.data.source.StarType
import com.subtracks.playback.PlaybackController import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.ArtistArt
import com.subtracks.ui.components.ArtistIcon import com.subtracks.ui.components.ArtistIcon
import com.subtracks.ui.components.ContextMenuHost import com.subtracks.ui.components.ContextMenuHost
import com.subtracks.ui.components.CoverArt import com.subtracks.ui.components.CoverArt
@@ -138,7 +139,7 @@ fun HomeRoute(
playlists = viewModel.downloadedPlaylists.collectAsLazyPagingItems(), playlists = viewModel.downloadedPlaylists.collectAsLazyPagingItems(),
songs = viewModel.downloadedSongs.collectAsStateWithLifecycle(initialValue = emptyList()).value, songs = viewModel.downloadedSongs.collectAsStateWithLifecycle(initialValue = emptyList()).value,
coverArt = viewModel::coverArt, coverArt = viewModel::coverArt,
artistCovers = viewModel::artistArtCovers, artistArt = { viewModel.artistArt(it) },
playingSongId = playback.item?.id, playingSongId = playback.item?.id,
topInset = topInset, topInset = topInset,
bottomInset = bottomInset, bottomInset = bottomInset,
@@ -154,7 +155,7 @@ fun HomeRoute(
HomeScreen( HomeScreen(
feed = feed, feed = feed,
coverArt = viewModel::coverArt, coverArt = viewModel::coverArt,
artistCovers = viewModel::artistArtCovers, artistArt = { viewModel.artistArt(it) },
playingSongId = playback.item?.id, playingSongId = playback.item?.id,
topInset = topInset, topInset = topInset,
bottomInset = bottomInset, bottomInset = bottomInset,
@@ -176,7 +177,7 @@ fun HomeScreen(
coverArt: (String?, Boolean) -> CoverArtRef?, coverArt: (String?, Boolean) -> CoverArtRef?,
playingSongId: String?, playingSongId: String?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
artistCovers: suspend (String) -> List<CoverArtRef> = { emptyList() }, artistArt: (Artist) -> ArtistArt = { ArtistArt() },
topInset: Dp = 0.dp, topInset: Dp = 0.dp,
bottomInset: Dp = 0.dp, bottomInset: Dp = 0.dp,
onAlbumClick: (Album) -> Unit = {}, onAlbumClick: (Album) -> Unit = {},
@@ -219,7 +220,7 @@ fun HomeScreen(
HomeSection.RecentlyPlayedArtists, HomeSection.RecentlyPlayedArtists,
feed.recentlyPlayedArtists, feed.recentlyPlayedArtists,
coverArt, coverArt,
artistCovers, artistArt,
onArtistClick, onArtistClick,
onLongClick, onLongClick,
onMore, onMore,
@@ -254,7 +255,7 @@ fun HomeScreen(
HomeSection.MostPlayedArtists, HomeSection.MostPlayedArtists,
feed.mostPlayedArtists, feed.mostPlayedArtists,
coverArt, coverArt,
artistCovers, artistArt,
onArtistClick, onArtistClick,
onLongClick, onLongClick,
onMore, onMore,
@@ -302,7 +303,7 @@ fun OfflineHomeScreen(
coverArt: (String?, Boolean) -> CoverArtRef?, coverArt: (String?, Boolean) -> CoverArtRef?,
playingSongId: String?, playingSongId: String?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
artistCovers: suspend (String) -> List<CoverArtRef> = { emptyList() }, artistArt: (Artist) -> ArtistArt = { ArtistArt() },
topInset: Dp = 0.dp, topInset: Dp = 0.dp,
bottomInset: Dp = 0.dp, bottomInset: Dp = 0.dp,
onAlbumClick: (Album) -> Unit = {}, onAlbumClick: (Album) -> Unit = {},
@@ -319,7 +320,7 @@ fun OfflineHomeScreen(
) { ) {
homeTitle() homeTitle()
downloadedAlbumRow(albums, coverArt, onAlbumClick, onLongClick) { onMore(OfflineListKind.Albums) } 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) } downloadedPlaylistRow(playlists, coverArt, onPlaylistClick, onLongClick) { onMore(OfflineListKind.Playlists) }
songList( songList(
key = "downloaded-songs", key = "downloaded-songs",
@@ -372,7 +373,7 @@ private fun LazyListScope.downloadedAlbumRow(
private fun LazyListScope.downloadedArtistRow( private fun LazyListScope.downloadedArtistRow(
artists: LazyPagingItems<Artist>, artists: LazyPagingItems<Artist>,
coverArt: (String?, Boolean) -> CoverArtRef?, coverArt: (String?, Boolean) -> CoverArtRef?,
artistCovers: suspend (String) -> List<CoverArtRef>, artistArt: (Artist) -> ArtistArt,
onArtistClick: (Artist) -> Unit, onArtistClick: (Artist) -> Unit,
onLongClick: (MenuTarget) -> Unit, onLongClick: (MenuTarget) -> Unit,
onMore: () -> Unit, onMore: () -> Unit,
@@ -390,13 +391,12 @@ private fun LazyListScope.downloadedArtistRow(
items(count = artists.itemCount, key = artists.itemKey { it.id }) { index -> items(count = artists.itemCount, key = artists.itemKey { it.id }) { index ->
val artist = artists[index] val artist = artists[index]
if (artist != null) { if (artist != null) {
val ref = coverArt(artist.coverArt, true) val art = artistArt(artist)
ArtistTile( ArtistTile(
artist = artist, artist = artist,
ref = ref, art = art,
artistCovers = artistCovers,
onClick = { onArtistClick(artist) }, 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, section: HomeSection,
artists: List<Artist>, artists: List<Artist>,
coverArt: (String?, Boolean) -> CoverArtRef?, coverArt: (String?, Boolean) -> CoverArtRef?,
artistCovers: suspend (String) -> List<CoverArtRef>, artistArt: (Artist) -> ArtistArt,
onArtistClick: (Artist) -> Unit, onArtistClick: (Artist) -> Unit,
onLongClick: (MenuTarget) -> Unit, onLongClick: (MenuTarget) -> Unit,
onMore: (HomeSection) -> Unit, onMore: (HomeSection) -> Unit,
@@ -635,13 +635,12 @@ private fun LazyListScope.artistRow(
modifier = Modifier.padding(top = SECTION_CONTENT_TOP), modifier = Modifier.padding(top = SECTION_CONTENT_TOP),
) { ) {
items(artists, key = { it.id }) { artist -> items(artists, key = { it.id }) { artist ->
val ref = coverArt(artist.coverArt, true) val art = artistArt(artist)
ArtistTile( ArtistTile(
artist = artist, artist = artist,
ref = ref, art = art,
artistCovers = artistCovers,
onClick = { onArtistClick(artist) }, onClick = { onArtistClick(artist) },
onLongClick = { onLongClick(MenuTarget.Artist(artist, ref)) }, onLongClick = { onLongClick(MenuTarget.Artist(artist, art.primary)) },
) )
} }
} }
@@ -790,8 +789,7 @@ private fun AlbumTile(
@Composable @Composable
private fun ArtistTile( private fun ArtistTile(
artist: Artist, artist: Artist,
ref: CoverArtRef?, art: ArtistArt,
artistCovers: suspend (String) -> List<CoverArtRef>,
onClick: () -> Unit, onClick: () -> Unit,
onLongClick: () -> Unit, onLongClick: () -> Unit,
) { ) {
@@ -801,8 +799,7 @@ private fun ArtistTile(
) { ) {
ArtistIcon( ArtistIcon(
artist = artist, artist = artist,
fallback = ref, art = art,
loadCovers = artistCovers,
modifier = Modifier.size(ARTIST_TILE_SIZE).clip(CircleShape), modifier = Modifier.size(ARTIST_TILE_SIZE).clip(CircleShape),
) )
Spacer(Modifier.height(6.dp)) 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.SyncManager
import com.subtracks.data.sync.SyncStatus import com.subtracks.data.sync.SyncStatus
import com.subtracks.playback.PlaybackController 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.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -128,7 +129,10 @@ class HomeViewModel(
thumbnail: Boolean, thumbnail: Boolean,
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail) ): 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) { fun playStarred(song: Song) {
viewModelScope.playStarredList(libraryRepository, playbackController, 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.model.ListDownloadStatus
import com.subtracks.data.source.StarType import com.subtracks.data.source.StarType
import com.subtracks.playback.PlaybackController 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.CloseWhenDownloadsGone
import com.subtracks.ui.components.ContextMenuHost import com.subtracks.ui.components.ContextMenuHost
import com.subtracks.ui.components.CoverArt import com.subtracks.ui.components.CoverArt
@@ -205,7 +206,7 @@ fun ArtistDetailRoute(
LaunchedEffect(artistId) { viewModel.refreshBio() } LaunchedEffect(artistId) { viewModel.refreshBio() }
val art by viewModel.art.collectAsStateWithLifecycle() val art by viewModel.art.collectAsStateWithLifecycle()
val artThumbnail by viewModel.artThumbnail.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 downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
val offline by viewModel.offline.collectAsStateWithLifecycle() val offline by viewModel.offline.collectAsStateWithLifecycle()
CloseWhenDownloadsGone(downloadStatus.downloaded, offline, onBack) CloseWhenDownloadsGone(downloadStatus.downloaded, offline, onBack)
@@ -245,7 +246,7 @@ fun ArtistDetailRoute(
bioExpanded = bioExpanded, bioExpanded = bioExpanded,
art = art, art = art,
artThumbnail = artThumbnail, artThumbnail = artThumbnail,
artCovers = artCovers, heroArt = heroArt,
artwork = rememberArtworkColors(shortcutArt ?: artThumbnail ?: art, THEME_TRANSITION_MS, fallbackName = artistName), artwork = rememberArtworkColors(shortcutArt ?: artThumbnail ?: art, THEME_TRANSITION_MS, fallbackName = artistName),
coverArt = viewModel::coverArt, coverArt = viewModel::coverArt,
onBack = onBack, onBack = onBack,
@@ -286,6 +287,7 @@ fun ArtistDetailScreen(
art: CoverArtRef?, art: CoverArtRef?,
artThumbnail: CoverArtRef? = null, artThumbnail: CoverArtRef? = null,
artCovers: List<CoverArtRef> = emptyList(), artCovers: List<CoverArtRef> = emptyList(),
heroArt: ArtistArt? = null,
artwork: ArtworkColors? = null, artwork: ArtworkColors? = null,
coverArt: (String?, Boolean) -> CoverArtRef?, coverArt: (String?, Boolean) -> CoverArtRef?,
onBack: () -> Unit, onBack: () -> Unit,
@@ -403,22 +405,16 @@ fun ArtistDetailScreen(
.onSizeChanged { imageHeightPx = it.height.toFloat() }, .onSizeChanged { imageHeightPx = it.height.toFloat() },
) { ) {
Box(Modifier.fillMaxWidth()) { Box(Modifier.fillMaxWidth()) {
if (artCovers.isNotEmpty()) { ArtistHeroArt(
ArtistArtGrid( art =
covers = artCovers, heroArt ?: ArtistArt(
name = displayName, image = art.takeIf { artCovers.isEmpty() },
singleThumbnail = artThumbnail, covers = artCovers,
modifier = Modifier.fillMaxWidth().height(ART_HEIGHT), ),
) name = displayName,
} else { thumbnail = artThumbnail,
CoverArt( modifier = Modifier.fillMaxWidth().height(ART_HEIGHT),
ref = art, )
name = displayName,
thumbnailRef = artThumbnail,
showPlaceholder = art == null,
modifier = Modifier.fillMaxWidth().height(ART_HEIGHT),
)
}
if (nameScrimAlpha > 0f) { if (nameScrimAlpha > 0f) {
Box( Box(
modifier = modifier =
@@ -13,6 +13,8 @@ import com.subtracks.data.repo.DownloadRepository
import com.subtracks.data.repo.LibraryRepository import com.subtracks.data.repo.LibraryRepository
import com.subtracks.data.repo.SourceRepository import com.subtracks.data.repo.SourceRepository
import com.subtracks.playback.PlaybackController import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.ArtistArt
import com.subtracks.ui.components.artistArt
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -136,41 +138,22 @@ class ArtistDetailViewModel(
private val _artThumbnail = MutableStateFlow<CoverArtRef?>(null) private val _artThumbnail = MutableStateFlow<CoverArtRef?>(null)
val artThumbnail: StateFlow<CoverArtRef?> = _artThumbnail val artThumbnail: StateFlow<CoverArtRef?> = _artThumbnail
private val _artCovers = MutableStateFlow<List<CoverArtRef>>(emptyList()) private val _heroArt = MutableStateFlow<ArtistArt?>(null)
val artCovers: StateFlow<List<CoverArtRef>> = _artCovers val heroArt: StateFlow<ArtistArt?> = _heroArt
init { init {
viewModelScope.launch { viewModelScope.launch {
artist combine(artist.filterNotNull(), sourceRepository.offline) { loaded, _ -> loaded }
.filterNotNull()
.collectLatest { loaded -> .collectLatest { loaded ->
val (full, thumbnail, covers) = resolveArt(loaded) val hero = sourceRepository.artistArt(loaded, forIcon = false)
_art.value = full val icon = sourceRepository.artistArt(loaded, forIcon = true)
_artThumbnail.value = thumbnail _art.value = hero.image ?: hero.covers.firstOrNull()
_artCovers.value = covers _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( fun coverArt(
coverArt: String?, coverArt: String?,
thumbnail: Boolean, thumbnail: Boolean,
@@ -29,6 +29,7 @@ import com.subtracks.R
import com.subtracks.data.model.Artist import com.subtracks.data.model.Artist
import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.ListDownloadStatus import com.subtracks.data.model.ListDownloadStatus
import com.subtracks.ui.components.ArtistArt
import com.subtracks.ui.components.ArtistIcon import com.subtracks.ui.components.ArtistIcon
import com.subtracks.ui.components.EmptyState import com.subtracks.ui.components.EmptyState
import com.subtracks.ui.components.FilteredEmptyState import com.subtracks.ui.components.FilteredEmptyState
@@ -55,7 +56,7 @@ fun ArtistsContent(
onSync: () -> Unit = {}, onSync: () -> Unit = {},
downloadStatuses: Map<String, ListDownloadStatus> = emptyMap(), downloadStatuses: Map<String, ListDownloadStatus> = emptyMap(),
scrollToTopKey: Any? = null, scrollToTopKey: Any? = null,
artistCovers: suspend (String) -> List<CoverArtRef> = { emptyList() }, artistArt: (Artist) -> ArtistArt = { ArtistArt() },
) { ) {
when { when {
items.itemCount == 0 && items.loadState.refresh is LoadState.Loading -> { 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 -> items(count = items.itemCount, key = items.itemKey { it.id }) { index ->
val artist = items[index] val artist = items[index]
if (artist != null) { if (artist != null) {
PrefetchArtworkSeeds(coverArt(artist.coverArt, true)) val art = artistArt(artist)
PrefetchArtworkSeeds(art.primary)
ListItem( ListItem(
headlineContent = { headlineContent = {
Text( Text(
@@ -107,8 +109,7 @@ fun ArtistsContent(
leadingContent = { leadingContent = {
ArtistIcon( ArtistIcon(
artist = artist, artist = artist,
fallback = coverArt(artist.coverArt, true), art = art,
loadCovers = artistCovers,
modifier = Modifier.size(48.dp).clip(CircleShape), modifier = Modifier.size(48.dp).clip(CircleShape),
) )
}, },
@@ -119,7 +120,7 @@ fun ArtistsContent(
Modifier.combinedClickable( Modifier.combinedClickable(
onClick = { onArtistClick(artist) }, onClick = { onArtistClick(artist) },
onLongClick = { 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), 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.prefs.StarredFilter
import com.subtracks.data.source.StarType import com.subtracks.data.source.StarType
import com.subtracks.playback.PlaybackController import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.ArtistArt
import com.subtracks.ui.components.ContextMenuHost import com.subtracks.ui.components.ContextMenuHost
import com.subtracks.ui.components.DeleteDownloadsDialog import com.subtracks.ui.components.DeleteDownloadsDialog
import com.subtracks.ui.components.DismissOnRequest import com.subtracks.ui.components.DismissOnRequest
@@ -308,7 +309,7 @@ fun LibraryRoute(
artists = viewModel.artists, artists = viewModel.artists,
playlists = viewModel.playlists, playlists = viewModel.playlists,
coverArt = viewModel::coverArt, coverArt = viewModel::coverArt,
artistCovers = viewModel::artistArtCovers, artistArt = { viewModel.artistArt(it) },
onAlbumClick = onAlbumClick, onAlbumClick = onAlbumClick,
onArtistClick = onArtistClick, onArtistClick = onArtistClick,
onPlaylistClick = onPlaylistClick, onPlaylistClick = onPlaylistClick,
@@ -397,7 +398,7 @@ fun LibraryScreen(
onSync: () -> Unit, onSync: () -> Unit,
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
artistCovers: suspend (String) -> List<CoverArtRef> = { emptyList() }, artistArt: (Artist) -> ArtistArt = { ArtistArt() },
onOpenSearch: () -> Unit = {}, onOpenSearch: () -> Unit = {},
offline: Boolean = false, offline: Boolean = false,
onExitOffline: () -> Unit = {}, onExitOffline: () -> Unit = {},
@@ -541,7 +542,7 @@ fun LibraryScreen(
onSync = onSync, onSync = onSync,
downloadStatuses = artistDownloads, downloadStatuses = artistDownloads,
scrollToTopKey = scrollToTopKey, 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.SyncManager
import com.subtracks.data.sync.SyncStatus import com.subtracks.data.sync.SyncStatus
import com.subtracks.playback.PlaybackController 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.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -212,7 +213,10 @@ class LibraryViewModel(
thumbnail: Boolean, thumbnail: Boolean,
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail) ): 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() { fun sync() {
if (syncManager.status.value !is SyncStatus.Running) syncManager.requestSync() 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.model.Song
import com.subtracks.data.source.StarType import com.subtracks.data.source.StarType
import com.subtracks.playback.PlaybackController import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.ArtistArt
import com.subtracks.ui.components.ContextMenuHost import com.subtracks.ui.components.ContextMenuHost
import com.subtracks.ui.components.EmptyState import com.subtracks.ui.components.EmptyState
import com.subtracks.ui.components.ItemActions import com.subtracks.ui.components.ItemActions
@@ -106,7 +107,7 @@ fun SearchRoute(
if (next != 0) filterMask = next if (next != 0) filterMask = next
}, },
coverArt = viewModel::coverArt, coverArt = viewModel::coverArt,
artistCovers = viewModel::artistArtCovers, artistArt = { viewModel.artistArt(it) },
playingSongId = playback.item?.id, playingSongId = playback.item?.id,
onBack = onBack, onBack = onBack,
onAlbumClick = { onAlbumClick = {
@@ -147,7 +148,7 @@ fun SearchScreen(
filters: Set<SearchFilter> = ALL_FILTERS, filters: Set<SearchFilter> = ALL_FILTERS,
onToggleFilter: (SearchFilter) -> Unit = {}, onToggleFilter: (SearchFilter) -> Unit = {},
coverArt: (String?, Boolean) -> CoverArtRef? = { _, _ -> null }, coverArt: (String?, Boolean) -> CoverArtRef? = { _, _ -> null },
artistCovers: suspend (String) -> List<CoverArtRef> = { emptyList() }, artistArt: (Artist) -> ArtistArt = { ArtistArt() },
playingSongId: String? = null, playingSongId: String? = null,
onBack: () -> Unit = {}, onBack: () -> Unit = {},
onAlbumClick: (Album) -> Unit = {}, onAlbumClick: (Album) -> Unit = {},
@@ -217,7 +218,7 @@ fun SearchScreen(
contentPadding = PaddingValues(bottom = 24.dp), contentPadding = PaddingValues(bottom = 24.dp),
) { ) {
if (showPlaylists) playlistResults(results.playlists, playlistsLabel, coverArt, onPlaylistClick, onLongClick) 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 (showAlbums) albumResults(results.albums, albumsLabel, coverArt, onAlbumClick, onLongClick)
if (showSongs) songResults(results.songs, songsLabel, coverArt, playingSongId, onSongClick, onLongClick) if (showSongs) songResults(results.songs, songsLabel, coverArt, playingSongId, onSongClick, onLongClick)
} }
@@ -310,21 +311,20 @@ private fun LazyListScope.albumResults(
private fun LazyListScope.artistResults( private fun LazyListScope.artistResults(
artists: List<Artist>, artists: List<Artist>,
header: String, header: String,
coverArt: (String?, Boolean) -> CoverArtRef?, artistArt: (Artist) -> ArtistArt,
artistCovers: suspend (String) -> List<CoverArtRef>,
onClick: (Artist) -> Unit, onClick: (Artist) -> Unit,
onLongClick: (MenuTarget) -> Unit, onLongClick: (MenuTarget) -> Unit,
) { ) {
if (artists.isEmpty()) return if (artists.isEmpty()) return
resultHeader(header) resultHeader(header)
items(artists, key = { "artist-${it.id}" }) { artist -> items(artists, key = { "artist-${it.id}" }) { artist ->
val art = artistArt(artist)
ArtistListRow( ArtistListRow(
artist = artist, artist = artist,
coverArt = coverArt, art = art,
artistCovers = artistCovers,
info = null, info = null,
onClick = { onClick(artist) }, 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.LibraryRepository
import com.subtracks.data.repo.SourceRepository import com.subtracks.data.repo.SourceRepository
import com.subtracks.playback.PlaybackController 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 com.subtracks.ui.home.playSongInContext
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -75,7 +76,10 @@ class SearchViewModel(
thumbnail: Boolean, thumbnail: Boolean,
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail) ): 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) { fun play(song: Song) {
viewModelScope.playSongInContext(libraryRepository, playbackController, song) viewModelScope.playSongInContext(libraryRepository, playbackController, song)
@@ -548,7 +548,7 @@ class LibraryDaoTest {
} }
@Test @Test
fun artistArtFallsBackToTheFirstAlbumCreditThenTheFirstTrackCredit() = fun artistArtIsMaterializedFromAlbumCreditsThenTrackCredits() =
runTest { runTest {
val sourceId = source() val sourceId = source()
val dao = db.libraryDao() val dao = db.libraryDao()
@@ -615,40 +615,121 @@ class LibraryDaoTest {
} }
} }
dao.recomputeArtistArt(sourceId)
val artists = dao.artistsByName(sourceId, 0, "").page().associateBy { it.id } val artists = dao.artistsByName(sourceId, 0, "").page().associateBy { it.id }
assertEquals("own-art", artists.getValue("ar-own").coverArt) assertEquals("own-art", artists.getValue("ar-own").coverArt)
assertEquals("new-art", artists.getValue("ar-album").coverArt) assertNull(artists.getValue("ar-album").coverArt)
assertEquals("track-art", artists.getValue("ar-track").coverArt) assertNull(artists.getValue("ar-track").coverArt)
assertEquals("album-ref-art", artists.getValue("ar-album-ref").coverArt) assertNull(artists.getValue("ar-album-ref").coverArt)
assertEquals("track-ref-art", artists.getValue("ar-track-ref").coverArt) assertNull(artists.getValue("ar-track-ref").coverArt)
assertNull(artists.getValue("ar-null-art").coverArt) assertNull(artists.getValue("ar-null-art").coverArt)
assertNull(artists.getValue("ar-none").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( assertEquals(
"album-ref-art", listOf("album-ref-art"),
dao dao
.artistsPaging(artistsDefaultQuery(sourceId, 0, "", ArtistSort.Name, false)) .artistsPaging(artistsDefaultQuery(sourceId, 0, "", ArtistSort.Name, false))
.page() .page()
.first { it.id == "ar-album-ref" } .first { it.id == "ar-album-ref" }
.coverArt, .coverArts,
) )
} }
@Test @Test
fun artistArtFallbackFindsCreditsThroughArtistIndexes() = fun downloadedArtistArtUsesOnlyDownloadedAlbums() =
runTest { runTest {
val plan = plan("SELECT $ARTIST_COLUMNS $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_NAME LIMIT 20") val sourceId = source()
val dao = db.libraryDao()
assertTrue(plan, plan.contains("USING INDEX index_albums_sourceId_artistId (sourceId=? AND artistId=?)")) dao.upsertArtists(listOf(artist(sourceId, "ar-1", "Artist", albumCount = 3)))
assertTrue( dao.upsertAlbums(
plan, listOf(
plan.contains("USING INDEX index_album_artists_sourceId_artistId (sourceId=? AND artistId=?)"), 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=?)")) dao.upsertSongs(
assertTrue( listOf(
plan, song(sourceId, "s1", "One", starred = null).copy(albumId = "al-1", artistId = "ar-1"),
plan.contains("USING INDEX index_song_artists_sourceId_artistId (sourceId=? AND artistId=?)"), 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 @Test
fun theGenreBackfillTrimsAndSkipsBlankSingularGenres() = fun theGenreBackfillTrimsAndSkipsBlankSingularGenres() =
runTest { runTest {
@@ -26,6 +26,7 @@ import com.subtracks.data.model.Song
import com.subtracks.data.model.SongDownload import com.subtracks.data.model.SongDownload
import com.subtracks.data.model.Source import com.subtracks.data.model.Source
import com.subtracks.data.model.SubsonicSource import com.subtracks.data.model.SubsonicSource
import com.subtracks.data.model.artistImageKey
import com.subtracks.data.model.coverArtKey import com.subtracks.data.model.coverArtKey
import com.subtracks.data.prefs.fakeUserPreferences import com.subtracks.data.prefs.fakeUserPreferences
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -70,11 +71,12 @@ class DownloadRepositoryTest {
private val requestedArt = CopyOnWriteArrayList<String>() private val requestedArt = CopyOnWriteArrayList<String>()
private val artFetchedAfterTheEngineRequest = CopyOnWriteArrayList<Boolean>() private val artFetchedAfterTheEngineRequest = CopyOnWriteArrayList<Boolean>()
private var failArtworkFetch = false private var failArtworkFetch = false
private val failingArtUrls = CopyOnWriteArrayList<String>()
private val fetcher = private val fetcher =
ArtworkFetcher { url -> ArtworkFetcher { url ->
requestedArt += url requestedArt += url
artFetchedAfterTheEngineRequest += engine.requests.isNotEmpty() artFetchedAfterTheEngineRequest += engine.requests.isNotEmpty()
if (failArtworkFetch) throw IOException("boom") if (failArtworkFetch || url in failingArtUrls) throw IOException("boom")
byteArrayOf(1, 2, 3) 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 @Test
fun downloadingASongAlsoStoresTheArtworkOfPlaylistsThatContainIt() = fun downloadingASongAlsoStoresTheArtworkOfPlaylistsThatContainIt() =
runTest { runTest {
@@ -1282,8 +1373,19 @@ class DownloadRepositoryTest {
songCount = 2, songCount = 2,
) )
private fun artist(coverArt: String?) = private fun artist(
Artist(sourceId = 1, id = "ar1", name = "Artist", albumCount = 1, starred = null, coverArt = coverArt, songCount = 2) 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/") { private fun seedLibrary(address: String = "http://localhost:4533/") {
runBlocking { runBlocking {
@@ -11,6 +11,7 @@ import com.subtracks.cancelAndJoinBlocking
import com.subtracks.data.db.SubtracksDatabase import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.download.ArtworkStore import com.subtracks.data.download.ArtworkStore
import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.artistImageKey
import com.subtracks.data.model.coverArtKey import com.subtracks.data.model.coverArtKey
import com.subtracks.data.prefs.StreamQuality import com.subtracks.data.prefs.StreamQuality
import com.subtracks.data.prefs.UserPreferences import com.subtracks.data.prefs.UserPreferences
@@ -33,6 +34,7 @@ import okhttp3.mockwebserver.RecordedRequest
import org.junit.After import org.junit.After
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
@@ -283,6 +285,26 @@ class SourceRepositoryTest {
assertTrue("expected the stored file, was ${ref.url}", ref.url.startsWith("file:")) 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 awaitActiveSourceId(): Long = withTimeout(TEST_TIMEOUT_MS) { repository.activeSourceId().first { it != null }!! }
private suspend fun awaitCoverArt(coverArt: String): CoverArtRef = private suspend fun awaitCoverArt(coverArt: String): CoverArtRef =
@@ -74,7 +74,8 @@ class SubsonicXmlTest {
""" """
<subsonic-response status="ok"> <subsonic-response status="ok">
<artists> <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"/> <artist id="ar2" name="Portishead" albumCount="3"/>
</artists> </artists>
</subsonic-response> </subsonic-response>
@@ -88,6 +89,8 @@ class SubsonicXmlTest {
assertEquals(7L, artists[0].sourceId) assertEquals(7L, artists[0].sourceId)
assertEquals(9L, artists[0].albumCount) assertEquals(9L, artists[0].albumCount)
assertEquals(1577934245L, artists[0].starred) assertEquals(1577934245L, artists[0].starred)
assertEquals("https://img.example/radiohead.jpg", artists[0].artistImageUrl)
assertEquals(null, artists[1].artistImageUrl)
assertEquals(null, artists[1].starred) assertEquals(null, artists[1].starred)
} }
@@ -48,6 +48,7 @@ import com.subtracks.data.prefs.StreamQuality
import com.subtracks.playback.PlaybackState import com.subtracks.playback.PlaybackState
import com.subtracks.playback.QueueItem import com.subtracks.playback.QueueItem
import com.subtracks.playback.RepeatMode import com.subtracks.playback.RepeatMode
import com.subtracks.ui.components.ArtistArt
import com.subtracks.ui.components.DOWNLOADING_INDICATOR_TAG import com.subtracks.ui.components.DOWNLOADING_INDICATOR_TAG
import com.subtracks.ui.library.ALBUM_COVER_TAG import com.subtracks.ui.library.ALBUM_COVER_TAG
import com.subtracks.ui.library.ARTIST_NAME_SCRIM_TAG import com.subtracks.ui.library.ARTIST_NAME_SCRIM_TAG
@@ -613,12 +614,15 @@ class AppScreenshotTest {
artists = remember { flowOf(PagingData.from(Fixtures.artists)) }, artists = remember { flowOf(PagingData.from(Fixtures.artists)) },
playlists = remember { flowOf(PagingData.from(Fixtures.playlists)) }, playlists = remember { flowOf(PagingData.from(Fixtures.playlists)) },
coverArt = { id, _ -> id?.let { CoverArtRef(it, "test:$it") } }, coverArt = { id, _ -> id?.let { CoverArtRef(it, "test:$it") } },
artistCovers = { id -> artistArt = { artist ->
Fixtures.albums ArtistArt(
.filter { it.artistId == id } covers =
.take(4) Fixtures.albums
.mapNotNull { it.coverArt } .filter { it.artistId == artist.id }
.map { CoverArtRef(it, "test:$it") } .take(4)
.mapNotNull { it.coverArt }
.map { CoverArtRef(it, "test:$it") },
)
}, },
onAlbumClick = {}, onAlbumClick = {},
onArtistClick = {}, 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,
)
}