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