release v2026.05.11.1: Flutter caching, navigation, and player polish #39
@@ -11,6 +11,7 @@ import com.fabledsword.minstrel.cache.sync.SyncController
|
||||
import com.fabledsword.minstrel.di.ApplicationScope
|
||||
import com.fabledsword.minstrel.events.EventsStream
|
||||
import com.fabledsword.minstrel.events.LiveEventsDispatcher
|
||||
import com.fabledsword.minstrel.metadata.FreshnessSweeper
|
||||
import com.fabledsword.minstrel.player.CoverPrefetcher
|
||||
import com.fabledsword.minstrel.player.PlayEventsReporter
|
||||
import com.fabledsword.minstrel.player.ResumeController
|
||||
@@ -88,6 +89,15 @@ class MinstrelApplication :
|
||||
*/
|
||||
@Suppress("unused") @Inject lateinit var versionCheckController: VersionCheckController
|
||||
|
||||
/**
|
||||
* Same construct-the-singleton trick — FreshnessSweeper's init
|
||||
* block starts a 30-min sweep loop that re-fetches cached
|
||||
* album/artist/track rows whose `fetchedAt` is older than 24h
|
||||
* via MetadataProvider, so the cache stays warm without
|
||||
* user-driven pull-to-refresh.
|
||||
*/
|
||||
@Suppress("unused") @Inject lateinit var freshnessSweeper: FreshnessSweeper
|
||||
|
||||
/**
|
||||
* Same construct-the-singleton trick — EventsStream's init block
|
||||
* subscribes to AuthStore.sessionCookie and opens / closes the
|
||||
|
||||
+3
@@ -24,6 +24,9 @@ interface CachedAlbumDao {
|
||||
@Query("SELECT * FROM cached_albums WHERE id = :id")
|
||||
fun observeById(id: String): Flow<CachedAlbumEntity?>
|
||||
|
||||
@Query("SELECT id FROM cached_albums WHERE fetchedAt < :before LIMIT :limit")
|
||||
suspend fun idsStaleBefore(before: Long, limit: Int): List<String>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsertAll(rows: List<CachedAlbumEntity>)
|
||||
|
||||
|
||||
+3
@@ -18,6 +18,9 @@ interface CachedArtistDao {
|
||||
@Query("SELECT * FROM cached_artists WHERE id = :id")
|
||||
fun observeById(id: String): Flow<CachedArtistEntity?>
|
||||
|
||||
@Query("SELECT id FROM cached_artists WHERE fetchedAt < :before LIMIT :limit")
|
||||
suspend fun idsStaleBefore(before: Long, limit: Int): List<String>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsertAll(rows: List<CachedArtistEntity>)
|
||||
|
||||
|
||||
+3
@@ -24,6 +24,9 @@ interface CachedTrackDao {
|
||||
@Query("SELECT * FROM cached_tracks WHERE id IN (:ids)")
|
||||
suspend fun getByIds(ids: List<String>): List<CachedTrackEntity>
|
||||
|
||||
@Query("SELECT id FROM cached_tracks WHERE fetchedAt < :before LIMIT :limit")
|
||||
suspend fun idsStaleBefore(before: Long, limit: Int): List<String>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsertAll(rows: List<CachedTrackEntity>)
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.fabledsword.minstrel.metadata
|
||||
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
|
||||
import com.fabledsword.minstrel.di.ApplicationScope
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
private const val SWEEP_INTERVAL_MS = 30L * 60 * 1000 // 30 minutes
|
||||
private const val STALENESS_MS = 24L * 60 * 60 * 1000 // 24 hours
|
||||
private const val SWEEP_BATCH_SIZE = 100
|
||||
private const val SWEEP_CONCURRENCY = 4
|
||||
|
||||
/**
|
||||
* Background freshness pass over [MetadataProvider]. Wakes every
|
||||
* [SWEEP_INTERVAL_MS] and re-fetches up to [SWEEP_BATCH_SIZE]
|
||||
* cached_album / cached_artist / cached_track rows whose
|
||||
* `fetchedAt` is older than [STALENESS_MS]. Reuses
|
||||
* [MetadataProvider.refreshAlbum] / refreshArtist / refreshTrack —
|
||||
* dedup applies, so a sweep that overlaps with a user-driven
|
||||
* on-miss fetch is a no-op on the duplicated ID.
|
||||
*
|
||||
* Bounded by [SWEEP_CONCURRENCY] in-flight requests so we don't
|
||||
* saturate the connection pool. Failures are tolerated per-row —
|
||||
* one bad ID doesn't stop the batch.
|
||||
*
|
||||
* Constructed at app launch via the construct-the-singleton trick
|
||||
* in [com.fabledsword.minstrel.MinstrelApplication].
|
||||
*/
|
||||
@Singleton
|
||||
class FreshnessSweeper @Inject constructor(
|
||||
private val albumDao: CachedAlbumDao,
|
||||
private val artistDao: CachedArtistDao,
|
||||
private val trackDao: CachedTrackDao,
|
||||
private val metadataProvider: MetadataProvider,
|
||||
@ApplicationScope private val appScope: CoroutineScope,
|
||||
) {
|
||||
init {
|
||||
appScope.launch {
|
||||
while (true) {
|
||||
sweep()
|
||||
delay(SWEEP_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun sweep() {
|
||||
val cutoff = System.currentTimeMillis() - STALENESS_MS
|
||||
val sem = Semaphore(SWEEP_CONCURRENCY)
|
||||
sweepBucket(albumDao.idsStaleBefore(cutoff, SWEEP_BATCH_SIZE), sem) { id ->
|
||||
metadataProvider.refreshAlbum(id).join()
|
||||
}
|
||||
sweepBucket(artistDao.idsStaleBefore(cutoff, SWEEP_BATCH_SIZE), sem) { id ->
|
||||
metadataProvider.refreshArtist(id).join()
|
||||
}
|
||||
sweepBucket(trackDao.idsStaleBefore(cutoff, SWEEP_BATCH_SIZE), sem) { id ->
|
||||
metadataProvider.refreshTrack(id).join()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun sweepBucket(
|
||||
ids: List<String>,
|
||||
sem: Semaphore,
|
||||
refresh: suspend (String) -> Unit,
|
||||
) {
|
||||
ids.forEach { id ->
|
||||
sem.withPermit { refresh(id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user