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 040217cab6 - Show all commits
@@ -6,17 +6,26 @@ 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.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` /
@@ -38,6 +47,8 @@ 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,
retrofit: Retrofit,
) {
private val api: HomeApi = retrofit.create()
@@ -59,7 +70,17 @@ class HomeRepository @Inject constructor(
/**
* Pulls /api/home/index and replaces each section in cached_home_index.
* Caller (ViewModel) is responsible for catching network errors.
* 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.
*/
suspend fun refreshIndex() {
val wire = api.getHomeIndex()
@@ -68,6 +89,44 @@ 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>) {