feat: M3 weighted shuffle v1 — real /api/radio with scoring #21

Merged
bvandeusen merged 262 commits from dev into main 2026-04-27 11:00:29 -04:00
4 changed files with 121 additions and 0 deletions
Showing only changes of commit 18b082def0 - Show all commits
@@ -21,6 +21,9 @@ interface CachedAlbumDao {
@Query("SELECT * FROM cached_albums WHERE id = :id")
suspend fun getById(id: String): CachedAlbumEntity?
@Query("SELECT * FROM cached_albums WHERE id = :id")
fun observeById(id: String): Flow<CachedAlbumEntity?>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertAll(rows: List<CachedAlbumEntity>)
@@ -15,6 +15,9 @@ interface CachedArtistDao {
@Query("SELECT * FROM cached_artists WHERE id = :id")
suspend fun getById(id: String): CachedArtistEntity?
@Query("SELECT * FROM cached_artists WHERE id = :id")
fun observeById(id: String): Flow<CachedArtistEntity?>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertAll(rows: List<CachedArtistEntity>)
@@ -18,6 +18,9 @@ interface CachedTrackDao {
@Query("SELECT * FROM cached_tracks WHERE id = :id")
suspend fun getById(id: String): CachedTrackEntity?
@Query("SELECT * FROM cached_tracks WHERE id = :id")
fun observeById(id: String): Flow<CachedTrackEntity?>
@Query("SELECT * FROM cached_tracks WHERE id IN (:ids)")
suspend fun getByIds(ids: List<String>): List<CachedTrackEntity>
@@ -0,0 +1,112 @@
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 com.fabledsword.minstrel.library.data.LibraryRepository
import com.fabledsword.minstrel.library.data.toDomain
import com.fabledsword.minstrel.models.AlbumRef
import com.fabledsword.minstrel.models.ArtistRef
import com.fabledsword.minstrel.models.TrackRef
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton
/**
* Cache-first metadata reads with eager on-miss live-fetch.
*
* Mirrors Flutter's tile-provider pattern (`albumTileProvider(id)` /
* `artistTileProvider(id)` / `trackTileProvider(id)`): subscribers
* observe a per-ID Flow that emits the Room row immediately if
* present, and otherwise triggers a single background fetch that
* upserts into Room — the flow then re-emits the populated row.
*
* Fetch dedup is per-ID via a ConcurrentHashMap of in-flight Jobs;
* five tiles asking for the same missing album = one network call.
* Fetch errors are swallowed (the row stays null and the tile keeps
* rendering its skeleton); the [FreshnessSweeper] retries later.
*
* Fire-and-forget: subscribers see Flow<X?> where null means "not
* cached yet" — they render skeletons in that case and re-render
* automatically when the fetch lands.
*/
@Singleton
class MetadataProvider @Inject constructor(
private val albumDao: CachedAlbumDao,
private val artistDao: CachedArtistDao,
private val trackDao: CachedTrackDao,
private val libraryRepository: LibraryRepository,
@ApplicationScope private val appScope: CoroutineScope,
) {
private val inFlightAlbums = ConcurrentHashMap<String, Job>()
private val inFlightArtists = ConcurrentHashMap<String, Job>()
private val inFlightTracks = ConcurrentHashMap<String, Job>()
/**
* Live AlbumRef? for [id]. Fires a background fetch the first
* time the cache emits null. Safe to subscribe concurrently from
* multiple tiles — fetch is deduplicated.
*/
fun observeAlbum(id: String): Flow<AlbumRef?> =
albumDao.observeById(id)
.map { it?.toDomain() }
.onEach { row -> if (row == null) fetchAlbum(id) }
fun observeArtist(id: String): Flow<ArtistRef?> =
artistDao.observeById(id)
.map { it?.toDomain() }
.onEach { row -> if (row == null) fetchArtist(id) }
fun observeTrack(id: String): Flow<TrackRef?> =
trackDao.observeById(id)
.map { it?.toDomain() }
.onEach { row -> if (row == null) fetchTrack(id) }
/**
* Force-refresh [id] from the server even if the cache has it.
* Used by [FreshnessSweeper] to walk stale rows. Same dedup as
* the on-miss path — repeated calls during an in-flight fetch
* are no-ops.
*/
fun refreshAlbum(id: String): Job = fetchAlbum(id)
fun refreshArtist(id: String): Job = fetchArtist(id)
fun refreshTrack(id: String): Job = fetchTrack(id)
private fun fetchAlbum(id: String): Job = launchDeduped(inFlightAlbums, id) {
libraryRepository.refreshAlbumDetail(id)
}
private fun fetchArtist(id: String): Job = launchDeduped(inFlightArtists, id) {
libraryRepository.refreshArtistDetail(id)
}
private fun fetchTrack(id: String): Job = launchDeduped(inFlightTracks, id) {
libraryRepository.refreshTrack(id)
}
private fun launchDeduped(
inFlight: ConcurrentHashMap<String, Job>,
id: String,
block: suspend () -> Unit,
): Job = inFlight.computeIfAbsent(id) {
appScope.launch {
try {
block()
} catch (
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
) {
// Soft-fail. Row stays null; tile stays skeletal until
// FreshnessSweeper retries or the user pulls to refresh.
} finally {
inFlight.remove(id)
}
}
}
}