diff --git a/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt b/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt index 363bd7ec..b3ebcc51 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt @@ -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 diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedAlbumDao.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedAlbumDao.kt index 7dd89e97..267b25d8 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedAlbumDao.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedAlbumDao.kt @@ -24,6 +24,9 @@ interface CachedAlbumDao { @Query("SELECT * FROM cached_albums WHERE id = :id") fun observeById(id: String): Flow + @Query("SELECT id FROM cached_albums WHERE fetchedAt < :before LIMIT :limit") + suspend fun idsStaleBefore(before: Long, limit: Int): List + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertAll(rows: List) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedArtistDao.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedArtistDao.kt index 31183056..450053b1 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedArtistDao.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedArtistDao.kt @@ -18,6 +18,9 @@ interface CachedArtistDao { @Query("SELECT * FROM cached_artists WHERE id = :id") fun observeById(id: String): Flow + @Query("SELECT id FROM cached_artists WHERE fetchedAt < :before LIMIT :limit") + suspend fun idsStaleBefore(before: Long, limit: Int): List + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertAll(rows: List) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedTrackDao.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedTrackDao.kt index 682f1f5b..b07095a6 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedTrackDao.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedTrackDao.kt @@ -24,6 +24,9 @@ interface CachedTrackDao { @Query("SELECT * FROM cached_tracks WHERE id IN (:ids)") suspend fun getByIds(ids: List): List + @Query("SELECT id FROM cached_tracks WHERE fetchedAt < :before LIMIT :limit") + suspend fun idsStaleBefore(before: Long, limit: Int): List + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertAll(rows: List) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/metadata/FreshnessSweeper.kt b/android/app/src/main/java/com/fabledsword/minstrel/metadata/FreshnessSweeper.kt new file mode 100644 index 00000000..96550e1a --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/metadata/FreshnessSweeper.kt @@ -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, + sem: Semaphore, + refresh: suspend (String) -> Unit, + ) { + ids.forEach { id -> + sem.withPermit { refresh(id) } + } + } +}