feat(android): MetadataProvider — cache-first reads + on-miss live fetch (audit v3 #24, slice 1/3)
Foundation for the Flutter tile-provider pattern.
Adds CachedAlbumDao / CachedArtistDao / CachedTrackDao observeById(id)
Flow methods (query-only change; no schema migration).
MetadataProvider exposes observeAlbum(id) / observeArtist(id) /
observeTrack(id) as Flow<X?> that:
- emits the Room row immediately if present,
- fires a single background fetch when the cache emits null,
- re-emits the populated row once the fetch upserts.
Fetch dedup is per-ID via ConcurrentHashMap<String, Job> — five
tiles asking for the same missing album = one network call. Fetch
failures are swallowed (row stays null, tile keeps skeleton);
FreshnessSweeper (next slice) retries.
Also exposes refreshAlbum/Artist/Track(id) for the upcoming sweeper
to force-refresh stale-but-cached rows; same dedup applies.
Next slices:
2/3 - FreshnessSweeper periodic walk + EventsStream reconnect hook.
3/3 - Wire MetadataProvider into Home / Library / Search tiles
and remove the band-aid Home hydration from commit 040217ca.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+3
@@ -21,6 +21,9 @@ interface CachedAlbumDao {
|
||||
@Query("SELECT * FROM cached_albums WHERE id = :id")
|
||||
suspend fun getById(id: String): CachedAlbumEntity?
|
||||
|
||||
@Query("SELECT * FROM cached_albums WHERE id = :id")
|
||||
fun observeById(id: String): Flow<CachedAlbumEntity?>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsertAll(rows: List<CachedAlbumEntity>)
|
||||
|
||||
|
||||
+3
@@ -15,6 +15,9 @@ interface CachedArtistDao {
|
||||
@Query("SELECT * FROM cached_artists WHERE id = :id")
|
||||
suspend fun getById(id: String): CachedArtistEntity?
|
||||
|
||||
@Query("SELECT * FROM cached_artists WHERE id = :id")
|
||||
fun observeById(id: String): Flow<CachedArtistEntity?>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsertAll(rows: List<CachedArtistEntity>)
|
||||
|
||||
|
||||
+3
@@ -18,6 +18,9 @@ interface CachedTrackDao {
|
||||
@Query("SELECT * FROM cached_tracks WHERE id = :id")
|
||||
suspend fun getById(id: String): CachedTrackEntity?
|
||||
|
||||
@Query("SELECT * FROM cached_tracks WHERE id = :id")
|
||||
fun observeById(id: String): Flow<CachedTrackEntity?>
|
||||
|
||||
@Query("SELECT * FROM cached_tracks WHERE id IN (:ids)")
|
||||
suspend fun getByIds(ids: List<String>): List<CachedTrackEntity>
|
||||
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package com.fabledsword.minstrel.metadata
|
||||
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
|
||||
import com.fabledsword.minstrel.di.ApplicationScope
|
||||
import com.fabledsword.minstrel.library.data.LibraryRepository
|
||||
import com.fabledsword.minstrel.library.data.toDomain
|
||||
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.Job
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Cache-first metadata reads with eager on-miss live-fetch.
|
||||
*
|
||||
* Mirrors Flutter's tile-provider pattern (`albumTileProvider(id)` /
|
||||
* `artistTileProvider(id)` / `trackTileProvider(id)`): subscribers
|
||||
* observe a per-ID Flow that emits the Room row immediately if
|
||||
* present, and otherwise triggers a single background fetch that
|
||||
* upserts into Room — the flow then re-emits the populated row.
|
||||
*
|
||||
* Fetch dedup is per-ID via a ConcurrentHashMap of in-flight Jobs;
|
||||
* five tiles asking for the same missing album = one network call.
|
||||
* Fetch errors are swallowed (the row stays null and the tile keeps
|
||||
* rendering its skeleton); the [FreshnessSweeper] retries later.
|
||||
*
|
||||
* Fire-and-forget: subscribers see Flow<X?> where null means "not
|
||||
* cached yet" — they render skeletons in that case and re-render
|
||||
* automatically when the fetch lands.
|
||||
*/
|
||||
@Singleton
|
||||
class MetadataProvider @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 inFlightAlbums = ConcurrentHashMap<String, Job>()
|
||||
private val inFlightArtists = ConcurrentHashMap<String, Job>()
|
||||
private val inFlightTracks = ConcurrentHashMap<String, Job>()
|
||||
|
||||
/**
|
||||
* Live AlbumRef? for [id]. Fires a background fetch the first
|
||||
* time the cache emits null. Safe to subscribe concurrently from
|
||||
* multiple tiles — fetch is deduplicated.
|
||||
*/
|
||||
fun observeAlbum(id: String): Flow<AlbumRef?> =
|
||||
albumDao.observeById(id)
|
||||
.map { it?.toDomain() }
|
||||
.onEach { row -> if (row == null) fetchAlbum(id) }
|
||||
|
||||
fun observeArtist(id: String): Flow<ArtistRef?> =
|
||||
artistDao.observeById(id)
|
||||
.map { it?.toDomain() }
|
||||
.onEach { row -> if (row == null) fetchArtist(id) }
|
||||
|
||||
fun observeTrack(id: String): Flow<TrackRef?> =
|
||||
trackDao.observeById(id)
|
||||
.map { it?.toDomain() }
|
||||
.onEach { row -> if (row == null) fetchTrack(id) }
|
||||
|
||||
/**
|
||||
* Force-refresh [id] from the server even if the cache has it.
|
||||
* Used by [FreshnessSweeper] to walk stale rows. Same dedup as
|
||||
* the on-miss path — repeated calls during an in-flight fetch
|
||||
* are no-ops.
|
||||
*/
|
||||
fun refreshAlbum(id: String): Job = fetchAlbum(id)
|
||||
fun refreshArtist(id: String): Job = fetchArtist(id)
|
||||
fun refreshTrack(id: String): Job = fetchTrack(id)
|
||||
|
||||
private fun fetchAlbum(id: String): Job = launchDeduped(inFlightAlbums, id) {
|
||||
libraryRepository.refreshAlbumDetail(id)
|
||||
}
|
||||
|
||||
private fun fetchArtist(id: String): Job = launchDeduped(inFlightArtists, id) {
|
||||
libraryRepository.refreshArtistDetail(id)
|
||||
}
|
||||
|
||||
private fun fetchTrack(id: String): Job = launchDeduped(inFlightTracks, id) {
|
||||
libraryRepository.refreshTrack(id)
|
||||
}
|
||||
|
||||
private fun launchDeduped(
|
||||
inFlight: ConcurrentHashMap<String, Job>,
|
||||
id: String,
|
||||
block: suspend () -> Unit,
|
||||
): Job = inFlight.computeIfAbsent(id) {
|
||||
appScope.launch {
|
||||
try {
|
||||
block()
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||
) {
|
||||
// Soft-fail. Row stays null; tile stays skeletal until
|
||||
// FreshnessSweeper retries or the user pulls to refresh.
|
||||
} finally {
|
||||
inFlight.remove(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user