fix(android): hydrate missing Home entities so sections actually render

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-27 20:37:39 -04:00
parent e8a7e48bd5
commit 040217cab6
@@ -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.CachedHomeIndexDao
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
import com.fabledsword.minstrel.cache.db.entities.CachedHomeIndexEntity 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.library.data.toDomain
import com.fabledsword.minstrel.models.AlbumRef import com.fabledsword.minstrel.models.AlbumRef
import com.fabledsword.minstrel.models.ArtistRef import com.fabledsword.minstrel.models.ArtistRef
import com.fabledsword.minstrel.models.TrackRef 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.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.create import retrofit2.create
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
private const val HYDRATE_CONCURRENCY = 4
/** /**
* Cache-first reads for the Home screen. Per-section ID lists live in * Cache-first reads for the Home screen. Per-section ID lists live in
* `cached_home_index`; entity rows live in `cached_albums` / * `cached_home_index`; entity rows live in `cached_albums` /
@@ -38,6 +47,8 @@ class HomeRepository @Inject constructor(
private val albumDao: CachedAlbumDao, private val albumDao: CachedAlbumDao,
private val artistDao: CachedArtistDao, private val artistDao: CachedArtistDao,
private val trackDao: CachedTrackDao, private val trackDao: CachedTrackDao,
private val libraryRepository: LibraryRepository,
@ApplicationScope private val appScope: CoroutineScope,
retrofit: Retrofit, retrofit: Retrofit,
) { ) {
private val api: HomeApi = retrofit.create() 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. * 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() { suspend fun refreshIndex() {
val wire = api.getHomeIndex() val wire = api.getHomeIndex()
@@ -68,6 +89,44 @@ class HomeRepository @Inject constructor(
replaceSection(SECTION_REDISCOVER_ARTISTS, "artist", wire.rediscoverArtists) replaceSection(SECTION_REDISCOVER_ARTISTS, "artist", wire.rediscoverArtists)
replaceSection(SECTION_MOST_PLAYED_TRACKS, "track", wire.mostPlayedTracks) replaceSection(SECTION_MOST_PLAYED_TRACKS, "track", wire.mostPlayedTracks)
replaceSection(SECTION_LAST_PLAYED_ARTISTS, "artist", wire.lastPlayedArtists) 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>) { private suspend fun replaceSection(section: String, entityType: String, ids: List<String>) {