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.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.metadata.MetadataProvider
|
||||||
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
|
||||||
* `cached_home_index`; entity rows live in `cached_albums` /
|
* in `cached_home_index`; entity rows live in `cached_albums` /
|
||||||
* `cached_artists` / `cached_tracks` and are observed via the
|
* `cached_artists` / `cached_tracks`.
|
||||||
* Library DAOs.
|
|
||||||
*
|
*
|
||||||
* Sections are exposed as `Flow<List<DomainRef>>`. Each emission joins
|
* Sections are exposed as `Flow<List<DomainRef>>`. Each emission
|
||||||
* the ID list against the per-entity DAO; rows that haven't been
|
* joins the ID list against the per-entity DAO. IDs that aren't yet
|
||||||
* hydrated into Room yet are silently dropped from the emitted list
|
* cached are silently dropped from the emitted list AND fire a
|
||||||
* (Phase 7+ adds a per-tile hydration queue that fills the gaps).
|
* 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
|
* `refreshIndex()` pulls `GET /api/home/index` and replaces each
|
||||||
* section in-place (delete-then-insert), so the Flow emissions
|
* 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
|
@Singleton
|
||||||
class HomeRepository @Inject constructor(
|
class HomeRepository @Inject constructor(
|
||||||
@@ -47,8 +41,7 @@ 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,
|
private val metadataProvider: MetadataProvider,
|
||||||
@ApplicationScope private val appScope: CoroutineScope,
|
|
||||||
retrofit: Retrofit,
|
retrofit: Retrofit,
|
||||||
) {
|
) {
|
||||||
private val api: HomeApi = retrofit.create()
|
private val api: HomeApi = retrofit.create()
|
||||||
@@ -69,18 +62,10 @@ class HomeRepository @Inject constructor(
|
|||||||
homeIndexDao.observeBySection(SECTION_LAST_PLAYED_ARTISTS).map { hydrateArtists(it) }
|
homeIndexDao.observeBySection(SECTION_LAST_PLAYED_ARTISTS).map { hydrateArtists(it) }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pulls /api/home/index and replaces each section in cached_home_index.
|
* Pulls /api/home/index and replaces each section in
|
||||||
* Also fires a fire-and-forget hydration pass for any entity IDs
|
* cached_home_index. The Flow emissions re-fire automatically,
|
||||||
* the cache doesn't have yet — without this, Rediscover and
|
* and any missing entity rows fire on-miss fetches via the
|
||||||
* Most-Played tiles silently disappear because [hydrateAlbums] /
|
* hydrate helpers below.
|
||||||
* [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()
|
||||||
@@ -89,44 +74,6 @@ 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>) {
|
||||||
@@ -145,13 +92,25 @@ class HomeRepository @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun hydrateAlbums(rows: List<CachedHomeIndexEntity>): List<AlbumRef> =
|
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> =
|
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> =
|
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 {
|
companion object {
|
||||||
const val SECTION_RECENTLY_ADDED_ALBUMS = "recently_added_albums"
|
const val SECTION_RECENTLY_ADDED_ALBUMS = "recently_added_albums"
|
||||||
|
|||||||
Reference in New Issue
Block a user