refactor(android): HomeRepository hydrates via MetadataProvider (audit v3 #24, slice 3/3)

Replaces the eager fan-out hydration from commit 040217ca (the
band-aid that fetched every missing ID in a 4-wide chunked loop
on refreshIndex) with the cleaner per-tile on-miss pattern:

* hydrateAlbums / hydrateArtists / hydrateTracks now call
  metadataProvider.refreshAlbum/Artist/Track(id) for any ID the
  cache doesn't have yet — same dedup as elsewhere, so multiple
  Home re-collections / SSE index updates / Library cross-traffic
  all share in-flight fetches.

* refreshIndex no longer launches a background hydration pass —
  the Flow-side on-miss handles it declaratively as each tile
  materialises.

* Removed @ApplicationScope, LibraryRepository, async/awaitAll,
  coroutineScope, launch, HYDRATE_CONCURRENCY constant — all
  dead now that the eager loop is gone.

Net: same user-visible behaviour (Rediscover populates) but the
plumbing is more honest, dedup'd across surfaces, and the
FreshnessSweeper keeps these rows fresh going forward.

Closes audit v3 #24.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-27 21:11:25 -04:00
parent 4dec61ba55
commit 80b59aecf0
@@ -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<List<DomainRef>>`. 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<List<DomainRef>>`. 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<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>) {
@@ -145,13 +92,25 @@ class HomeRepository @Inject constructor(
}
private suspend fun hydrateAlbums(rows: List<CachedHomeIndexEntity>): List<AlbumRef> =
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<CachedHomeIndexEntity>): List<ArtistRef> =
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<CachedHomeIndexEntity>): List<TrackRef> =
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"