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:
2026-05-27 21:08:59 -04:00
parent 05c7d922c4
commit 18b082def0
4 changed files with 121 additions and 0 deletions
@@ -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>)
@@ -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>)
@@ -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)
}
}
}
}