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
Showing only changes of commit 80b59aecf0 - Show all commits
@@ -6,40 +6,34 @@ import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao
import com.fabledsword.minstrel.cache.db.dao.CachedHomeIndexDao
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
import com.fabledsword.minstrel.cache.db.entities.CachedHomeIndexEntity
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.library.data.LibraryRepository
import com.fabledsword.minstrel.library.data.toDomain
import com.fabledsword.minstrel.metadata.MetadataProvider
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.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import retrofit2.Retrofit
import retrofit2.create
import javax.inject.Inject
import javax.inject.Singleton
private const val HYDRATE_CONCURRENCY = 4
/**
* Cache-first reads for the Home screen. Per-section ID lists live in
* `cached_home_index`; entity rows live in `cached_albums` /
* `cached_artists` / `cached_tracks` and are observed via the
* Library DAOs.
* Cache-first reads for the Home screen. Per-section ID lists live
* in `cached_home_index`; entity rows live in `cached_albums` /
* `cached_artists` / `cached_tracks`.
*
* Sections are exposed as `Flow<List<DomainRef>>`. Each emission joins
* the ID list against the per-entity DAO; rows that haven't been
* hydrated into Room yet are silently dropped from the emitted list
* (Phase 7+ adds a per-tile hydration queue that fills the gaps).
* Sections are exposed as `Flow<List<DomainRef>>`. Each emission
* joins the ID list against the per-entity DAO. IDs that aren't yet
* cached are silently dropped from the emitted list AND fire a
* dedup'd background fetch via [MetadataProvider]. The fetch upserts
* into Room, the Flow re-emits, the tile populates.
*
* `refreshIndex()` pulls `GET /api/home/index` and replaces each
* section in-place (delete-then-insert), so the Flow emissions
* propagate automatically. Errors surface to the caller.
* propagate automatically. Errors surface to the caller. No more
* eager fan-out at refresh time — the on-miss path handles it
* declaratively as each tile materialises.
*/
@Singleton
class HomeRepository @Inject constructor(
@@ -47,8 +41,7 @@ class HomeRepository @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 metadataProvider: MetadataProvider,
retrofit: Retrofit,
) {
private val api: HomeApi = retrofit.create()
@@ -69,18 +62,10 @@ class HomeRepository @Inject constructor(
homeIndexDao.observeBySection(SECTION_LAST_PLAYED_ARTISTS).map { hydrateArtists(it) }
/**
* Pulls /api/home/index and replaces each section in cached_home_index.
* Also fires a fire-and-forget hydration pass for any entity IDs
* the cache doesn't have yet — without this, Rediscover and
* Most-Played tiles silently disappear because [hydrateAlbums] /
* [hydrateTracks] mapNotNull-drop unknown rows.
*
* Hydration runs on the app scope so refreshIndex() returns as
* soon as the index is in place; the Flow re-emits each section
* as missing rows land.
*
* Caller (ViewModel) is responsible for catching network errors
* from the index fetch itself.
* Pulls /api/home/index and replaces each section in
* cached_home_index. The Flow emissions re-fire automatically,
* and any missing entity rows fire on-miss fetches via the
* hydrate helpers below.
*/
suspend fun refreshIndex() {
val wire = api.getHomeIndex()
@@ -89,44 +74,6 @@ class HomeRepository @Inject constructor(
replaceSection(SECTION_REDISCOVER_ARTISTS, "artist", wire.rediscoverArtists)
replaceSection(SECTION_MOST_PLAYED_TRACKS, "track", wire.mostPlayedTracks)
replaceSection(SECTION_LAST_PLAYED_ARTISTS, "artist", wire.lastPlayedArtists)
appScope.launch {
hydrateMissingAlbums((wire.recentlyAddedAlbums + wire.rediscoverAlbums).distinct())
hydrateMissingArtists((wire.rediscoverArtists + wire.lastPlayedArtists).distinct())
hydrateMissingTracks(wire.mostPlayedTracks)
}
}
private suspend fun hydrateMissingAlbums(ids: List<String>) {
val missing = ids.filter { albumDao.getById(it) == null }
missing.chunked(HYDRATE_CONCURRENCY).forEach { batch ->
coroutineScope {
batch.map { id ->
async { runCatching { libraryRepository.refreshAlbumDetail(id) } }
}.awaitAll()
}
}
}
private suspend fun hydrateMissingArtists(ids: List<String>) {
val missing = ids.filter { artistDao.getById(it) == null }
missing.chunked(HYDRATE_CONCURRENCY).forEach { batch ->
coroutineScope {
batch.map { id ->
async { runCatching { libraryRepository.refreshArtistDetail(id) } }
}.awaitAll()
}
}
}
private suspend fun hydrateMissingTracks(ids: List<String>) {
val missing = ids.filter { trackDao.getById(it) == null }
missing.chunked(HYDRATE_CONCURRENCY).forEach { batch ->
coroutineScope {
batch.map { id ->
async { runCatching { libraryRepository.refreshTrack(id) } }
}.awaitAll()
}
}
}
private suspend fun replaceSection(section: String, entityType: String, ids: List<String>) {
@@ -145,13 +92,25 @@ class HomeRepository @Inject constructor(
}
private suspend fun hydrateAlbums(rows: List<CachedHomeIndexEntity>): List<AlbumRef> =
rows.mapNotNull { albumDao.getById(it.entityId)?.toDomain() }
rows.mapNotNull { row ->
val cached = albumDao.getById(row.entityId)
if (cached == null) metadataProvider.refreshAlbum(row.entityId)
cached?.toDomain()
}
private suspend fun hydrateArtists(rows: List<CachedHomeIndexEntity>): List<ArtistRef> =
rows.mapNotNull { artistDao.getById(it.entityId)?.toDomain() }
rows.mapNotNull { row ->
val cached = artistDao.getById(row.entityId)
if (cached == null) metadataProvider.refreshArtist(row.entityId)
cached?.toDomain()
}
private suspend fun hydrateTracks(rows: List<CachedHomeIndexEntity>): List<TrackRef> =
rows.mapNotNull { trackDao.getById(it.entityId)?.toDomain() }
rows.mapNotNull { row ->
val cached = trackDao.getById(row.entityId)
if (cached == null) metadataProvider.refreshTrack(row.entityId)
cached?.toDomain()
}
companion object {
const val SECTION_RECENTLY_ADDED_ALBUMS = "recently_added_albums"