bound artwork seeds and size the now-playing prefetch

This commit is contained in:
austinried
2026-10-04 00:29:58 +00:00
parent ca87a38276
commit a97a5e0687
10 changed files with 255 additions and 18 deletions
@@ -12,4 +12,16 @@ interface ArtworkSeedDao {
@Upsert
suspend fun upsert(seed: ArtworkSeed)
@Query("UPDATE artwork_seeds SET lastUsed = :lastUsed WHERE cacheKey = :cacheKey")
suspend fun touch(
cacheKey: String,
lastUsed: Long,
)
@Query(
"DELETE FROM artwork_seeds WHERE cacheKey NOT IN " +
"(SELECT cacheKey FROM artwork_seeds ORDER BY lastUsed DESC LIMIT :keep)",
)
suspend fun prune(keep: Int)
}
@@ -112,6 +112,21 @@ val MIGRATION_11_12 =
}
}
val MIGRATION_12_13 =
object : Migration(12, 13) {
override suspend fun migrate(connection: SQLiteConnection) {
connection.execSQL("DROP TABLE IF EXISTS `artwork_seeds`")
connection.execSQL(
"CREATE TABLE IF NOT EXISTS `artwork_seeds` (`cacheKey` TEXT NOT NULL, " +
"`primary` INTEGER NOT NULL, `secondary` INTEGER, `nameBusy` INTEGER, " +
"`sourceId` INTEGER NOT NULL, `lastUsed` INTEGER NOT NULL DEFAULT 0, " +
"PRIMARY KEY(`cacheKey`), " +
"FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
)
connection.execSQL("CREATE INDEX IF NOT EXISTS `index_artwork_seeds_sourceId` ON `artwork_seeds` (`sourceId`)")
}
}
val MIGRATIONS: Array<Migration> =
arrayOf(
MIGRATION_1_2,
@@ -125,4 +140,5 @@ val MIGRATIONS: Array<Migration> =
MIGRATION_9_10,
MIGRATION_10_11,
MIGRATION_11_12,
MIGRATION_12_13,
)
@@ -32,7 +32,7 @@ import com.subtracks.data.model.SubsonicSource
ShuffleOrder::class,
ArtworkSeed::class,
],
version = 12,
version = 13,
exportSchema = true,
)
@ColumnTypeConverters(QueueKindConverter::class)
@@ -237,12 +237,25 @@ data class ShuffleOrder(
val flatPosition: Long,
)
@Entity(tableName = "artwork_seeds")
@Entity(
tableName = "artwork_seeds",
foreignKeys = [
ForeignKey(
entity = Source::class,
parentColumns = ["id"],
childColumns = ["sourceId"],
onDelete = ForeignKey.CASCADE,
),
],
indices = [Index("sourceId")],
)
data class ArtworkSeed(
@PrimaryKey val cacheKey: String,
val primary: Int,
val secondary: Int?,
val nameBusy: Boolean? = null,
val sourceId: Long = 0,
@ColumnInfo(defaultValue = "0") val lastUsed: Long = 0,
)
class QueueKindConverter {
@@ -3,6 +3,8 @@ package com.subtracks.data.repo
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.model.ArtworkSeed
private const val MAX_STORED_SEEDS = 1024
interface ArtworkSeedStore {
suspend fun seed(cacheKey: String): ArtworkSeed?
@@ -11,10 +13,19 @@ interface ArtworkSeedStore {
class ArtworkSeedRepository(
private val db: SubtracksDatabase,
private val maxStoredSeeds: Int = MAX_STORED_SEEDS,
) : ArtworkSeedStore {
override suspend fun seed(cacheKey: String): ArtworkSeed? = db.artworkSeedDao().seed(cacheKey)
override suspend fun seed(cacheKey: String): ArtworkSeed? {
val dao = db.artworkSeedDao()
val row = dao.seed(cacheKey) ?: return null
dao.touch(cacheKey, System.currentTimeMillis())
return row
}
override suspend fun save(seed: ArtworkSeed) {
db.artworkSeedDao().upsert(seed)
val sourceId = seed.cacheKey.substringBefore(':').toLongOrNull() ?: return
val dao = db.artworkSeedDao()
dao.upsert(seed.copy(sourceId = sourceId, lastUsed = System.currentTimeMillis()))
dao.prune(maxStoredSeeds)
}
}
@@ -580,11 +580,10 @@ class PlaybackController(
thumbnail: Boolean = false,
): CoverArtRef? = sourceRepository.coverArt(item?.coverArtId, thumbnail)
suspend fun upcomingCoverArt(thumbnail: Boolean = false): CoverArtRef? {
suspend fun upcomingItem(): QueueItem? {
val snap = snapshot ?: return null
val position = currentPosition() ?: return null
val next = queueRepository.itemAt(snap, position + 1) ?: return null
return sourceRepository.coverArt(next.toQueueItem().coverArtId, thumbnail)
return queueRepository.itemAt(snap, position + 1)?.toQueueItem()
}
private fun play(
@@ -98,8 +98,10 @@ fun NowPlayingRoute(
}
LaunchedEffect(state.item?.id, state.hasNext) {
if (!state.hasNext) return@LaunchedEffect
controller.upcomingCoverArt(thumbnail = true)?.let { ArtworkSeedCache.prefetch(context, it) }
controller.upcomingCoverArt()?.let { prefetchImage(context, it) }
controller.upcomingItem()?.let { next ->
controller.coverArt(next, thumbnail = true)?.let { ArtworkSeedCache.prefetch(context, it) }
controller.coverArt(next)?.let { prefetchImage(context, it) }
}
}
NowPlayingScreen(
state = state,
@@ -120,18 +122,26 @@ fun NowPlayingRoute(
)
}
private fun prefetchImage(
internal fun prefetchImage(
context: Context,
ref: CoverArtRef,
) {
SingletonImageLoader.get(context).enqueue(
ImageRequest
.Builder(context)
.data(ref.url)
.memoryCacheKey(ref.cacheKey)
.diskCacheKey(ref.cacheKey)
.build(),
)
SingletonImageLoader.get(context).enqueue(prefetchImageRequest(context, ref))
}
internal fun prefetchImageRequest(
context: Context,
ref: CoverArtRef,
): ImageRequest {
val metrics = context.resources.displayMetrics
val maxDimension = maxOf(metrics.widthPixels, metrics.heightPixels)
return ImageRequest
.Builder(context)
.data(ref.url)
.memoryCacheKey(ref.cacheKey)
.diskCacheKey(ref.cacheKey)
.size(maxDimension)
.build()
}
@OptIn(ExperimentalMaterial3Api::class)
@@ -0,0 +1,103 @@
package com.subtracks.data.db
import android.content.Context
import androidx.room3.Room
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.model.ArtworkSeed
import com.subtracks.data.model.Source
import com.subtracks.data.repo.ArtworkSeedRepository
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class ArtworkSeedDaoTest {
private lateinit var db: SubtracksDatabase
@Before
fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>()
db =
Room
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
.setDriver(BundledSQLiteDriver())
.build()
}
@After
fun tearDown() {
db.close()
}
@Test
fun deletingASourceCascadesToItsSeeds() =
runTest {
val sourceId = source()
val dao = db.artworkSeedDao()
dao.upsert(ArtworkSeed(cacheKey = "$sourceId:cover:false", sourceId = sourceId, primary = 1, secondary = null))
db.sourcesDao().deleteSource(sourceId)
assertNull(dao.seed("$sourceId:cover:false"))
}
@Test
fun pruningKeepsTheMostRecentlyUsedSeeds() =
runTest {
val sourceId = source()
val dao = db.artworkSeedDao()
(1..5).forEach { index ->
dao.upsert(
ArtworkSeed(
cacheKey = "$sourceId:cover$index:false",
sourceId = sourceId,
primary = index,
secondary = null,
lastUsed = index.toLong(),
),
)
}
dao.prune(keep = 2)
val remaining = (1..5).mapNotNull { dao.seed("$sourceId:cover$it:false")?.cacheKey }.toSet()
assertEquals(setOf("$sourceId:cover5:false", "$sourceId:cover4:false"), remaining)
}
@Test
fun savingDerivesTheSourceFromTheCacheKey() =
runTest {
val sourceId = source()
val repository = ArtworkSeedRepository(db)
repository.save("$sourceId:cover:true", 10 to 20)
val row = db.artworkSeedDao().seed("$sourceId:cover:true")
assertEquals(sourceId, row?.sourceId)
assertEquals(10, row?.primary)
assertEquals(20, row?.secondary)
}
@Test
fun savingBeyondTheCapPrunesTheOldestSeeds() =
runTest {
val sourceId = source()
val repository = ArtworkSeedRepository(db, maxStoredSeeds = 2)
repository.save("$sourceId:cover1:false", 1 to null)
repository.save("$sourceId:cover2:false", 2 to null)
repository.save("$sourceId:cover3:false", 3 to null)
val stored = (1..3).count { db.artworkSeedDao().seed("$sourceId:cover$it:false") != null }
assertEquals(2, stored)
}
private suspend fun source(): Long =
db.sourcesDao().insertSource(Source(name = "Navidrome", address = "http://localhost", isActive = true, createdAt = 0))
}
@@ -6,6 +6,7 @@ import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.model.Album
import com.subtracks.data.model.QueueEntry
import com.subtracks.data.model.QueueKind
import com.subtracks.data.model.Song
@@ -74,6 +75,39 @@ class PlaybackControllerTest {
assertEquals(listOf("prepare", "play"), handle.operations.takeLast(2))
}
@Test
fun upcomingItemIsTheNextQueueItemWithItsCoverArt() {
seedAlbum(3, sourceId = 1)
runBlocking {
db.libraryDao().upsertAlbums(
listOf(
Album(
sourceId = 1,
id = "al1",
artistId = "ar1",
name = "Album",
albumArtist = "Artist",
created = 0,
coverArt = "cover-al1",
genre = null,
year = null,
starred = null,
songCount = 3,
frequentRank = null,
recentRank = null,
),
),
)
}
controller.playAlbum(1, "al1", 0)
await {
controller.state.value.item
?.id == "s1"
}
assertEquals("cover-al1", runBlocking { controller.upcomingItem()?.coverArtId })
}
@Test
fun playingTheSongsListQueuesTheWholeLibrary() {
seedAlbum(60, sourceId = 1)
@@ -0,0 +1,39 @@
package com.subtracks.ui.playback
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import coil3.SingletonImageLoader
import coil3.annotation.DelicateCoilApi
import coil3.size.Dimension
import com.subtracks.data.model.CoverArtRef
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
@OptIn(DelicateCoilApi::class)
@RunWith(AndroidJUnit4::class)
class NowPlayingArtworkTest {
@After
fun resetImageLoader() {
SingletonImageLoader.reset()
}
@Test
fun prefetchingTheNextOriginalIsBoundedToTheScreen() =
runBlocking {
val context = ApplicationProvider.getApplicationContext<Context>()
val ref = CoverArtRef(url = "http://example.test/art", cacheKey = "1:art:false")
val request = prefetchImageRequest(context, ref)
val resolved = request.sizeResolver.size()
val width = (resolved.width as Dimension.Pixels).px
val metrics = context.resources.displayMetrics
assertEquals(maxOf(metrics.widthPixels, metrics.heightPixels), width)
assertEquals(ref.cacheKey, request.memoryCacheKey)
assertEquals(ref.cacheKey, request.diskCacheKey)
}
}