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:
@@ -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>) {
|
||||||
|
|||||||
Reference in New Issue
Block a user