From 040217cab66c030527254e3d1529a68a9017b5fe Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 27 May 2026 20:37:39 -0400 Subject: [PATCH] fix(android): hydrate missing Home entities so sections actually render MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit v3 §2 + §4.5: HomeRepository's hydrateAlbums / hydrateArtists / hydrateTracks use mapNotNull on the per-entity DAO, so any ID in /api/home/index that the local cache hasn't seen gets silently dropped from the emitted list. Combined with HomeSuccessContent hiding empty sections, the user sees the home view missing entire rows — Rediscover is the worst hit because by definition those are albums you HAVEN'T played recently, least likely to be in cache. User reported on emulator: 'we're missing a lot of rows and formatting from the home view, the rediscover section is completely missing and a number of the shown sections are rendered with a different number of rows than the flutter app has.' Fix: after refreshIndex() pulls the section ID lists, fire-and-forget a hydration pass on the app scope. For each section, find IDs the cache doesn't have, fetch via LibraryRepository's existing per-entity endpoints (refreshAlbumDetail / refreshArtistDetail / refreshTrack — the last already commented 'for the hydration queue'), chunked HYDRATE_CONCURRENCY=4 wide so we don't fire 50 parallel requests. runCatching per-call so one broken ID doesn't fail the batch. This is the slim version of audit #24 MetadataPrefetcher scoped to Home — the full background hydration queue with tile-providers is still pending. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../minstrel/home/data/HomeRepository.kt | 61 ++++++++++++++++++- 1 file changed, 60 insertions(+), 1 deletion(-) 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 5edb5473..c18429e2 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,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) { + 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) {