diff --git a/android/app/src/main/java/com/fabledsword/minstrel/home/data/HomeRepository.kt b/android/app/src/main/java/com/fabledsword/minstrel/home/data/HomeRepository.kt index c18429e2..fb4a2279 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/home/data/HomeRepository.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/home/data/HomeRepository.kt @@ -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>`. 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>`. 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) { - 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) { - 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) { - 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) { @@ -145,13 +92,25 @@ class HomeRepository @Inject constructor( } private suspend fun hydrateAlbums(rows: List): List = - 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): List = - 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): List = - 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"