Hotfix v2026.05.15.1 — allow discovery-mix variants in playlists CHECK constraints #49

Merged
bvandeusen merged 262 commits from dev into main 2026-05-16 00:32:26 -04:00
5 changed files with 95 additions and 0 deletions
Showing only changes of commit 4dec61ba55 - Show all commits
@@ -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
@@ -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>)
@@ -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>)
@@ -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) }
}
}
}