From 18b082def031b07071a3b5745685c80cececbe87 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 27 May 2026 21:08:59 -0400 Subject: [PATCH] =?UTF-8?q?feat(android):=20MetadataProvider=20=E2=80=94?= =?UTF-8?q?=20cache-first=20reads=20+=20on-miss=20live=20fetch=20(audit=20?= =?UTF-8?q?v3=20#24,=20slice=201/3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 — 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) --- .../minstrel/cache/db/dao/CachedAlbumDao.kt | 3 + .../minstrel/cache/db/dao/CachedArtistDao.kt | 3 + .../minstrel/cache/db/dao/CachedTrackDao.kt | 3 + .../minstrel/metadata/MetadataProvider.kt | 112 ++++++++++++++++++ 4 files changed, 121 insertions(+) create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/metadata/MetadataProvider.kt diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedAlbumDao.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedAlbumDao.kt index 3f84bc61..7dd89e97 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedAlbumDao.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedAlbumDao.kt @@ -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 + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertAll(rows: List) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedArtistDao.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedArtistDao.kt index 2bd4cc03..31183056 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedArtistDao.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedArtistDao.kt @@ -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 + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertAll(rows: List) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedTrackDao.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedTrackDao.kt index 31dbc058..682f1f5b 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedTrackDao.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedTrackDao.kt @@ -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 + @Query("SELECT * FROM cached_tracks WHERE id IN (:ids)") suspend fun getByIds(ids: List): List diff --git a/android/app/src/main/java/com/fabledsword/minstrel/metadata/MetadataProvider.kt b/android/app/src/main/java/com/fabledsword/minstrel/metadata/MetadataProvider.kt new file mode 100644 index 00000000..7947d016 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/metadata/MetadataProvider.kt @@ -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 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() + private val inFlightArtists = ConcurrentHashMap() + private val inFlightTracks = ConcurrentHashMap() + + /** + * 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 = + albumDao.observeById(id) + .map { it?.toDomain() } + .onEach { row -> if (row == null) fetchAlbum(id) } + + fun observeArtist(id: String): Flow = + artistDao.observeById(id) + .map { it?.toDomain() } + .onEach { row -> if (row == null) fetchArtist(id) } + + fun observeTrack(id: String): Flow = + 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, + 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) + } + } + } +}