mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 15:11:58 +01:00
library: materialize artist art and provider images
Store the OpenSubsonic artistImageUrl and two materialized cover-id lists on each artist row, backfill them in the 35->36 migration, and refresh downloaded covers only for affected artists when downloads change. Icons and the artist hero now resolve provider art, server artist art, then the same materialized grid synchronously, with a definitive-failure fallback so a dead provider URL does not strand the letter placeholder. Download artwork also caches the provider image for the track and album artists, falls back to the artist cover when that fetch fails, and keeps both keys across sweeps. Query timing for the materialization pass is down from roughly 3.4s to 1.9s on the 20k-song benchmark.
This commit is contained in:
+1962
File diff suppressed because it is too large
Load Diff
@@ -162,12 +162,15 @@ interface DownloadDao {
|
||||
|
||||
@Query(
|
||||
"SELECT d.sourceId AS sourceId, s.albumId AS albumId, s.artistId AS artistId, " +
|
||||
"al.artistId AS albumArtistId, " +
|
||||
"al.id AS albumRow, al.coverArt AS albumCoverArt, " +
|
||||
"ar.id AS artistRow, ar.coverArt AS artistCoverArt " +
|
||||
"ar.id AS artistRow, ar.coverArt AS artistCoverArt, " +
|
||||
"aar.coverArt AS albumArtistCoverArt " +
|
||||
"FROM song_downloads d " +
|
||||
"LEFT JOIN songs s ON s.sourceId = d.sourceId AND s.id = d.songId " +
|
||||
"LEFT JOIN albums al ON al.sourceId = s.sourceId AND al.id = s.albumId " +
|
||||
"LEFT JOIN artists ar ON ar.sourceId = s.sourceId AND ar.id = s.artistId",
|
||||
"LEFT JOIN artists ar ON ar.sourceId = s.sourceId AND ar.id = s.artistId " +
|
||||
"LEFT JOIN artists aar ON aar.sourceId = al.sourceId AND aar.id = al.artistId",
|
||||
)
|
||||
suspend fun artwork(): List<DownloadArtwork>
|
||||
|
||||
|
||||
@@ -124,32 +124,125 @@ internal const val ALBUM_CREDIT_ALBUM_IDS =
|
||||
|
||||
// Download visibility follows the credit path: an album credit includes the album when any song is
|
||||
// downloaded, while a track credit includes it only when the credited track is downloaded.
|
||||
private const val DOWNLOADED_ARTIST_ALBUMS =
|
||||
"SELECT albums.id FROM albums " +
|
||||
"WHERE albums.sourceId = :sourceId AND albums.artistId = artists.id " +
|
||||
internal const val DOWNLOADED_ALBUM_ROWS =
|
||||
"SELECT albums.id AS albumId, albums.artistId AS artistId FROM albums " +
|
||||
"WHERE albums.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
|
||||
"AND EXISTS (SELECT 1 FROM songs " +
|
||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
||||
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
|
||||
"UNION " +
|
||||
"SELECT albums.id FROM album_artists " +
|
||||
"SELECT albums.id, album_artists.artistId FROM album_artists " +
|
||||
"JOIN albums ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
|
||||
"WHERE album_artists.sourceId = :sourceId AND album_artists.artistId = artists.id " +
|
||||
"WHERE album_artists.sourceId = :sourceId " +
|
||||
"AND EXISTS (SELECT 1 FROM songs " +
|
||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
||||
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
|
||||
"UNION " +
|
||||
"SELECT songs.albumId FROM songs " +
|
||||
"SELECT songs.albumId, songs.artistId FROM songs " +
|
||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
||||
"WHERE songs.sourceId = :sourceId AND songs.artistId = artists.id " +
|
||||
"AND sd.status = 'Completed' " +
|
||||
"WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL AND sd.status = 'Completed' " +
|
||||
"UNION " +
|
||||
"SELECT songs.albumId FROM song_artists " +
|
||||
"SELECT songs.albumId, song_artists.artistId FROM song_artists " +
|
||||
"JOIN songs ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
|
||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
||||
"WHERE song_artists.sourceId = :sourceId AND song_artists.artistId = artists.id " +
|
||||
"AND sd.status = 'Completed'"
|
||||
"WHERE song_artists.sourceId = :sourceId AND sd.status = 'Completed'"
|
||||
|
||||
private const val DOWNLOADED_ARTIST_ALBUMS =
|
||||
"SELECT albumId AS id FROM ($DOWNLOADED_ALBUM_ROWS) WHERE artistId = artists.id"
|
||||
|
||||
private const val DOWNLOADED_COVER_ALBUM_ROWS =
|
||||
"SELECT albums.id AS albumId, albums.artistId AS artistId FROM albums " +
|
||||
"WHERE albums.sourceId = :sourceId AND albums.artistId IS NOT NULL AND albums.coverArt IS NOT NULL " +
|
||||
"AND EXISTS (SELECT 1 FROM songs " +
|
||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
||||
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
|
||||
"UNION " +
|
||||
"SELECT albums.id, album_artists.artistId FROM album_artists " +
|
||||
"JOIN albums ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
|
||||
"WHERE album_artists.sourceId = :sourceId AND albums.coverArt IS NOT NULL " +
|
||||
"AND EXISTS (SELECT 1 FROM songs " +
|
||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
||||
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
|
||||
"UNION " +
|
||||
"SELECT songs.albumId, songs.artistId FROM songs " +
|
||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
||||
"WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL AND albums.coverArt IS NOT NULL " +
|
||||
"AND sd.status = 'Completed' " +
|
||||
"UNION " +
|
||||
"SELECT songs.albumId, song_artists.artistId FROM song_artists " +
|
||||
"JOIN songs ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
|
||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
||||
"WHERE song_artists.sourceId = :sourceId AND albums.coverArt IS NOT NULL AND sd.status = 'Completed'"
|
||||
|
||||
// Cover art only needs credited albums that actually have a cover, so filter before the window sort.
|
||||
private const val ARTIST_COVER_CREDITS =
|
||||
"SELECT albums.artistId AS artistId, albums.id AS albumId, 'album' AS creditType " +
|
||||
"FROM albums WHERE albums.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
|
||||
"AND albums.coverArt IS NOT NULL " +
|
||||
"UNION ALL " +
|
||||
"SELECT album_artists.artistId, albums.id, 'album' " +
|
||||
"FROM album_artists JOIN albums " +
|
||||
"ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
|
||||
"WHERE album_artists.sourceId = :sourceId AND albums.coverArt IS NOT NULL " +
|
||||
"UNION ALL " +
|
||||
"SELECT songs.artistId, songs.albumId, 'track' " +
|
||||
"FROM songs JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL AND albums.coverArt IS NOT NULL " +
|
||||
"UNION ALL " +
|
||||
"SELECT song_artists.artistId, songs.albumId, 'track' " +
|
||||
"FROM song_artists JOIN songs " +
|
||||
"ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
|
||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"WHERE song_artists.sourceId = :sourceId AND albums.coverArt IS NOT NULL"
|
||||
|
||||
internal const val ARTIST_ART_UPDATE_SQL =
|
||||
"WITH credits AS (SELECT artistId, albumId, " +
|
||||
"MIN(CASE creditType WHEN 'album' THEN 0 ELSE 1 END) AS creditRank " +
|
||||
"FROM ($ARTIST_COVER_CREDITS) GROUP BY artistId, albumId), " +
|
||||
"ranked AS (SELECT credits.artistId AS artistId, albums.coverArt AS coverArt, " +
|
||||
"ROW_NUMBER() OVER (PARTITION BY credits.artistId " +
|
||||
"ORDER BY credits.creditRank, albums.year DESC, albums.sortName COLLATE NOCASE, albums.id) AS coverRank " +
|
||||
"FROM credits JOIN albums ON albums.sourceId = :sourceId AND albums.id = credits.albumId " +
|
||||
"WHERE albums.coverArt IS NOT NULL), " +
|
||||
"unique_ranked AS (SELECT artistId, coverArt, MIN(coverRank) AS coverRank FROM ranked GROUP BY artistId, coverArt), " +
|
||||
"covers AS (SELECT artistId, group_concat(coverArt, char(31)) AS coverArts FROM (" +
|
||||
"SELECT artistId, coverArt, coverRank FROM unique_ranked WHERE coverRank <= 4 ORDER BY artistId, coverRank" +
|
||||
") GROUP BY artistId), " +
|
||||
"downloaded_albums AS (SELECT DISTINCT artistId, albumId FROM ($DOWNLOADED_COVER_ALBUM_ROWS)), " +
|
||||
"downloaded AS (SELECT dl.artistId AS artistId, albums.coverArt AS coverArt, " +
|
||||
"ROW_NUMBER() OVER (PARTITION BY dl.artistId " +
|
||||
"ORDER BY albums.year DESC, albums.sortName COLLATE NOCASE, albums.id) AS coverRank " +
|
||||
"FROM downloaded_albums dl JOIN albums ON albums.sourceId = :sourceId AND albums.id = dl.albumId " +
|
||||
"WHERE albums.coverArt IS NOT NULL), " +
|
||||
"unique_downloaded AS (SELECT artistId, coverArt, MIN(coverRank) AS coverRank FROM downloaded GROUP BY artistId, coverArt), " +
|
||||
"downloaded_covers AS (SELECT artistId, group_concat(coverArt, char(31)) AS coverArts FROM (" +
|
||||
"SELECT artistId, coverArt, coverRank FROM unique_downloaded WHERE coverRank <= 4 ORDER BY artistId, coverRank" +
|
||||
") GROUP BY artistId) " +
|
||||
"UPDATE artists SET " +
|
||||
"coverArts = COALESCE((SELECT coverArts FROM covers WHERE covers.artistId = artists.id), ''), " +
|
||||
"downloadedCoverArts = COALESCE((SELECT coverArts FROM downloaded_covers " +
|
||||
"WHERE downloaded_covers.artistId = artists.id), '') " +
|
||||
"WHERE sourceId = :sourceId"
|
||||
|
||||
internal const val DOWNLOADED_ARTIST_ART_UPDATE_SQL =
|
||||
"WITH downloaded_albums AS (SELECT DISTINCT artistId, albumId FROM ($DOWNLOADED_COVER_ALBUM_ROWS) " +
|
||||
"WHERE artistId IN (:artistIds)), " +
|
||||
"downloaded AS (SELECT dl.artistId AS artistId, albums.coverArt AS coverArt, " +
|
||||
"ROW_NUMBER() OVER (PARTITION BY dl.artistId " +
|
||||
"ORDER BY albums.year DESC, albums.sortName COLLATE NOCASE, albums.id) AS coverRank " +
|
||||
"FROM downloaded_albums dl JOIN albums ON albums.sourceId = :sourceId AND albums.id = dl.albumId " +
|
||||
"WHERE albums.coverArt IS NOT NULL), " +
|
||||
"unique_downloaded AS (SELECT artistId, coverArt, MIN(coverRank) AS coverRank FROM downloaded GROUP BY artistId, coverArt), " +
|
||||
"downloaded_covers AS (SELECT artistId, group_concat(coverArt, char(31)) AS coverArts FROM (" +
|
||||
"SELECT artistId, coverArt, coverRank FROM unique_downloaded WHERE coverRank <= 4 ORDER BY artistId, coverRank" +
|
||||
") GROUP BY artistId) " +
|
||||
"UPDATE artists SET downloadedCoverArts = COALESCE((SELECT coverArts FROM downloaded_covers " +
|
||||
"WHERE downloaded_covers.artistId = artists.id), '') " +
|
||||
"WHERE sourceId = :sourceId AND id IN (:artistIds)"
|
||||
|
||||
internal const val ARTISTS_FILTER =
|
||||
"FROM artists WHERE sourceId = :sourceId " +
|
||||
@@ -164,40 +257,10 @@ internal const val ARTISTS_FILTER =
|
||||
"OR (length(:search) < 3 AND (instr(lower(name), lower(:search)) > 0 " +
|
||||
"OR instr(lower(sortName), lower(:search)) > 0))) "
|
||||
|
||||
internal const val ARTIST_ALBUM_CREDIT_ART =
|
||||
"SELECT albums.coverArt FROM (" +
|
||||
"SELECT albums.id, albums.coverArt, albums.year, albums.sortName FROM albums " +
|
||||
"INDEXED BY index_albums_sourceId_artistId " +
|
||||
"WHERE albums.sourceId = artists.sourceId AND albums.artistId = artists.id " +
|
||||
"UNION ALL " +
|
||||
"SELECT albums.id, albums.coverArt, albums.year, albums.sortName FROM album_artists " +
|
||||
"JOIN albums ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
|
||||
"WHERE album_artists.sourceId = artists.sourceId AND album_artists.artistId = artists.id" +
|
||||
") albums ORDER BY albums.year DESC, albums.sortName COLLATE NOCASE, albums.id LIMIT 1"
|
||||
|
||||
internal const val ARTIST_TRACK_CREDIT_ART =
|
||||
"SELECT albums.coverArt FROM (" +
|
||||
"SELECT albums.id, albums.coverArt, albums.year, albums.sortName FROM songs " +
|
||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"WHERE songs.sourceId = artists.sourceId AND songs.artistId = artists.id " +
|
||||
"UNION ALL " +
|
||||
"SELECT albums.id, albums.coverArt, albums.year, albums.sortName FROM song_artists " +
|
||||
"JOIN songs ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
|
||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"WHERE song_artists.sourceId = artists.sourceId AND song_artists.artistId = artists.id" +
|
||||
") albums ORDER BY albums.year DESC, albums.sortName COLLATE NOCASE, albums.id LIMIT 1"
|
||||
|
||||
internal const val ARTIST_COVER_ART =
|
||||
"COALESCE(artists.coverArt, CASE " +
|
||||
"WHEN EXISTS (SELECT 1 FROM albums WHERE albums.sourceId = artists.sourceId AND albums.artistId = artists.id) " +
|
||||
"OR EXISTS (SELECT 1 FROM album_artists " +
|
||||
"WHERE album_artists.sourceId = artists.sourceId AND album_artists.artistId = artists.id) " +
|
||||
"THEN ($ARTIST_ALBUM_CREDIT_ART) ELSE ($ARTIST_TRACK_CREDIT_ART) END)"
|
||||
|
||||
internal const val ARTIST_COLUMNS =
|
||||
"artists.sourceId, artists.id, artists.name, artists.albumCount, artists.starred, " +
|
||||
"$ARTIST_COVER_ART AS coverArt, artists.playCount, artists.played, artists.songCount, " +
|
||||
"artists.sortName, artists.credited"
|
||||
"artists.coverArt, artists.artistImageUrl, artists.coverArts, artists.downloadedCoverArts, " +
|
||||
"artists.playCount, artists.played, artists.songCount, artists.sortName, artists.credited"
|
||||
|
||||
// With the Downloaded filter on, count the albums that actually have a download so the number under
|
||||
// an artist matches the albums its detail screen lists.
|
||||
@@ -206,8 +269,8 @@ internal const val ARTISTS_SELECT =
|
||||
"CASE WHEN :downloadedFilter = 1 THEN (" +
|
||||
"SELECT COUNT(*) FROM ($DOWNLOADED_ARTIST_ALBUMS)" +
|
||||
") ELSE artists.albumCount END AS albumCount, " +
|
||||
"artists.starred, $ARTIST_COVER_ART AS coverArt, artists.playCount, artists.played, artists.songCount, " +
|
||||
"artists.sortName, artists.credited "
|
||||
"artists.starred, artists.coverArt, artists.artistImageUrl, artists.coverArts, artists.downloadedCoverArts, " +
|
||||
"artists.playCount, artists.played, artists.songCount, artists.sortName, artists.credited "
|
||||
|
||||
internal const val ARTIST_ORDER_BY_NAME = "sortName COLLATE NOCASE, id"
|
||||
internal const val ARTIST_ORDER_BY_NAME_REVERSED = "sortName COLLATE NOCASE DESC, id DESC"
|
||||
@@ -334,10 +397,20 @@ interface LibraryDao {
|
||||
)
|
||||
suspend fun recomputeArtistAggregates(sourceId: Long)
|
||||
|
||||
@Query(ARTIST_ART_UPDATE_SQL)
|
||||
suspend fun recomputeArtistArt(sourceId: Long)
|
||||
|
||||
@Query(DOWNLOADED_ARTIST_ART_UPDATE_SQL)
|
||||
suspend fun recomputeDownloadedArtistArt(
|
||||
sourceId: Long,
|
||||
artistIds: List<String>,
|
||||
)
|
||||
|
||||
@Transaction
|
||||
suspend fun recomputeAggregates(sourceId: Long) {
|
||||
recomputeAlbumPlayData(sourceId)
|
||||
recomputeArtistAggregates(sourceId)
|
||||
recomputeArtistArt(sourceId)
|
||||
}
|
||||
|
||||
@Query(
|
||||
@@ -381,6 +454,12 @@ interface LibraryDao {
|
||||
songId: String,
|
||||
): String?
|
||||
|
||||
@Query("SELECT DISTINCT artistId FROM ($CREDITED_SONG_ROWS) WHERE songId IN (:songIds)")
|
||||
suspend fun creditedArtistIdsForSongs(
|
||||
sourceId: Long,
|
||||
songIds: List<String>,
|
||||
): List<String>
|
||||
|
||||
// Counted locally when a scrobble is submitted, so the play sorts move before the next sync; the
|
||||
// sync then overwrites with the server's own number.
|
||||
@Transaction
|
||||
@@ -1070,26 +1149,6 @@ interface LibraryDao {
|
||||
artistId: String,
|
||||
): Flow<Artist?>
|
||||
|
||||
@Query("SELECT coverArt FROM artists WHERE sourceId = :sourceId AND id = :artistId")
|
||||
suspend fun artistOwnCoverArt(
|
||||
sourceId: Long,
|
||||
artistId: String,
|
||||
): String?
|
||||
|
||||
@Query(
|
||||
"SELECT albums.coverArt FROM (" +
|
||||
"SELECT albumId, MIN(CASE creditType WHEN 'album' THEN 0 ELSE 1 END) AS creditRank " +
|
||||
"FROM ($CREDITED_ALBUM_ROWS) WHERE artistId = :artistId GROUP BY albumId" +
|
||||
") credits " +
|
||||
"JOIN albums ON albums.sourceId = :sourceId AND albums.id = credits.albumId " +
|
||||
"WHERE albums.coverArt IS NOT NULL " +
|
||||
"ORDER BY credits.creditRank, albums.year DESC, albums.sortName COLLATE NOCASE, albums.id LIMIT 4",
|
||||
)
|
||||
suspend fun artistFallbackCoverArts(
|
||||
sourceId: Long,
|
||||
artistId: String,
|
||||
): List<String>
|
||||
|
||||
@Query("SELECT * FROM artist_bios WHERE sourceId = :sourceId AND artistId = :artistId")
|
||||
fun artistBio(
|
||||
sourceId: Long,
|
||||
|
||||
@@ -695,6 +695,29 @@ private suspend fun createSearchIndex(
|
||||
connection.execSQL("INSERT INTO `$table`(`$table`) VALUES('rebuild')")
|
||||
}
|
||||
|
||||
val MIGRATION_35_36 =
|
||||
object : Migration(35, 36) {
|
||||
override suspend fun migrate(connection: SQLiteConnection) {
|
||||
connection.execSQL("ALTER TABLE `artists` ADD COLUMN `artistImageUrl` TEXT")
|
||||
connection.execSQL("ALTER TABLE `artists` ADD COLUMN `coverArts` TEXT NOT NULL DEFAULT ''")
|
||||
connection.execSQL("ALTER TABLE `artists` ADD COLUMN `downloadedCoverArts` TEXT NOT NULL DEFAULT ''")
|
||||
backfillArtistArt(connection)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun backfillArtistArt(connection: SQLiteConnection) {
|
||||
val sourceIds = mutableListOf<Long>()
|
||||
connection.prepare("SELECT id FROM sources").use { statement ->
|
||||
while (statement.step()) sourceIds += statement.getLong(0)
|
||||
}
|
||||
sourceIds.forEach { sourceId ->
|
||||
connection.prepare(ARTIST_ART_UPDATE_SQL).use { statement ->
|
||||
statement.bindLong(1, sourceId)
|
||||
statement.step()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATIONS: Array<Migration> =
|
||||
arrayOf(
|
||||
MIGRATION_1_2,
|
||||
@@ -731,4 +754,5 @@ val MIGRATIONS: Array<Migration> =
|
||||
MIGRATION_32_33,
|
||||
MIGRATION_33_34,
|
||||
MIGRATION_34_35,
|
||||
MIGRATION_35_36,
|
||||
)
|
||||
|
||||
@@ -28,6 +28,7 @@ import com.subtracks.data.model.SongDownload
|
||||
import com.subtracks.data.model.SongGenre
|
||||
import com.subtracks.data.model.SongSearch
|
||||
import com.subtracks.data.model.Source
|
||||
import com.subtracks.data.model.StringListConverter
|
||||
import com.subtracks.data.model.SubsonicSource
|
||||
import com.subtracks.data.model.UpNextEntry
|
||||
|
||||
@@ -56,7 +57,7 @@ import com.subtracks.data.model.UpNextEntry
|
||||
SongDownload::class,
|
||||
PendingAction::class,
|
||||
],
|
||||
version = 35,
|
||||
version = 36,
|
||||
exportSchema = true,
|
||||
)
|
||||
@ColumnTypeConverters(
|
||||
@@ -65,6 +66,7 @@ import com.subtracks.data.model.UpNextEntry
|
||||
DownloadErrorConverter::class,
|
||||
PendingActionKindConverter::class,
|
||||
ArtistRefsConverter::class,
|
||||
StringListConverter::class,
|
||||
)
|
||||
abstract class SubtracksDatabase : RoomDatabase() {
|
||||
abstract fun libraryDao(): LibraryDao
|
||||
|
||||
@@ -10,3 +10,8 @@ fun coverArtKey(
|
||||
coverArt: String,
|
||||
thumbnail: Boolean,
|
||||
): String = "$sourceId:$coverArt:$thumbnail"
|
||||
|
||||
fun artistImageKey(
|
||||
sourceId: Long,
|
||||
artistId: String,
|
||||
): String = "$sourceId:artist-image:$artistId"
|
||||
|
||||
@@ -106,10 +106,12 @@ data class DownloadArtwork(
|
||||
val sourceId: Long,
|
||||
val albumId: String?,
|
||||
val artistId: String?,
|
||||
val albumArtistId: String?,
|
||||
val albumRow: String?,
|
||||
val artistRow: String?,
|
||||
val albumCoverArt: String?,
|
||||
val artistCoverArt: String?,
|
||||
val albumArtistCoverArt: String?,
|
||||
) {
|
||||
val hasMissingLibraryRow: Boolean
|
||||
get() = (albumId != null && albumRow == null) || (artistId != null && artistRow == null)
|
||||
|
||||
@@ -88,6 +88,9 @@ data class Artist(
|
||||
val albumCount: Long,
|
||||
val starred: Long?,
|
||||
val coverArt: String? = null,
|
||||
val artistImageUrl: String? = null,
|
||||
@ColumnInfo(defaultValue = "''") val coverArts: List<String> = emptyList(),
|
||||
@ColumnInfo(defaultValue = "''") val downloadedCoverArts: List<String> = emptyList(),
|
||||
@ColumnInfo(defaultValue = "0") val playCount: Long = 0,
|
||||
val played: Long? = null,
|
||||
@ColumnInfo(defaultValue = "0") val songCount: Long = 0,
|
||||
@@ -592,6 +595,16 @@ class ArtistRefsConverter {
|
||||
fun toArtistRefs(value: String): List<ArtistRef> = ArtistRefsCodec.decode(value)
|
||||
}
|
||||
|
||||
class StringListConverter {
|
||||
@ColumnTypeConverter
|
||||
fun fromList(values: List<String>): String = values.joinToString(LIST_SEPARATOR)
|
||||
|
||||
@ColumnTypeConverter
|
||||
fun toList(value: String): List<String> = if (value.isEmpty()) emptyList() else value.split(LIST_SEPARATOR)
|
||||
}
|
||||
|
||||
private const val LIST_SEPARATOR = "\u001F"
|
||||
|
||||
object ArtistRefsCodec {
|
||||
fun encode(refs: List<ArtistRef>): String =
|
||||
buildString {
|
||||
|
||||
@@ -11,6 +11,7 @@ import com.subtracks.data.download.EngineDownload
|
||||
import com.subtracks.data.download.EngineRequest
|
||||
import com.subtracks.data.download.EngineStatus
|
||||
import com.subtracks.data.download.looksLikeAudio
|
||||
import com.subtracks.data.model.Artist
|
||||
import com.subtracks.data.model.ArtistBio
|
||||
import com.subtracks.data.model.BulkDownloadAction
|
||||
import com.subtracks.data.model.DownloadError
|
||||
@@ -19,6 +20,7 @@ import com.subtracks.data.model.DownloadStatus
|
||||
import com.subtracks.data.model.DownloadedSong
|
||||
import com.subtracks.data.model.ListDownloadStatus
|
||||
import com.subtracks.data.model.SongDownload
|
||||
import com.subtracks.data.model.artistImageKey
|
||||
import com.subtracks.data.model.coverArtKey
|
||||
import com.subtracks.log.Log
|
||||
import kotlinx.coroutines.CancellationException
|
||||
@@ -350,6 +352,9 @@ class DownloadRepository(
|
||||
rows.groupBy { it.sourceId }.forEach { (sourceId, group) ->
|
||||
group.forEach { file(sourceId, it.songId).delete() }
|
||||
group.map { it.songId }.chunked(DELETE_CHUNK).forEach { db.downloadDao().deleteSongs(sourceId, it) }
|
||||
if (group.any { it.status == DownloadStatus.Completed }) {
|
||||
refreshDownloadedArtistArt(sourceId, group.map { it.songId })
|
||||
}
|
||||
}
|
||||
sweep(db.downloadDao().all())
|
||||
}
|
||||
@@ -413,13 +418,18 @@ class DownloadRepository(
|
||||
) {
|
||||
val song = db.libraryDao().songOnce(sourceId, songId)
|
||||
val album = song?.albumId?.let { db.libraryDao().albumOnce(sourceId, it) }
|
||||
val songArtist = song?.artistId?.let { db.libraryDao().artistOnce(sourceId, it) }
|
||||
val albumArtist = album?.artistId?.let { db.libraryDao().artistOnce(sourceId, it) }
|
||||
listOfNotNull(song?.artistId, album?.artistId).forEach(artistIds::add)
|
||||
listOfNotNull(songArtist, albumArtist).forEach { storeArtistImage(sourceId, it) }
|
||||
val artistFallbackCovers =
|
||||
listOfNotNull(songArtist, albumArtist)
|
||||
.filter { it.artistImageUrl.isNullOrBlank() }
|
||||
.mapNotNull { it.coverArt }
|
||||
val coverArt =
|
||||
(
|
||||
listOfNotNull(
|
||||
album?.coverArt,
|
||||
song?.artistId?.let { db.libraryDao().artistOnce(sourceId, it)?.coverArt },
|
||||
) + db.downloadDao().playlistCoversForSong(sourceId, songId)
|
||||
listOfNotNull(album?.coverArt) + artistFallbackCovers +
|
||||
db.downloadDao().playlistCoversForSong(sourceId, songId)
|
||||
).distinct()
|
||||
coverArt.forEach { cover ->
|
||||
storeArtwork(sourceId, cover, thumbnail = false)
|
||||
@@ -427,6 +437,33 @@ class DownloadRepository(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun storeArtistImage(
|
||||
sourceId: Long,
|
||||
artist: Artist,
|
||||
) {
|
||||
val imageUrl = artist.artistImageUrl
|
||||
if (!imageUrl.isNullOrBlank()) {
|
||||
val cacheKey = artistImageKey(sourceId, artist.id)
|
||||
if (artworkStore.file(sourceId, cacheKey).exists()) return
|
||||
val bytes =
|
||||
try {
|
||||
artworkFetcher.fetch(imageUrl)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
if (bytes != null && bytes.isNotEmpty()) {
|
||||
artworkStore.write(sourceId, cacheKey, bytes)
|
||||
return
|
||||
}
|
||||
}
|
||||
artist.coverArt?.let {
|
||||
storeArtwork(sourceId, it, thumbnail = false)
|
||||
storeArtwork(sourceId, it, thumbnail = true)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchArtistBio(
|
||||
sourceId: Long,
|
||||
artistId: String,
|
||||
@@ -491,11 +528,15 @@ class DownloadRepository(
|
||||
.mapValues { (sourceId, rows) ->
|
||||
if (rows.any { it.hasMissingLibraryRow }) return@mapValues null
|
||||
val covers =
|
||||
rows.flatMap { row -> listOfNotNull(row.albumCoverArt, row.artistCoverArt) } +
|
||||
playlistCovers[sourceId].orEmpty()
|
||||
covers
|
||||
.flatMap { cover -> listOf(false, true).map { thumbnail -> coverArtKey(sourceId, cover, thumbnail) } }
|
||||
.toSet()
|
||||
rows.flatMap { row ->
|
||||
listOfNotNull(row.albumCoverArt, row.artistCoverArt, row.albumArtistCoverArt)
|
||||
} + playlistCovers[sourceId].orEmpty()
|
||||
val artistImages = rows.flatMap { row -> listOfNotNull(row.artistId, row.albumArtistId) }
|
||||
(
|
||||
covers.flatMap { cover ->
|
||||
listOf(false, true).map { thumbnail -> coverArtKey(sourceId, cover, thumbnail) }
|
||||
} + artistImages.map { artistImageKey(sourceId, it) }
|
||||
).toSet()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -545,6 +586,7 @@ class DownloadRepository(
|
||||
row.engineId?.let { engine.cancel(listOf(it)) }
|
||||
file(sourceId, songId).delete()
|
||||
db.downloadDao().delete(sourceId, songId)
|
||||
if (row.status == DownloadStatus.Completed) refreshDownloadedArtistArt(sourceId, listOf(songId))
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -625,6 +667,11 @@ class DownloadRepository(
|
||||
},
|
||||
)
|
||||
if (updated != row) db.downloadDao().upsert(updated)
|
||||
if (updated.status != row.status &&
|
||||
(updated.status == DownloadStatus.Completed || row.status == DownloadStatus.Completed)
|
||||
) {
|
||||
refreshDownloadedArtistArt(row.sourceId, listOf(row.songId))
|
||||
}
|
||||
if (updated.status != row.status) {
|
||||
when (updated.status) {
|
||||
DownloadStatus.Completed -> Log.i("download", "completed ${row.songId} (${updated.total} bytes)")
|
||||
@@ -642,9 +689,22 @@ class DownloadRepository(
|
||||
row.engineId?.let { engine.cancel(listOf(it)) }
|
||||
file(row.sourceId, row.songId).delete()
|
||||
db.downloadDao().upsert(row.copy(status = DownloadStatus.Failed, engineId = null, error = error))
|
||||
if (row.status == DownloadStatus.Completed) {
|
||||
refreshDownloadedArtistArt(row.sourceId, listOf(row.songId))
|
||||
}
|
||||
Log.w("download", "failed ${row.songId}: $error")
|
||||
}
|
||||
|
||||
private suspend fun refreshDownloadedArtistArt(
|
||||
sourceId: Long,
|
||||
songIds: Collection<String>,
|
||||
) {
|
||||
songIds.chunked(500).forEach { songs ->
|
||||
val artistIds = db.libraryDao().creditedArtistIdsForSongs(sourceId, songs).distinct()
|
||||
artistIds.chunked(500).forEach { db.libraryDao().recomputeDownloadedArtistArt(sourceId, it) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensurePolling() {
|
||||
if (pollJob?.isActive == true) return
|
||||
pollJob =
|
||||
|
||||
@@ -353,16 +353,6 @@ class LibraryRepository(
|
||||
artistId: String,
|
||||
): Flow<Artist?> = db.libraryDao().artist(sourceId, artistId)
|
||||
|
||||
suspend fun artistOwnCoverArt(
|
||||
sourceId: Long,
|
||||
artistId: String,
|
||||
): String? = db.libraryDao().artistOwnCoverArt(sourceId, artistId)
|
||||
|
||||
suspend fun artistFallbackCoverArts(
|
||||
sourceId: Long,
|
||||
artistId: String,
|
||||
): List<String> = db.libraryDao().artistFallbackCoverArts(sourceId, artistId)
|
||||
|
||||
fun artistName(
|
||||
sourceId: Long,
|
||||
artistId: String,
|
||||
|
||||
@@ -9,6 +9,7 @@ import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.data.model.Source
|
||||
import com.subtracks.data.model.SubsonicConfig
|
||||
import com.subtracks.data.model.SubsonicSource
|
||||
import com.subtracks.data.model.artistImageKey
|
||||
import com.subtracks.data.model.coverArtKey
|
||||
import com.subtracks.data.net.NetworkMode
|
||||
import com.subtracks.data.prefs.StreamQuality
|
||||
@@ -143,6 +144,18 @@ class SourceRepository(
|
||||
return CoverArtRef(url = url, cacheKey = cacheKey)
|
||||
}
|
||||
|
||||
fun artistImage(
|
||||
artistId: String,
|
||||
url: String?,
|
||||
): CoverArtRef? {
|
||||
val current = active ?: return null
|
||||
if (url.isNullOrBlank()) return null
|
||||
val cacheKey = artistImageKey(current.id, artistId)
|
||||
artworkStore.uri(current.id, cacheKey)?.let { return CoverArtRef(url = it, cacheKey = cacheKey) }
|
||||
if (_offline.value) return null
|
||||
return CoverArtRef(url = url, cacheKey = cacheKey)
|
||||
}
|
||||
|
||||
fun networkCoverArt(
|
||||
sourceId: Long,
|
||||
coverArt: String,
|
||||
|
||||
@@ -32,6 +32,7 @@ object SubsonicXml {
|
||||
albumCount = attrs.longAttr("albumCount") ?: 0L,
|
||||
starred = attrs.dateAttr("starred"),
|
||||
coverArt = attrs.attr("coverArt").ifEmpty { null },
|
||||
artistImageUrl = attrs.attr("artistImageUrl").ifEmpty { null },
|
||||
sortName = attrs.attr("sortName").takeIf { it.isNotBlank() } ?: attrs.attr("name"),
|
||||
)
|
||||
|
||||
|
||||
@@ -438,7 +438,7 @@ private fun discHash(
|
||||
|
||||
private val primaryKey = listOf("sourceId", "id")
|
||||
|
||||
private val artistColumns = listOf("sourceId", "id", "name", "albumCount", "starred", "coverArt", "sortName")
|
||||
private val artistColumns = listOf("sourceId", "id", "name", "albumCount", "starred", "coverArt", "artistImageUrl", "sortName")
|
||||
|
||||
private val albumColumns =
|
||||
listOf(
|
||||
@@ -515,7 +515,7 @@ private val playlistSongColumns = listOf("sourceId", "playlistId", "songId", "po
|
||||
|
||||
private val playlistSongKey = listOf("sourceId", "playlistId", "position")
|
||||
|
||||
private fun Artist.values() = listOf<Any?>(sourceId, id, name, albumCount, starred, coverArt, sortName)
|
||||
private fun Artist.values() = listOf<Any?>(sourceId, id, name, albumCount, starred, coverArt, artistImageUrl, sortName)
|
||||
|
||||
private fun Album.values() =
|
||||
listOf<Any?>(
|
||||
|
||||
@@ -375,7 +375,15 @@ private fun MainNavigation() {
|
||||
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
|
||||
},
|
||||
onArtistClick = { artist ->
|
||||
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artist.id, Routes.artist(artist.id, artist.coverArt))
|
||||
navigateDetail(
|
||||
Routes.ARTIST_DETAIL,
|
||||
"artistId",
|
||||
artist.id,
|
||||
Routes.artist(
|
||||
artist.id,
|
||||
artist.coverArt ?: artist.coverArts.firstOrNull(),
|
||||
),
|
||||
)
|
||||
},
|
||||
onPlaylistClick = { playlist ->
|
||||
navigateDetail(Routes.PLAYLIST_DETAIL, "playlistId", playlist.id, Routes.playlist(playlist.id))
|
||||
@@ -418,7 +426,15 @@ private fun MainNavigation() {
|
||||
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
|
||||
},
|
||||
onArtistClick = { artist ->
|
||||
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artist.id, Routes.artist(artist.id, artist.coverArt))
|
||||
navigateDetail(
|
||||
Routes.ARTIST_DETAIL,
|
||||
"artistId",
|
||||
artist.id,
|
||||
Routes.artist(
|
||||
artist.id,
|
||||
artist.coverArt ?: artist.coverArts.firstOrNull(),
|
||||
),
|
||||
)
|
||||
},
|
||||
onPlaylistClick = { playlist ->
|
||||
navigateDetail(Routes.PLAYLIST_DETAIL, "playlistId", playlist.id, Routes.playlist(playlist.id))
|
||||
@@ -514,7 +530,15 @@ private fun MainNavigation() {
|
||||
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
|
||||
},
|
||||
onArtistClick = { artist ->
|
||||
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artist.id, Routes.artist(artist.id, artist.coverArt))
|
||||
navigateDetail(
|
||||
Routes.ARTIST_DETAIL,
|
||||
"artistId",
|
||||
artist.id,
|
||||
Routes.artist(
|
||||
artist.id,
|
||||
artist.coverArt ?: artist.coverArts.firstOrNull(),
|
||||
),
|
||||
)
|
||||
},
|
||||
onViewAlbum = { albumId -> navigateDetail(Routes.ALBUM_DETAIL, "albumId", albumId, Routes.album(albumId)) },
|
||||
onViewArtist = { artistId ->
|
||||
@@ -538,7 +562,15 @@ private fun MainNavigation() {
|
||||
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
|
||||
},
|
||||
onArtistClick = { artist ->
|
||||
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artist.id, Routes.artist(artist.id, artist.coverArt))
|
||||
navigateDetail(
|
||||
Routes.ARTIST_DETAIL,
|
||||
"artistId",
|
||||
artist.id,
|
||||
Routes.artist(
|
||||
artist.id,
|
||||
artist.coverArt ?: artist.coverArts.firstOrNull(),
|
||||
),
|
||||
)
|
||||
},
|
||||
onViewAlbum = { albumId -> navigateDetail(Routes.ALBUM_DETAIL, "albumId", albumId, Routes.album(albumId)) },
|
||||
onViewArtist = { artistId ->
|
||||
@@ -562,7 +594,15 @@ private fun MainNavigation() {
|
||||
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
|
||||
},
|
||||
onArtistClick = { artist ->
|
||||
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artist.id, Routes.artist(artist.id, artist.coverArt))
|
||||
navigateDetail(
|
||||
Routes.ARTIST_DETAIL,
|
||||
"artistId",
|
||||
artist.id,
|
||||
Routes.artist(
|
||||
artist.id,
|
||||
artist.coverArt ?: artist.coverArts.firstOrNull(),
|
||||
),
|
||||
)
|
||||
},
|
||||
onViewAlbum = { albumId -> navigateDetail(Routes.ALBUM_DETAIL, "albumId", albumId, Routes.album(albumId)) },
|
||||
onViewArtist = { artistId ->
|
||||
@@ -588,7 +628,15 @@ private fun MainNavigation() {
|
||||
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
|
||||
},
|
||||
onArtistClick = { artist ->
|
||||
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artist.id, Routes.artist(artist.id, artist.coverArt))
|
||||
navigateDetail(
|
||||
Routes.ARTIST_DETAIL,
|
||||
"artistId",
|
||||
artist.id,
|
||||
Routes.artist(
|
||||
artist.id,
|
||||
artist.coverArt ?: artist.coverArts.firstOrNull(),
|
||||
),
|
||||
)
|
||||
},
|
||||
onPlaylistClick = { playlist ->
|
||||
navigateDetail(Routes.PLAYLIST_DETAIL, "playlistId", playlist.id, Routes.playlist(playlist.id))
|
||||
|
||||
@@ -7,7 +7,9 @@ import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
@@ -19,49 +21,120 @@ import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import com.subtracks.data.model.Artist
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.data.repo.LibraryRepository
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.min
|
||||
import kotlin.math.sin
|
||||
|
||||
internal suspend fun loadArtistArtCovers(
|
||||
library: LibraryRepository,
|
||||
source: SourceRepository,
|
||||
artistId: String,
|
||||
): List<CoverArtRef> {
|
||||
val sourceId = library.activeSourceId.first() ?: return emptyList()
|
||||
val own = library.artistOwnCoverArt(sourceId, artistId)
|
||||
val ids =
|
||||
if (own != null) {
|
||||
listOf(own)
|
||||
} else {
|
||||
library.artistFallbackCoverArts(sourceId, artistId)
|
||||
}
|
||||
return ids.take(4).mapNotNull { source.coverArt(it, thumbnail = true) }
|
||||
data class ArtistArt(
|
||||
val image: CoverArtRef? = null,
|
||||
val covers: List<CoverArtRef> = emptyList(),
|
||||
val fallback: ArtistArt? = null,
|
||||
) {
|
||||
val primary: CoverArtRef? get() = image ?: covers.firstOrNull()
|
||||
}
|
||||
|
||||
fun SourceRepository.artistArt(
|
||||
artist: Artist,
|
||||
forIcon: Boolean = true,
|
||||
): ArtistArt =
|
||||
resolveArtistArt(
|
||||
artist = artist,
|
||||
coverArt = this::coverArt,
|
||||
artistImage = this::artistImage,
|
||||
offline = offline.value,
|
||||
forIcon = forIcon,
|
||||
)
|
||||
|
||||
internal fun resolveArtistArt(
|
||||
artist: Artist,
|
||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||
artistImage: (String, String?) -> CoverArtRef?,
|
||||
offline: Boolean,
|
||||
forIcon: Boolean,
|
||||
): ArtistArt {
|
||||
val coverIds = if (offline) artist.downloadedCoverArts else artist.coverArts
|
||||
val grid =
|
||||
ArtistArt(
|
||||
covers = coverIds.mapNotNull { coverArt(it, forIcon || coverIds.size > 1) },
|
||||
)
|
||||
val own =
|
||||
artist.coverArt
|
||||
?.let { coverArt(it, forIcon) }
|
||||
?.let { ArtistArt(image = it, fallback = grid) }
|
||||
return artist.artistImageUrl
|
||||
?.let { artistImage(artist.id, it) }
|
||||
?.let { ArtistArt(image = it, fallback = own ?: grid) }
|
||||
?: own
|
||||
?: grid
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ArtistIcon(
|
||||
artist: Artist,
|
||||
fallback: CoverArtRef?,
|
||||
loadCovers: suspend (String) -> List<CoverArtRef>,
|
||||
art: ArtistArt,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val covers by produceState(initialValue = emptyList<CoverArtRef>(), artist.id) {
|
||||
value = loadCovers(artist.id)
|
||||
var shown by remember(artist.id, art) { mutableStateOf(art) }
|
||||
val showFallback = {
|
||||
shown.fallback?.let { shown = it } ?: run { shown = shown.copy(image = null) }
|
||||
}
|
||||
if (covers.size >= 2) {
|
||||
ArtistArtGrid(covers = covers, name = artist.name, modifier = modifier)
|
||||
} else {
|
||||
CoverArt(
|
||||
ref = covers.firstOrNull() ?: fallback,
|
||||
name = artist.name,
|
||||
showPlaceholder = covers.isEmpty() && fallback == null,
|
||||
modifier = modifier,
|
||||
)
|
||||
when {
|
||||
shown.image != null -> {
|
||||
CoverArt(
|
||||
ref = shown.image,
|
||||
name = artist.name,
|
||||
modifier = modifier,
|
||||
onError = showFallback,
|
||||
)
|
||||
}
|
||||
|
||||
shown.covers.size >= 2 -> {
|
||||
ArtistArtGrid(covers = shown.covers, name = artist.name, modifier = modifier)
|
||||
}
|
||||
|
||||
else -> {
|
||||
CoverArt(ref = shown.covers.firstOrNull(), name = artist.name, modifier = modifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ArtistHeroArt(
|
||||
art: ArtistArt,
|
||||
name: String,
|
||||
thumbnail: CoverArtRef?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var shown by remember(art) { mutableStateOf(art) }
|
||||
val showFallback = {
|
||||
shown.fallback?.let { shown = it } ?: run { shown = shown.copy(image = null) }
|
||||
}
|
||||
when {
|
||||
shown.image != null -> {
|
||||
CoverArt(
|
||||
ref = shown.image,
|
||||
name = name,
|
||||
thumbnailRef = thumbnail,
|
||||
showPlaceholder = false,
|
||||
modifier = modifier,
|
||||
onError = showFallback,
|
||||
)
|
||||
}
|
||||
|
||||
shown.covers.isNotEmpty() -> {
|
||||
ArtistArtGrid(
|
||||
covers = shown.covers,
|
||||
name = name,
|
||||
singleThumbnail = thumbnail,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
CoverArt(ref = null, name = name, modifier = modifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -85,10 +85,12 @@ fun CoverArt(
|
||||
square: Boolean = true,
|
||||
elevation: Dp = 0.dp,
|
||||
colorFilter: ColorFilter? = null,
|
||||
onError: (() -> Unit)? = null,
|
||||
) {
|
||||
val context = LocalPlatformContext.current
|
||||
val refKey = ref?.cacheKey
|
||||
val thumbnailKey = thumbnailRef?.cacheKey
|
||||
val hasThumbnailFallback = thumbnailRef != null && thumbnailRef != ref
|
||||
var failed by remember(refKey, thumbnailKey) { mutableStateOf(false) }
|
||||
var thumbnailLoaded by remember(refKey, thumbnailKey) { mutableStateOf(false) }
|
||||
var thumbnailRatio by remember(refKey, thumbnailKey) { mutableStateOf(thumbnailKey?.let(ArtworkRatioCache::get)) }
|
||||
@@ -131,7 +133,10 @@ fun CoverArt(
|
||||
.ratioOrNull()
|
||||
?.let { ArtworkRatioCache.put(ref.cacheKey, it) }
|
||||
},
|
||||
onError = { failed = true },
|
||||
onError = {
|
||||
failed = true
|
||||
if (!hasThumbnailFallback) onError?.invoke()
|
||||
},
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
@@ -160,7 +165,10 @@ fun CoverArt(
|
||||
)
|
||||
}
|
||||
},
|
||||
onError = { failed = true },
|
||||
onError = {
|
||||
failed = true
|
||||
if (!hasThumbnailFallback) onError?.invoke()
|
||||
},
|
||||
contentScale = ContentScale.Fit,
|
||||
filterQuality = FilterQuality.High,
|
||||
)
|
||||
|
||||
@@ -57,6 +57,7 @@ import com.subtracks.data.model.Playlist
|
||||
import com.subtracks.data.model.Song
|
||||
import com.subtracks.data.source.StarType
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import com.subtracks.ui.components.ArtistArt
|
||||
import com.subtracks.ui.components.ArtistIcon
|
||||
import com.subtracks.ui.components.ContextMenuHost
|
||||
import com.subtracks.ui.components.CoverArt
|
||||
@@ -109,7 +110,7 @@ fun HomeListRoute(
|
||||
genres = genres,
|
||||
decades = decades,
|
||||
coverArt = viewModel::coverArt,
|
||||
artistCovers = viewModel::artistArtCovers,
|
||||
artistArt = { viewModel.artistArt(it) },
|
||||
playingSongId = playback.item?.id,
|
||||
onBack = onBack,
|
||||
onAlbumClick = onAlbumClick,
|
||||
@@ -135,7 +136,7 @@ fun HomeListScreen(
|
||||
genres: List<String> = emptyList(),
|
||||
decades: List<Long> = emptyList(),
|
||||
coverArt: (String?, Boolean) -> CoverArtRef? = { _, _ -> null },
|
||||
artistCovers: suspend (String) -> List<CoverArtRef> = { emptyList() },
|
||||
artistArt: (Artist) -> ArtistArt = { ArtistArt() },
|
||||
playingSongId: String? = null,
|
||||
onBack: () -> Unit = {},
|
||||
onAlbumClick: (Album) -> Unit = {},
|
||||
@@ -194,8 +195,7 @@ fun HomeListScreen(
|
||||
OfflineListKind.Artists -> {
|
||||
ArtistsList(
|
||||
artists = artists,
|
||||
coverArt = coverArt,
|
||||
artistCovers = artistCovers,
|
||||
artistArt = artistArt,
|
||||
info = { artistListInfo(request, it, now, resources) },
|
||||
onArtistClick = onArtistClick,
|
||||
onLongClick = onLongClick,
|
||||
@@ -249,8 +249,7 @@ fun HomeListScreen(
|
||||
HomeSection.RecentlyPlayedArtists, HomeSection.MostPlayedArtists -> {
|
||||
ArtistsList(
|
||||
artists = artists,
|
||||
coverArt = coverArt,
|
||||
artistCovers = artistCovers,
|
||||
artistArt = artistArt,
|
||||
info = { artistListInfo(request, it, now, resources) },
|
||||
onArtistClick = onArtistClick,
|
||||
onLongClick = onLongClick,
|
||||
@@ -448,8 +447,7 @@ internal fun AlbumListRow(
|
||||
@Composable
|
||||
private fun ArtistsList(
|
||||
artists: LazyPagingItems<Artist>,
|
||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||
artistCovers: suspend (String) -> List<CoverArtRef>,
|
||||
artistArt: (Artist) -> ArtistArt,
|
||||
info: (Artist) -> String?,
|
||||
onArtistClick: (Artist) -> Unit,
|
||||
onLongClick: (MenuTarget) -> Unit,
|
||||
@@ -466,13 +464,13 @@ private fun ArtistsList(
|
||||
items(count = artists.itemCount, key = artists.itemKey { it.id }) { index ->
|
||||
val artist = artists[index]
|
||||
if (artist != null) {
|
||||
val art = artistArt(artist)
|
||||
ArtistListRow(
|
||||
artist = artist,
|
||||
coverArt = coverArt,
|
||||
artistCovers = artistCovers,
|
||||
art = art,
|
||||
info = info(artist),
|
||||
onClick = { onArtistClick(artist) },
|
||||
onLongClick = { onLongClick(MenuTarget.Artist(artist, coverArt(artist.coverArt, true))) },
|
||||
onLongClick = { onLongClick(MenuTarget.Artist(artist, art.primary)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -482,11 +480,10 @@ private fun ArtistsList(
|
||||
@Composable
|
||||
internal fun ArtistListRow(
|
||||
artist: Artist,
|
||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||
art: ArtistArt,
|
||||
info: String?,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
artistCovers: suspend (String) -> List<CoverArtRef> = { emptyList() },
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
@@ -498,8 +495,7 @@ internal fun ArtistListRow(
|
||||
) {
|
||||
ArtistIcon(
|
||||
artist = artist,
|
||||
fallback = coverArt(artist.coverArt, true),
|
||||
loadCovers = artistCovers,
|
||||
art = art,
|
||||
modifier = Modifier.size(ALBUM_ROW_COVER).clip(CircleShape),
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
|
||||
@@ -16,7 +16,8 @@ import com.subtracks.data.prefs.PlaylistSort
|
||||
import com.subtracks.data.repo.LibraryRepository
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import com.subtracks.ui.components.loadArtistArtCovers
|
||||
import com.subtracks.ui.components.ArtistArt
|
||||
import com.subtracks.ui.components.artistArt
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
@@ -157,7 +158,10 @@ class HomeListViewModel(
|
||||
thumbnail: Boolean,
|
||||
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
|
||||
|
||||
suspend fun artistArtCovers(artistId: String): List<CoverArtRef> = loadArtistArtCovers(libraryRepository, sourceRepository, artistId)
|
||||
fun artistArt(
|
||||
artist: Artist,
|
||||
forIcon: Boolean = true,
|
||||
): ArtistArt = sourceRepository.artistArt(artist, forIcon)
|
||||
|
||||
fun play(song: Song) {
|
||||
val genre = request.genre
|
||||
|
||||
@@ -72,6 +72,7 @@ import com.subtracks.data.model.Playlist
|
||||
import com.subtracks.data.model.Song
|
||||
import com.subtracks.data.source.StarType
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import com.subtracks.ui.components.ArtistArt
|
||||
import com.subtracks.ui.components.ArtistIcon
|
||||
import com.subtracks.ui.components.ContextMenuHost
|
||||
import com.subtracks.ui.components.CoverArt
|
||||
@@ -138,7 +139,7 @@ fun HomeRoute(
|
||||
playlists = viewModel.downloadedPlaylists.collectAsLazyPagingItems(),
|
||||
songs = viewModel.downloadedSongs.collectAsStateWithLifecycle(initialValue = emptyList()).value,
|
||||
coverArt = viewModel::coverArt,
|
||||
artistCovers = viewModel::artistArtCovers,
|
||||
artistArt = { viewModel.artistArt(it) },
|
||||
playingSongId = playback.item?.id,
|
||||
topInset = topInset,
|
||||
bottomInset = bottomInset,
|
||||
@@ -154,7 +155,7 @@ fun HomeRoute(
|
||||
HomeScreen(
|
||||
feed = feed,
|
||||
coverArt = viewModel::coverArt,
|
||||
artistCovers = viewModel::artistArtCovers,
|
||||
artistArt = { viewModel.artistArt(it) },
|
||||
playingSongId = playback.item?.id,
|
||||
topInset = topInset,
|
||||
bottomInset = bottomInset,
|
||||
@@ -176,7 +177,7 @@ fun HomeScreen(
|
||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||
playingSongId: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
artistCovers: suspend (String) -> List<CoverArtRef> = { emptyList() },
|
||||
artistArt: (Artist) -> ArtistArt = { ArtistArt() },
|
||||
topInset: Dp = 0.dp,
|
||||
bottomInset: Dp = 0.dp,
|
||||
onAlbumClick: (Album) -> Unit = {},
|
||||
@@ -219,7 +220,7 @@ fun HomeScreen(
|
||||
HomeSection.RecentlyPlayedArtists,
|
||||
feed.recentlyPlayedArtists,
|
||||
coverArt,
|
||||
artistCovers,
|
||||
artistArt,
|
||||
onArtistClick,
|
||||
onLongClick,
|
||||
onMore,
|
||||
@@ -254,7 +255,7 @@ fun HomeScreen(
|
||||
HomeSection.MostPlayedArtists,
|
||||
feed.mostPlayedArtists,
|
||||
coverArt,
|
||||
artistCovers,
|
||||
artistArt,
|
||||
onArtistClick,
|
||||
onLongClick,
|
||||
onMore,
|
||||
@@ -302,7 +303,7 @@ fun OfflineHomeScreen(
|
||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||
playingSongId: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
artistCovers: suspend (String) -> List<CoverArtRef> = { emptyList() },
|
||||
artistArt: (Artist) -> ArtistArt = { ArtistArt() },
|
||||
topInset: Dp = 0.dp,
|
||||
bottomInset: Dp = 0.dp,
|
||||
onAlbumClick: (Album) -> Unit = {},
|
||||
@@ -319,7 +320,7 @@ fun OfflineHomeScreen(
|
||||
) {
|
||||
homeTitle()
|
||||
downloadedAlbumRow(albums, coverArt, onAlbumClick, onLongClick) { onMore(OfflineListKind.Albums) }
|
||||
downloadedArtistRow(artists, coverArt, artistCovers, onArtistClick, onLongClick) { onMore(OfflineListKind.Artists) }
|
||||
downloadedArtistRow(artists, coverArt, artistArt, onArtistClick, onLongClick) { onMore(OfflineListKind.Artists) }
|
||||
downloadedPlaylistRow(playlists, coverArt, onPlaylistClick, onLongClick) { onMore(OfflineListKind.Playlists) }
|
||||
songList(
|
||||
key = "downloaded-songs",
|
||||
@@ -372,7 +373,7 @@ private fun LazyListScope.downloadedAlbumRow(
|
||||
private fun LazyListScope.downloadedArtistRow(
|
||||
artists: LazyPagingItems<Artist>,
|
||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||
artistCovers: suspend (String) -> List<CoverArtRef>,
|
||||
artistArt: (Artist) -> ArtistArt,
|
||||
onArtistClick: (Artist) -> Unit,
|
||||
onLongClick: (MenuTarget) -> Unit,
|
||||
onMore: () -> Unit,
|
||||
@@ -390,13 +391,12 @@ private fun LazyListScope.downloadedArtistRow(
|
||||
items(count = artists.itemCount, key = artists.itemKey { it.id }) { index ->
|
||||
val artist = artists[index]
|
||||
if (artist != null) {
|
||||
val ref = coverArt(artist.coverArt, true)
|
||||
val art = artistArt(artist)
|
||||
ArtistTile(
|
||||
artist = artist,
|
||||
ref = ref,
|
||||
artistCovers = artistCovers,
|
||||
art = art,
|
||||
onClick = { onArtistClick(artist) },
|
||||
onLongClick = { onLongClick(MenuTarget.Artist(artist, ref)) },
|
||||
onLongClick = { onLongClick(MenuTarget.Artist(artist, art.primary)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -616,7 +616,7 @@ private fun LazyListScope.artistRow(
|
||||
section: HomeSection,
|
||||
artists: List<Artist>,
|
||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||
artistCovers: suspend (String) -> List<CoverArtRef>,
|
||||
artistArt: (Artist) -> ArtistArt,
|
||||
onArtistClick: (Artist) -> Unit,
|
||||
onLongClick: (MenuTarget) -> Unit,
|
||||
onMore: (HomeSection) -> Unit,
|
||||
@@ -635,13 +635,12 @@ private fun LazyListScope.artistRow(
|
||||
modifier = Modifier.padding(top = SECTION_CONTENT_TOP),
|
||||
) {
|
||||
items(artists, key = { it.id }) { artist ->
|
||||
val ref = coverArt(artist.coverArt, true)
|
||||
val art = artistArt(artist)
|
||||
ArtistTile(
|
||||
artist = artist,
|
||||
ref = ref,
|
||||
artistCovers = artistCovers,
|
||||
art = art,
|
||||
onClick = { onArtistClick(artist) },
|
||||
onLongClick = { onLongClick(MenuTarget.Artist(artist, ref)) },
|
||||
onLongClick = { onLongClick(MenuTarget.Artist(artist, art.primary)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -790,8 +789,7 @@ private fun AlbumTile(
|
||||
@Composable
|
||||
private fun ArtistTile(
|
||||
artist: Artist,
|
||||
ref: CoverArtRef?,
|
||||
artistCovers: suspend (String) -> List<CoverArtRef>,
|
||||
art: ArtistArt,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
) {
|
||||
@@ -801,8 +799,7 @@ private fun ArtistTile(
|
||||
) {
|
||||
ArtistIcon(
|
||||
artist = artist,
|
||||
fallback = ref,
|
||||
loadCovers = artistCovers,
|
||||
art = art,
|
||||
modifier = Modifier.size(ARTIST_TILE_SIZE).clip(CircleShape),
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
|
||||
@@ -17,7 +17,8 @@ import com.subtracks.data.repo.rediscoverCutoff
|
||||
import com.subtracks.data.sync.SyncManager
|
||||
import com.subtracks.data.sync.SyncStatus
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import com.subtracks.ui.components.loadArtistArtCovers
|
||||
import com.subtracks.ui.components.ArtistArt
|
||||
import com.subtracks.ui.components.artistArt
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -128,7 +129,10 @@ class HomeViewModel(
|
||||
thumbnail: Boolean,
|
||||
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
|
||||
|
||||
suspend fun artistArtCovers(artistId: String): List<CoverArtRef> = loadArtistArtCovers(libraryRepository, sourceRepository, artistId)
|
||||
fun artistArt(
|
||||
artist: Artist,
|
||||
forIcon: Boolean = true,
|
||||
): ArtistArt = sourceRepository.artistArt(artist, forIcon)
|
||||
|
||||
fun playStarred(song: Song) {
|
||||
viewModelScope.playStarredList(libraryRepository, playbackController, song)
|
||||
|
||||
@@ -102,7 +102,8 @@ import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.data.model.ListDownloadStatus
|
||||
import com.subtracks.data.source.StarType
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import com.subtracks.ui.components.ArtistArtGrid
|
||||
import com.subtracks.ui.components.ArtistArt
|
||||
import com.subtracks.ui.components.ArtistHeroArt
|
||||
import com.subtracks.ui.components.CloseWhenDownloadsGone
|
||||
import com.subtracks.ui.components.ContextMenuHost
|
||||
import com.subtracks.ui.components.CoverArt
|
||||
@@ -205,7 +206,7 @@ fun ArtistDetailRoute(
|
||||
LaunchedEffect(artistId) { viewModel.refreshBio() }
|
||||
val art by viewModel.art.collectAsStateWithLifecycle()
|
||||
val artThumbnail by viewModel.artThumbnail.collectAsStateWithLifecycle()
|
||||
val artCovers by viewModel.artCovers.collectAsStateWithLifecycle()
|
||||
val heroArt by viewModel.heroArt.collectAsStateWithLifecycle()
|
||||
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
|
||||
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
||||
CloseWhenDownloadsGone(downloadStatus.downloaded, offline, onBack)
|
||||
@@ -245,7 +246,7 @@ fun ArtistDetailRoute(
|
||||
bioExpanded = bioExpanded,
|
||||
art = art,
|
||||
artThumbnail = artThumbnail,
|
||||
artCovers = artCovers,
|
||||
heroArt = heroArt,
|
||||
artwork = rememberArtworkColors(shortcutArt ?: artThumbnail ?: art, THEME_TRANSITION_MS, fallbackName = artistName),
|
||||
coverArt = viewModel::coverArt,
|
||||
onBack = onBack,
|
||||
@@ -286,6 +287,7 @@ fun ArtistDetailScreen(
|
||||
art: CoverArtRef?,
|
||||
artThumbnail: CoverArtRef? = null,
|
||||
artCovers: List<CoverArtRef> = emptyList(),
|
||||
heroArt: ArtistArt? = null,
|
||||
artwork: ArtworkColors? = null,
|
||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||
onBack: () -> Unit,
|
||||
@@ -403,22 +405,16 @@ fun ArtistDetailScreen(
|
||||
.onSizeChanged { imageHeightPx = it.height.toFloat() },
|
||||
) {
|
||||
Box(Modifier.fillMaxWidth()) {
|
||||
if (artCovers.isNotEmpty()) {
|
||||
ArtistArtGrid(
|
||||
covers = artCovers,
|
||||
name = displayName,
|
||||
singleThumbnail = artThumbnail,
|
||||
modifier = Modifier.fillMaxWidth().height(ART_HEIGHT),
|
||||
)
|
||||
} else {
|
||||
CoverArt(
|
||||
ref = art,
|
||||
name = displayName,
|
||||
thumbnailRef = artThumbnail,
|
||||
showPlaceholder = art == null,
|
||||
modifier = Modifier.fillMaxWidth().height(ART_HEIGHT),
|
||||
)
|
||||
}
|
||||
ArtistHeroArt(
|
||||
art =
|
||||
heroArt ?: ArtistArt(
|
||||
image = art.takeIf { artCovers.isEmpty() },
|
||||
covers = artCovers,
|
||||
),
|
||||
name = displayName,
|
||||
thumbnail = artThumbnail,
|
||||
modifier = Modifier.fillMaxWidth().height(ART_HEIGHT),
|
||||
)
|
||||
if (nameScrimAlpha > 0f) {
|
||||
Box(
|
||||
modifier =
|
||||
|
||||
@@ -13,6 +13,8 @@ import com.subtracks.data.repo.DownloadRepository
|
||||
import com.subtracks.data.repo.LibraryRepository
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import com.subtracks.ui.components.ArtistArt
|
||||
import com.subtracks.ui.components.artistArt
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -136,41 +138,22 @@ class ArtistDetailViewModel(
|
||||
private val _artThumbnail = MutableStateFlow<CoverArtRef?>(null)
|
||||
val artThumbnail: StateFlow<CoverArtRef?> = _artThumbnail
|
||||
|
||||
private val _artCovers = MutableStateFlow<List<CoverArtRef>>(emptyList())
|
||||
val artCovers: StateFlow<List<CoverArtRef>> = _artCovers
|
||||
private val _heroArt = MutableStateFlow<ArtistArt?>(null)
|
||||
val heroArt: StateFlow<ArtistArt?> = _heroArt
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
artist
|
||||
.filterNotNull()
|
||||
combine(artist.filterNotNull(), sourceRepository.offline) { loaded, _ -> loaded }
|
||||
.collectLatest { loaded ->
|
||||
val (full, thumbnail, covers) = resolveArt(loaded)
|
||||
_art.value = full
|
||||
_artThumbnail.value = thumbnail
|
||||
_artCovers.value = covers
|
||||
val hero = sourceRepository.artistArt(loaded, forIcon = false)
|
||||
val icon = sourceRepository.artistArt(loaded, forIcon = true)
|
||||
_art.value = hero.image ?: hero.covers.firstOrNull()
|
||||
_artThumbnail.value = icon.image ?: icon.covers.firstOrNull()
|
||||
_heroArt.value = hero
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun resolveArt(artist: Artist): Triple<CoverArtRef?, CoverArtRef?, List<CoverArtRef>> {
|
||||
val id = sourceId.first()
|
||||
val own = libraryRepository.artistOwnCoverArt(id, artistId)
|
||||
val coverIds =
|
||||
if (own != null) {
|
||||
listOf(own)
|
||||
} else {
|
||||
libraryRepository.artistFallbackCoverArts(id, artistId).distinct()
|
||||
}
|
||||
if (coverIds.isEmpty()) return Triple(null, null, emptyList())
|
||||
|
||||
val thumbnails = coverIds.mapNotNull { sourceRepository.coverArt(it, thumbnail = true) }
|
||||
val fulls = coverIds.mapNotNull { sourceRepository.coverArt(it) }
|
||||
val full = fulls.firstOrNull()
|
||||
val thumbnail = thumbnails.firstOrNull()
|
||||
val covers = if (coverIds.size == 1) listOfNotNull(full ?: thumbnail) else thumbnails
|
||||
return Triple(full ?: thumbnail, thumbnail, covers)
|
||||
}
|
||||
|
||||
fun coverArt(
|
||||
coverArt: String?,
|
||||
thumbnail: Boolean,
|
||||
|
||||
@@ -29,6 +29,7 @@ import com.subtracks.R
|
||||
import com.subtracks.data.model.Artist
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.data.model.ListDownloadStatus
|
||||
import com.subtracks.ui.components.ArtistArt
|
||||
import com.subtracks.ui.components.ArtistIcon
|
||||
import com.subtracks.ui.components.EmptyState
|
||||
import com.subtracks.ui.components.FilteredEmptyState
|
||||
@@ -55,7 +56,7 @@ fun ArtistsContent(
|
||||
onSync: () -> Unit = {},
|
||||
downloadStatuses: Map<String, ListDownloadStatus> = emptyMap(),
|
||||
scrollToTopKey: Any? = null,
|
||||
artistCovers: suspend (String) -> List<CoverArtRef> = { emptyList() },
|
||||
artistArt: (Artist) -> ArtistArt = { ArtistArt() },
|
||||
) {
|
||||
when {
|
||||
items.itemCount == 0 && items.loadState.refresh is LoadState.Loading -> {
|
||||
@@ -83,7 +84,8 @@ fun ArtistsContent(
|
||||
items(count = items.itemCount, key = items.itemKey { it.id }) { index ->
|
||||
val artist = items[index]
|
||||
if (artist != null) {
|
||||
PrefetchArtworkSeeds(coverArt(artist.coverArt, true))
|
||||
val art = artistArt(artist)
|
||||
PrefetchArtworkSeeds(art.primary)
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
@@ -107,8 +109,7 @@ fun ArtistsContent(
|
||||
leadingContent = {
|
||||
ArtistIcon(
|
||||
artist = artist,
|
||||
fallback = coverArt(artist.coverArt, true),
|
||||
loadCovers = artistCovers,
|
||||
art = art,
|
||||
modifier = Modifier.size(48.dp).clip(CircleShape),
|
||||
)
|
||||
},
|
||||
@@ -119,7 +120,7 @@ fun ArtistsContent(
|
||||
Modifier.combinedClickable(
|
||||
onClick = { onArtistClick(artist) },
|
||||
onLongClick = {
|
||||
onLongClick(MenuTarget.Artist(artist, coverArt(artist.coverArt, true), downloadStatuses[artist.id]))
|
||||
onLongClick(MenuTarget.Artist(artist, art.primary, downloadStatuses[artist.id]))
|
||||
},
|
||||
),
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
|
||||
@@ -120,6 +120,7 @@ import com.subtracks.data.prefs.PlaylistSort
|
||||
import com.subtracks.data.prefs.StarredFilter
|
||||
import com.subtracks.data.source.StarType
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import com.subtracks.ui.components.ArtistArt
|
||||
import com.subtracks.ui.components.ContextMenuHost
|
||||
import com.subtracks.ui.components.DeleteDownloadsDialog
|
||||
import com.subtracks.ui.components.DismissOnRequest
|
||||
@@ -308,7 +309,7 @@ fun LibraryRoute(
|
||||
artists = viewModel.artists,
|
||||
playlists = viewModel.playlists,
|
||||
coverArt = viewModel::coverArt,
|
||||
artistCovers = viewModel::artistArtCovers,
|
||||
artistArt = { viewModel.artistArt(it) },
|
||||
onAlbumClick = onAlbumClick,
|
||||
onArtistClick = onArtistClick,
|
||||
onPlaylistClick = onPlaylistClick,
|
||||
@@ -397,7 +398,7 @@ fun LibraryScreen(
|
||||
onSync: () -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
artistCovers: suspend (String) -> List<CoverArtRef> = { emptyList() },
|
||||
artistArt: (Artist) -> ArtistArt = { ArtistArt() },
|
||||
onOpenSearch: () -> Unit = {},
|
||||
offline: Boolean = false,
|
||||
onExitOffline: () -> Unit = {},
|
||||
@@ -541,7 +542,7 @@ fun LibraryScreen(
|
||||
onSync = onSync,
|
||||
downloadStatuses = artistDownloads,
|
||||
scrollToTopKey = scrollToTopKey,
|
||||
artistCovers = artistCovers,
|
||||
artistArt = artistArt,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,8 @@ import com.subtracks.data.repo.SourceRepository
|
||||
import com.subtracks.data.sync.SyncManager
|
||||
import com.subtracks.data.sync.SyncStatus
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import com.subtracks.ui.components.loadArtistArtCovers
|
||||
import com.subtracks.ui.components.ArtistArt
|
||||
import com.subtracks.ui.components.artistArt
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -212,7 +213,10 @@ class LibraryViewModel(
|
||||
thumbnail: Boolean,
|
||||
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
|
||||
|
||||
suspend fun artistArtCovers(artistId: String): List<CoverArtRef> = loadArtistArtCovers(libraryRepository, sourceRepository, artistId)
|
||||
fun artistArt(
|
||||
artist: Artist,
|
||||
forIcon: Boolean = true,
|
||||
): ArtistArt = sourceRepository.artistArt(artist, forIcon)
|
||||
|
||||
fun sync() {
|
||||
if (syncManager.status.value !is SyncStatus.Running) syncManager.requestSync()
|
||||
|
||||
@@ -46,6 +46,7 @@ import com.subtracks.data.model.Playlist
|
||||
import com.subtracks.data.model.Song
|
||||
import com.subtracks.data.source.StarType
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import com.subtracks.ui.components.ArtistArt
|
||||
import com.subtracks.ui.components.ContextMenuHost
|
||||
import com.subtracks.ui.components.EmptyState
|
||||
import com.subtracks.ui.components.ItemActions
|
||||
@@ -106,7 +107,7 @@ fun SearchRoute(
|
||||
if (next != 0) filterMask = next
|
||||
},
|
||||
coverArt = viewModel::coverArt,
|
||||
artistCovers = viewModel::artistArtCovers,
|
||||
artistArt = { viewModel.artistArt(it) },
|
||||
playingSongId = playback.item?.id,
|
||||
onBack = onBack,
|
||||
onAlbumClick = {
|
||||
@@ -147,7 +148,7 @@ fun SearchScreen(
|
||||
filters: Set<SearchFilter> = ALL_FILTERS,
|
||||
onToggleFilter: (SearchFilter) -> Unit = {},
|
||||
coverArt: (String?, Boolean) -> CoverArtRef? = { _, _ -> null },
|
||||
artistCovers: suspend (String) -> List<CoverArtRef> = { emptyList() },
|
||||
artistArt: (Artist) -> ArtistArt = { ArtistArt() },
|
||||
playingSongId: String? = null,
|
||||
onBack: () -> Unit = {},
|
||||
onAlbumClick: (Album) -> Unit = {},
|
||||
@@ -217,7 +218,7 @@ fun SearchScreen(
|
||||
contentPadding = PaddingValues(bottom = 24.dp),
|
||||
) {
|
||||
if (showPlaylists) playlistResults(results.playlists, playlistsLabel, coverArt, onPlaylistClick, onLongClick)
|
||||
if (showArtists) artistResults(results.artists, artistsLabel, coverArt, artistCovers, onArtistClick, onLongClick)
|
||||
if (showArtists) artistResults(results.artists, artistsLabel, artistArt, onArtistClick, onLongClick)
|
||||
if (showAlbums) albumResults(results.albums, albumsLabel, coverArt, onAlbumClick, onLongClick)
|
||||
if (showSongs) songResults(results.songs, songsLabel, coverArt, playingSongId, onSongClick, onLongClick)
|
||||
}
|
||||
@@ -310,21 +311,20 @@ private fun LazyListScope.albumResults(
|
||||
private fun LazyListScope.artistResults(
|
||||
artists: List<Artist>,
|
||||
header: String,
|
||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||
artistCovers: suspend (String) -> List<CoverArtRef>,
|
||||
artistArt: (Artist) -> ArtistArt,
|
||||
onClick: (Artist) -> Unit,
|
||||
onLongClick: (MenuTarget) -> Unit,
|
||||
) {
|
||||
if (artists.isEmpty()) return
|
||||
resultHeader(header)
|
||||
items(artists, key = { "artist-${it.id}" }) { artist ->
|
||||
val art = artistArt(artist)
|
||||
ArtistListRow(
|
||||
artist = artist,
|
||||
coverArt = coverArt,
|
||||
artistCovers = artistCovers,
|
||||
art = art,
|
||||
info = null,
|
||||
onClick = { onClick(artist) },
|
||||
onLongClick = { onLongClick(MenuTarget.Artist(artist, coverArt(artist.coverArt, true))) },
|
||||
onLongClick = { onLongClick(MenuTarget.Artist(artist, art.primary)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,8 @@ import com.subtracks.data.model.Song
|
||||
import com.subtracks.data.repo.LibraryRepository
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import com.subtracks.ui.components.loadArtistArtCovers
|
||||
import com.subtracks.ui.components.ArtistArt
|
||||
import com.subtracks.ui.components.artistArt
|
||||
import com.subtracks.ui.home.playSongInContext
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -75,7 +76,10 @@ class SearchViewModel(
|
||||
thumbnail: Boolean,
|
||||
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
|
||||
|
||||
suspend fun artistArtCovers(artistId: String): List<CoverArtRef> = loadArtistArtCovers(libraryRepository, sourceRepository, artistId)
|
||||
fun artistArt(
|
||||
artist: Artist,
|
||||
forIcon: Boolean = true,
|
||||
): ArtistArt = sourceRepository.artistArt(artist, forIcon)
|
||||
|
||||
fun play(song: Song) {
|
||||
viewModelScope.playSongInContext(libraryRepository, playbackController, song)
|
||||
|
||||
@@ -548,7 +548,7 @@ class LibraryDaoTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun artistArtFallsBackToTheFirstAlbumCreditThenTheFirstTrackCredit() =
|
||||
fun artistArtIsMaterializedFromAlbumCreditsThenTrackCredits() =
|
||||
runTest {
|
||||
val sourceId = source()
|
||||
val dao = db.libraryDao()
|
||||
@@ -615,40 +615,121 @@ class LibraryDaoTest {
|
||||
}
|
||||
}
|
||||
|
||||
dao.recomputeArtistArt(sourceId)
|
||||
|
||||
val artists = dao.artistsByName(sourceId, 0, "").page().associateBy { it.id }
|
||||
|
||||
assertEquals("own-art", artists.getValue("ar-own").coverArt)
|
||||
assertEquals("new-art", artists.getValue("ar-album").coverArt)
|
||||
assertEquals("track-art", artists.getValue("ar-track").coverArt)
|
||||
assertEquals("album-ref-art", artists.getValue("ar-album-ref").coverArt)
|
||||
assertEquals("track-ref-art", artists.getValue("ar-track-ref").coverArt)
|
||||
assertNull(artists.getValue("ar-album").coverArt)
|
||||
assertNull(artists.getValue("ar-track").coverArt)
|
||||
assertNull(artists.getValue("ar-album-ref").coverArt)
|
||||
assertNull(artists.getValue("ar-track-ref").coverArt)
|
||||
assertNull(artists.getValue("ar-null-art").coverArt)
|
||||
assertNull(artists.getValue("ar-none").coverArt)
|
||||
assertEquals("new-art", dao.artistOnce(sourceId, "ar-album")!!.coverArt)
|
||||
assertEquals(listOf("own-album"), artists.getValue("ar-own").coverArts)
|
||||
assertEquals(listOf("new-art", "old-art"), artists.getValue("ar-album").coverArts)
|
||||
assertEquals(listOf("track-art"), artists.getValue("ar-track").coverArts)
|
||||
assertEquals(listOf("album-ref-art"), artists.getValue("ar-album-ref").coverArts)
|
||||
assertEquals(listOf("track-ref-art"), artists.getValue("ar-track-ref").coverArts)
|
||||
assertEquals(listOf("later-art"), artists.getValue("ar-null-art").coverArts)
|
||||
assertEquals(emptyList<String>(), artists.getValue("ar-none").coverArts)
|
||||
assertEquals(listOf("new-art", "old-art"), dao.artistOnce(sourceId, "ar-album")!!.coverArts)
|
||||
assertEquals(
|
||||
"album-ref-art",
|
||||
listOf("album-ref-art"),
|
||||
dao
|
||||
.artistsPaging(artistsDefaultQuery(sourceId, 0, "", ArtistSort.Name, false))
|
||||
.page()
|
||||
.first { it.id == "ar-album-ref" }
|
||||
.coverArt,
|
||||
.coverArts,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun artistArtFallbackFindsCreditsThroughArtistIndexes() =
|
||||
fun downloadedArtistArtUsesOnlyDownloadedAlbums() =
|
||||
runTest {
|
||||
val plan = plan("SELECT $ARTIST_COLUMNS $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_NAME LIMIT 20")
|
||||
|
||||
assertTrue(plan, plan.contains("USING INDEX index_albums_sourceId_artistId (sourceId=? AND artistId=?)"))
|
||||
assertTrue(
|
||||
plan,
|
||||
plan.contains("USING INDEX index_album_artists_sourceId_artistId (sourceId=? AND artistId=?)"),
|
||||
val sourceId = source()
|
||||
val dao = db.libraryDao()
|
||||
dao.upsertArtists(listOf(artist(sourceId, "ar-1", "Artist", albumCount = 3)))
|
||||
dao.upsertAlbums(
|
||||
listOf(
|
||||
album(sourceId, "al-1", "One", year = 2020, starred = null)
|
||||
.copy(artistId = "ar-1", coverArt = "art-1"),
|
||||
album(sourceId, "al-2", "Two", year = 2021, starred = null)
|
||||
.copy(artistId = "ar-1", coverArt = "art-2"),
|
||||
album(sourceId, "al-3", "Three", year = 2022, starred = null)
|
||||
.copy(artistId = "ar-1", coverArt = "art-3"),
|
||||
),
|
||||
)
|
||||
assertTrue(plan, plan.contains("USING INDEX index_songs_sourceId_artistId (sourceId=? AND artistId=?)"))
|
||||
assertTrue(
|
||||
plan,
|
||||
plan.contains("USING INDEX index_song_artists_sourceId_artistId (sourceId=? AND artistId=?)"),
|
||||
dao.upsertSongs(
|
||||
listOf(
|
||||
song(sourceId, "s1", "One", starred = null).copy(albumId = "al-1", artistId = "ar-1"),
|
||||
song(sourceId, "s2", "Two", starred = null).copy(albumId = "al-2", artistId = "ar-1"),
|
||||
song(sourceId, "s3", "Three", starred = null).copy(albumId = "al-3", artistId = "ar-1"),
|
||||
),
|
||||
)
|
||||
db.downloadDao().upsert(SongDownload(sourceId, "s1", DownloadStatus.Completed))
|
||||
db.downloadDao().upsert(SongDownload(sourceId, "s2", DownloadStatus.Queued))
|
||||
|
||||
dao.recomputeArtistArt(sourceId)
|
||||
|
||||
val artist = dao.artistOnce(sourceId, "ar-1")!!
|
||||
assertEquals(listOf("art-3", "art-2", "art-1"), artist.coverArts)
|
||||
assertEquals(listOf("art-1"), artist.downloadedCoverArts)
|
||||
|
||||
db.downloadDao().upsert(SongDownload(sourceId, "s2", DownloadStatus.Completed))
|
||||
dao.recomputeDownloadedArtistArt(sourceId, listOf("ar-1"))
|
||||
|
||||
assertEquals(listOf("art-2", "art-1"), dao.artistOnce(sourceId, "ar-1")!!.downloadedCoverArts)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun artistArtMaterializationUsesTheCreditIndexes() =
|
||||
runTest {
|
||||
val plan = plan(ARTIST_ART_UPDATE_SQL)
|
||||
|
||||
assertTrue(plan, plan.contains("index_albums_sourceId_artistId"))
|
||||
assertTrue(plan, plan.contains("index_album_artists_sourceId_artistId"))
|
||||
assertTrue(plan, plan.contains("index_songs_sourceId_artistId"))
|
||||
assertTrue(plan, plan.contains("index_song_artists_sourceId_artistId"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun creditedSongArtistsIncludeTheAlbumArtistAndTrackCredits() =
|
||||
runTest {
|
||||
val sourceId = source()
|
||||
val dao = db.libraryDao()
|
||||
dao.upsertArtists(
|
||||
listOf(
|
||||
artist(sourceId, "ar-album", "Album Artist", albumCount = 1),
|
||||
artist(sourceId, "ar-track", "Track Artist", albumCount = 0),
|
||||
artist(sourceId, "ar-guest", "Guest", albumCount = 0),
|
||||
),
|
||||
)
|
||||
dao.upsertAlbums(
|
||||
listOf(
|
||||
album(sourceId, "al-1", "Album", year = null, starred = null)
|
||||
.copy(artistId = "ar-album"),
|
||||
),
|
||||
)
|
||||
dao.upsertSongs(
|
||||
listOf(
|
||||
song(sourceId, "s1", "Song", starred = null)
|
||||
.copy(albumId = "al-1", artistId = "ar-track"),
|
||||
),
|
||||
)
|
||||
db.useWriterConnection { connection ->
|
||||
connection.usePrepared(
|
||||
"INSERT INTO song_artists (sourceId, songId, position, artistId, name) " +
|
||||
"VALUES (?, 's1', 0, 'ar-guest', 'Guest')",
|
||||
) { statement ->
|
||||
statement.bindLong(1, sourceId)
|
||||
statement.step()
|
||||
}
|
||||
}
|
||||
|
||||
assertEquals(
|
||||
setOf("ar-album", "ar-track", "ar-guest"),
|
||||
dao.creditedArtistIdsForSongs(sourceId, listOf("s1")).toSet(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -153,6 +153,70 @@ class MigrationsTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theArtistArtColumnsDefaultToEmptyAndAcceptValues() =
|
||||
runTest {
|
||||
driver.open(":memory:").use { migrated ->
|
||||
applySchema(migrated, 35)
|
||||
migrated.execSQL(
|
||||
"INSERT INTO sources (id, name, address, isActive, createdAt) VALUES (1, 's', 'http://x', 1, 0)",
|
||||
)
|
||||
migrated.execSQL(
|
||||
"INSERT INTO artists (sourceId, id, name, albumCount, starred) " +
|
||||
"VALUES (1, 'ar1', 'Artist', 0, NULL)",
|
||||
)
|
||||
|
||||
MIGRATION_35_36.migrate(migrated)
|
||||
|
||||
assertEquals(1L, long(migrated, "SELECT artistImageUrl IS NULL FROM artists WHERE id = 'ar1'"))
|
||||
assertEquals("", text(migrated, "SELECT coverArts FROM artists WHERE id = 'ar1'"))
|
||||
assertEquals("", text(migrated, "SELECT downloadedCoverArts FROM artists WHERE id = 'ar1'"))
|
||||
migrated.execSQL(
|
||||
"UPDATE artists SET artistImageUrl = 'http://img', coverArts = 'a\u001fb', " +
|
||||
"downloadedCoverArts = 'c' WHERE id = 'ar1'",
|
||||
)
|
||||
assertEquals("http://img", text(migrated, "SELECT artistImageUrl FROM artists WHERE id = 'ar1'"))
|
||||
assertEquals("a\u001fb", text(migrated, "SELECT coverArts FROM artists WHERE id = 'ar1'"))
|
||||
assertEquals("c", text(migrated, "SELECT downloadedCoverArts FROM artists WHERE id = 'ar1'"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theArtistArtMigrationBackfillsExistingLibraries() =
|
||||
runTest {
|
||||
driver.open(":memory:").use { migrated ->
|
||||
applySchema(migrated, 35)
|
||||
migrated.execSQL(
|
||||
"INSERT INTO sources (id, name, address, isActive, createdAt) VALUES (1, 's', 'http://x', 1, 0)",
|
||||
)
|
||||
migrated.execSQL(
|
||||
"INSERT INTO artists (sourceId, id, name, albumCount, starred) " +
|
||||
"VALUES (1, 'ar1', 'Artist', 3, NULL)",
|
||||
)
|
||||
migrated.execSQL(
|
||||
"INSERT INTO albums (sourceId, id, artistId, name, albumArtist, created, coverArt, genre, " +
|
||||
"year, starred, songCount) VALUES " +
|
||||
"(1, 'al1', 'ar1', 'Shared', 'Artist', 0, 'shared', NULL, 2020, NULL, 1), " +
|
||||
"(1, 'al2', 'ar1', 'Shared Newer', 'Artist', 0, 'shared', NULL, 2021, NULL, 1), " +
|
||||
"(1, 'al3', 'ar1', 'Other', 'Artist', 0, 'other', NULL, 2019, NULL, 1)",
|
||||
)
|
||||
migrated.execSQL(
|
||||
"INSERT INTO songs (sourceId, id, albumId, artistId, title) VALUES " +
|
||||
"(1, 's1', 'al1', 'ar1', 'One'), (1, 's2', 'al2', 'ar1', 'Two'), " +
|
||||
"(1, 's3', 'al3', 'ar1', 'Three')",
|
||||
)
|
||||
migrated.execSQL(
|
||||
"INSERT INTO song_downloads (sourceId, songId, status, bytes, total) VALUES " +
|
||||
"(1, 's1', 'Completed', 1, 1), (1, 's2', 'Completed', 1, 1)",
|
||||
)
|
||||
|
||||
MIGRATION_35_36.migrate(migrated)
|
||||
|
||||
assertEquals("shared\u001fother", text(migrated, "SELECT coverArts FROM artists WHERE id = 'ar1'"))
|
||||
assertEquals("shared", text(migrated, "SELECT downloadedCoverArts FROM artists WHERE id = 'ar1'"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theGenreBackfillTrimsAndSkipsBlankSingularGenres() =
|
||||
runTest {
|
||||
|
||||
@@ -26,6 +26,7 @@ import com.subtracks.data.model.Song
|
||||
import com.subtracks.data.model.SongDownload
|
||||
import com.subtracks.data.model.Source
|
||||
import com.subtracks.data.model.SubsonicSource
|
||||
import com.subtracks.data.model.artistImageKey
|
||||
import com.subtracks.data.model.coverArtKey
|
||||
import com.subtracks.data.prefs.fakeUserPreferences
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -70,11 +71,12 @@ class DownloadRepositoryTest {
|
||||
private val requestedArt = CopyOnWriteArrayList<String>()
|
||||
private val artFetchedAfterTheEngineRequest = CopyOnWriteArrayList<Boolean>()
|
||||
private var failArtworkFetch = false
|
||||
private val failingArtUrls = CopyOnWriteArrayList<String>()
|
||||
private val fetcher =
|
||||
ArtworkFetcher { url ->
|
||||
requestedArt += url
|
||||
artFetchedAfterTheEngineRequest += engine.requests.isNotEmpty()
|
||||
if (failArtworkFetch) throw IOException("boom")
|
||||
if (failArtworkFetch || url in failingArtUrls) throw IOException("boom")
|
||||
byteArrayOf(1, 2, 3)
|
||||
}
|
||||
|
||||
@@ -381,6 +383,95 @@ class DownloadRepositoryTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun startingADownloadStoresTheProviderArtistImageAndKeepsItUntilTheDownloadGoes() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
runBlocking {
|
||||
db.libraryDao().upsertArtists(listOf(artist(ARTIST_ART, artistImageUrl = "https://img.example/ar1.jpg")))
|
||||
}
|
||||
|
||||
runBlocking { repository.download(1, "s1") }
|
||||
|
||||
assertTrue(requestedArt.contains("https://img.example/ar1.jpg"))
|
||||
val key = artistImageKey(1, "ar1")
|
||||
assertNotNull(artwork.uri(1, key))
|
||||
|
||||
runBlocking { repository.reconcile() }
|
||||
assertNotNull(artwork.uri(1, key))
|
||||
|
||||
runBlocking { repository.remove(1, "s1") }
|
||||
assertNull(artwork.uri(1, key))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun providerArtworkForTheAlbumArtistSurvivesASweepAndGoesWithTheDownload() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
runBlocking {
|
||||
db.libraryDao().upsertArtists(
|
||||
listOf(
|
||||
artist(ARTIST_ART, "https://img.example/track.jpg").copy(id = "ar-track"),
|
||||
artist(ARTIST_ART, "https://img.example/album.jpg").copy(id = "ar-album"),
|
||||
),
|
||||
)
|
||||
db.libraryDao().upsertAlbums(listOf(album(ALBUM_ART).copy(artistId = "ar-album")))
|
||||
db.libraryDao().upsertSongs(listOf(db.libraryDao().songOnce(1, "s1")!!.copy(artistId = "ar-track")))
|
||||
}
|
||||
|
||||
runBlocking { repository.download(1, "s1") }
|
||||
val trackKey = artistImageKey(1, "ar-track")
|
||||
val albumKey = artistImageKey(1, "ar-album")
|
||||
assertNotNull(artwork.uri(1, trackKey))
|
||||
assertNotNull(artwork.uri(1, albumKey))
|
||||
|
||||
runBlocking { repository.reconcile() }
|
||||
assertNotNull(artwork.uri(1, trackKey))
|
||||
assertNotNull(artwork.uri(1, albumKey))
|
||||
|
||||
runBlocking { repository.remove(1, "s1") }
|
||||
assertNull(artwork.uri(1, trackKey))
|
||||
assertNull(artwork.uri(1, albumKey))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aFailedProviderImageFetchCachesTheArtistCoverFallback() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
runBlocking {
|
||||
db.libraryDao().upsertArtists(listOf(artist(ARTIST_ART, artistImageUrl = "https://img.example/ar1.jpg")))
|
||||
}
|
||||
failingArtUrls += "https://img.example/ar1.jpg"
|
||||
|
||||
runBlocking { repository.download(1, "s1") }
|
||||
|
||||
assertNull(artwork.uri(1, artistImageKey(1, "ar1")))
|
||||
assertNotNull(artwork.uri(1, coverArtKey(1, ARTIST_ART, false)))
|
||||
assertNotNull(artwork.uri(1, coverArtKey(1, ARTIST_ART, true)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun completingADownloadMaterializesOnlyItsAlbumForOfflineArtistArt() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
repository.start()
|
||||
runBlocking { repository.download(1, "s1") }
|
||||
val id = engine.requests.single().first
|
||||
|
||||
engine.complete(id, total = 100)
|
||||
writeFile(1, "s1", "audio")
|
||||
runBlocking { repository.reconcile() }
|
||||
|
||||
await { row(1, "s1")?.status == DownloadStatus.Completed }
|
||||
assertEquals(
|
||||
listOf(ALBUM_ART),
|
||||
db.libraryDao().artistOnce(1, "ar1")!!.downloadedCoverArts,
|
||||
)
|
||||
|
||||
runBlocking { repository.remove(1, "s1") }
|
||||
assertEquals(emptyList<String>(), db.libraryDao().artistOnce(1, "ar1")!!.downloadedCoverArts)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun downloadingASongAlsoStoresTheArtworkOfPlaylistsThatContainIt() =
|
||||
runTest {
|
||||
@@ -1282,8 +1373,19 @@ class DownloadRepositoryTest {
|
||||
songCount = 2,
|
||||
)
|
||||
|
||||
private fun artist(coverArt: String?) =
|
||||
Artist(sourceId = 1, id = "ar1", name = "Artist", albumCount = 1, starred = null, coverArt = coverArt, songCount = 2)
|
||||
private fun artist(
|
||||
coverArt: String?,
|
||||
artistImageUrl: String? = null,
|
||||
) = Artist(
|
||||
sourceId = 1,
|
||||
id = "ar1",
|
||||
name = "Artist",
|
||||
albumCount = 1,
|
||||
starred = null,
|
||||
coverArt = coverArt,
|
||||
artistImageUrl = artistImageUrl,
|
||||
songCount = 2,
|
||||
)
|
||||
|
||||
private fun seedLibrary(address: String = "http://localhost:4533/") {
|
||||
runBlocking {
|
||||
|
||||
@@ -11,6 +11,7 @@ import com.subtracks.cancelAndJoinBlocking
|
||||
import com.subtracks.data.db.SubtracksDatabase
|
||||
import com.subtracks.data.download.ArtworkStore
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.data.model.artistImageKey
|
||||
import com.subtracks.data.model.coverArtKey
|
||||
import com.subtracks.data.prefs.StreamQuality
|
||||
import com.subtracks.data.prefs.UserPreferences
|
||||
@@ -33,6 +34,7 @@ import okhttp3.mockwebserver.RecordedRequest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
@@ -283,6 +285,26 @@ class SourceRepositoryTest {
|
||||
assertTrue("expected the stored file, was ${ref.url}", ref.url.startsWith("file:"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun artistImageUsesTheNetworkOnlineAndOnlyCachedFilesOffline() =
|
||||
runBlocking {
|
||||
repository.addSource("nav", "http://a.example/", "u", "p", false)
|
||||
val sourceId = awaitActiveSourceId()
|
||||
val url = "https://img.example/ar1.jpg"
|
||||
val online = withTimeout(TEST_TIMEOUT_MS) { repository.artistImage("ar1", url) }
|
||||
assertEquals(url, online?.url)
|
||||
|
||||
val key = artistImageKey(sourceId, "ar1")
|
||||
artwork.write(sourceId, key, byteArrayOf(1, 2, 3))
|
||||
assertEquals(artwork.uri(sourceId, key), repository.artistImage("ar1", url)?.url)
|
||||
|
||||
repository.setOfflineMode(true)
|
||||
withTimeout(TEST_TIMEOUT_MS) { repository.offline.first { it } }
|
||||
|
||||
assertEquals(artwork.uri(sourceId, key), repository.artistImage("ar1", url)?.url)
|
||||
assertNull(repository.artistImage("ar2", "https://img.example/ar2.jpg"))
|
||||
}
|
||||
|
||||
private suspend fun awaitActiveSourceId(): Long = withTimeout(TEST_TIMEOUT_MS) { repository.activeSourceId().first { it != null }!! }
|
||||
|
||||
private suspend fun awaitCoverArt(coverArt: String): CoverArtRef =
|
||||
|
||||
@@ -74,7 +74,8 @@ class SubsonicXmlTest {
|
||||
"""
|
||||
<subsonic-response status="ok">
|
||||
<artists>
|
||||
<artist id="ar1" name="Radiohead" albumCount="9" starred="2020-01-02T03:04:05.000Z"/>
|
||||
<artist id="ar1" name="Radiohead" albumCount="9" starred="2020-01-02T03:04:05.000Z"
|
||||
artistImageUrl="https://img.example/radiohead.jpg"/>
|
||||
<artist id="ar2" name="Portishead" albumCount="3"/>
|
||||
</artists>
|
||||
</subsonic-response>
|
||||
@@ -88,6 +89,8 @@ class SubsonicXmlTest {
|
||||
assertEquals(7L, artists[0].sourceId)
|
||||
assertEquals(9L, artists[0].albumCount)
|
||||
assertEquals(1577934245L, artists[0].starred)
|
||||
assertEquals("https://img.example/radiohead.jpg", artists[0].artistImageUrl)
|
||||
assertEquals(null, artists[1].artistImageUrl)
|
||||
assertEquals(null, artists[1].starred)
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ import com.subtracks.data.prefs.StreamQuality
|
||||
import com.subtracks.playback.PlaybackState
|
||||
import com.subtracks.playback.QueueItem
|
||||
import com.subtracks.playback.RepeatMode
|
||||
import com.subtracks.ui.components.ArtistArt
|
||||
import com.subtracks.ui.components.DOWNLOADING_INDICATOR_TAG
|
||||
import com.subtracks.ui.library.ALBUM_COVER_TAG
|
||||
import com.subtracks.ui.library.ARTIST_NAME_SCRIM_TAG
|
||||
@@ -613,12 +614,15 @@ class AppScreenshotTest {
|
||||
artists = remember { flowOf(PagingData.from(Fixtures.artists)) },
|
||||
playlists = remember { flowOf(PagingData.from(Fixtures.playlists)) },
|
||||
coverArt = { id, _ -> id?.let { CoverArtRef(it, "test:$it") } },
|
||||
artistCovers = { id ->
|
||||
Fixtures.albums
|
||||
.filter { it.artistId == id }
|
||||
.take(4)
|
||||
.mapNotNull { it.coverArt }
|
||||
.map { CoverArtRef(it, "test:$it") }
|
||||
artistArt = { artist ->
|
||||
ArtistArt(
|
||||
covers =
|
||||
Fixtures.albums
|
||||
.filter { it.artistId == artist.id }
|
||||
.take(4)
|
||||
.mapNotNull { it.coverArt }
|
||||
.map { CoverArtRef(it, "test:$it") },
|
||||
)
|
||||
},
|
||||
onAlbumClick = {},
|
||||
onArtistClick = {},
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
package com.subtracks.ui.components
|
||||
|
||||
import com.subtracks.data.model.Artist
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ArtistArtTest {
|
||||
@Test
|
||||
fun providerImageWinsOverArtistAndAlbumArt() {
|
||||
val calls = mutableListOf<String>()
|
||||
val art =
|
||||
resolveArtistArt(
|
||||
artist = artist(artistImageUrl = "https://img/artist"),
|
||||
coverArt = { id, _ -> id?.let { CoverArtRef(it, "cache:$it") } },
|
||||
artistImage = { id, url ->
|
||||
calls += "image:$id:$url"
|
||||
CoverArtRef("https://img/artist", "cache:image:$id")
|
||||
},
|
||||
offline = false,
|
||||
forIcon = true,
|
||||
)
|
||||
|
||||
assertEquals(CoverArtRef("https://img/artist", "cache:image:ar1"), art.image)
|
||||
assertTrue(art.covers.isEmpty())
|
||||
assertEquals(listOf("image:ar1:https://img/artist"), calls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun artistCoverArtWinsOverMaterializedAlbumArt() {
|
||||
val thumbnails = mutableListOf<Boolean>()
|
||||
val art =
|
||||
resolveArtistArt(
|
||||
artist = artist(coverArt = "own", coverArts = listOf("a", "b")),
|
||||
coverArt = { id, thumbnail ->
|
||||
thumbnails += thumbnail
|
||||
id?.let { CoverArtRef(it, "cache:$it:$thumbnail") }
|
||||
},
|
||||
artistImage = { _, _ -> null },
|
||||
offline = false,
|
||||
forIcon = true,
|
||||
)
|
||||
|
||||
assertEquals(CoverArtRef("own", "cache:own:true"), art.image)
|
||||
assertTrue(art.covers.isEmpty())
|
||||
assertTrue(thumbnails.all { it })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun iconUsesThumbnailsAndLimitsTheGridToFourCovers() {
|
||||
val thumbnails = mutableListOf<Boolean>()
|
||||
val art =
|
||||
resolveArtistArt(
|
||||
artist = artist(coverArts = listOf("a", "b", "c", "d", "e")),
|
||||
coverArt = { id, thumbnail ->
|
||||
thumbnails += thumbnail
|
||||
id?.let { CoverArtRef(it, "cache:$it") }
|
||||
},
|
||||
artistImage = { _, _ -> null },
|
||||
offline = false,
|
||||
forIcon = true,
|
||||
)
|
||||
|
||||
assertEquals(listOf("a", "b", "c", "d"), art.covers.map { it.url })
|
||||
assertEquals(listOf(true, true, true, true), thumbnails)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aSingleHeroCoverLoadsFullSizeWhileMultipleCoversUseThumbnails() {
|
||||
val thumbnails = mutableListOf<Boolean>()
|
||||
val coverArt = { id: String?, thumbnail: Boolean ->
|
||||
thumbnails += thumbnail
|
||||
id?.let { CoverArtRef(it, "cache:$it") }
|
||||
}
|
||||
val artistImage: (String, String?) -> CoverArtRef? = { _, _ -> null }
|
||||
|
||||
resolveArtistArt(artist(coverArts = listOf("a")), coverArt, artistImage, offline = false, forIcon = false)
|
||||
assertEquals(listOf(false), thumbnails)
|
||||
|
||||
thumbnails.clear()
|
||||
resolveArtistArt(artist(coverArts = listOf("a", "b")), coverArt, artistImage, offline = false, forIcon = false)
|
||||
assertEquals(listOf(true, true), thumbnails)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun offlineUsesDownloadedCoversAndNeverOnlineOnlyOnes() {
|
||||
val requested = mutableListOf<String>()
|
||||
val art =
|
||||
resolveArtistArt(
|
||||
artist =
|
||||
artist(
|
||||
coverArt = "online-own",
|
||||
coverArts = listOf("online-a", "online-b"),
|
||||
downloadedCoverArts = listOf("downloaded-a", "downloaded-b"),
|
||||
),
|
||||
coverArt = { id, _ ->
|
||||
requested += id ?: "null"
|
||||
id?.takeIf { it.startsWith("downloaded") }?.let { CoverArtRef(it, "cache:$it") }
|
||||
},
|
||||
artistImage = { _, _ -> null },
|
||||
offline = true,
|
||||
forIcon = true,
|
||||
)
|
||||
|
||||
assertEquals(listOf("downloaded-a", "downloaded-b"), art.covers.map { it.url })
|
||||
assertEquals(setOf("online-own", "downloaded-a", "downloaded-b"), requested.toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theProviderAndArtistImagesCarryTheNextSourceAsAFallback() {
|
||||
val provider =
|
||||
resolveArtistArt(
|
||||
artist = artist(coverArt = "own", artistImageUrl = "https://img/artist", coverArts = listOf("a", "b")),
|
||||
coverArt = { id, _ -> id?.let { CoverArtRef(it, "cache:$it") } },
|
||||
artistImage = { _, _ -> CoverArtRef("https://img/artist", "cache:image") },
|
||||
offline = false,
|
||||
forIcon = true,
|
||||
)
|
||||
|
||||
assertEquals(CoverArtRef("own", "cache:own"), provider.fallback?.image)
|
||||
assertEquals(
|
||||
listOf("a", "b"),
|
||||
provider.fallback
|
||||
?.fallback
|
||||
?.covers
|
||||
?.map { it.url },
|
||||
)
|
||||
|
||||
val own =
|
||||
resolveArtistArt(
|
||||
artist = artist(coverArt = "own", coverArts = listOf("a", "b")),
|
||||
coverArt = { id, _ -> id?.let { CoverArtRef(it, "cache:$it") } },
|
||||
artistImage = { _, _ -> null },
|
||||
offline = false,
|
||||
forIcon = true,
|
||||
)
|
||||
|
||||
assertEquals(listOf("a", "b"), own.fallback?.covers?.map { it.url })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noArtResolvesToAnEmptySelectionInsteadOfAStaleFallback() {
|
||||
val art =
|
||||
resolveArtistArt(
|
||||
artist = artist(coverArt = "online-own", coverArts = listOf("a")),
|
||||
coverArt = { _, _ -> null },
|
||||
artistImage = { _, _ -> null },
|
||||
offline = true,
|
||||
forIcon = true,
|
||||
)
|
||||
|
||||
assertNull(art.image)
|
||||
assertTrue(art.covers.isEmpty())
|
||||
}
|
||||
|
||||
private fun artist(
|
||||
coverArt: String? = null,
|
||||
artistImageUrl: String? = null,
|
||||
coverArts: List<String> = emptyList(),
|
||||
downloadedCoverArts: List<String> = emptyList(),
|
||||
) = Artist(
|
||||
sourceId = 1,
|
||||
id = "ar1",
|
||||
name = "Artist",
|
||||
albumCount = 0,
|
||||
starred = null,
|
||||
coverArt = coverArt,
|
||||
artistImageUrl = artistImageUrl,
|
||||
coverArts = coverArts,
|
||||
downloadedCoverArts = downloadedCoverArts,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user