From 69ccd7b25d0cde1a0c12ccca9841c9b84dfe930e Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 5 Jun 2026 12:29:59 -0400 Subject: [PATCH] feat(android): per-playlist fullyCached via cache-index join MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CachedPlaylistDao.observeCachedCounts LEFT-JOINs cached_playlist_tracks × audio_cache_index per playlist; PlaylistsRepository.observeAll combines it in and stamps PlaylistRef.fullyCached (trackCount>0 && cached>=trackCount). Merge extracted to a pure mergePlaylistsWithCache for Android-free unit tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../cache/db/dao/CachedPlaylistDao.kt | 14 ++++ .../cache/db/dao/PlaylistCachedCount.kt | 11 ++++ .../fabledsword/minstrel/models/Playlist.kt | 2 + .../playlists/data/PlaylistsRepository.kt | 30 ++++++++- .../data/PlaylistsRepositoryCacheMergeTest.kt | 64 +++++++++++++++++++ 5 files changed, 119 insertions(+), 2 deletions(-) create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/PlaylistCachedCount.kt create mode 100644 android/app/src/test/java/com/fabledsword/minstrel/playlists/data/PlaylistsRepositoryCacheMergeTest.kt diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedPlaylistDao.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedPlaylistDao.kt index bb3276da..1fc818b8 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedPlaylistDao.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedPlaylistDao.kt @@ -31,6 +31,20 @@ interface CachedPlaylistDao { @Query("SELECT * FROM cached_playlists WHERE id = :id") suspend fun getById(id: String): CachedPlaylistEntity? + /** + * Per-playlist count of member tracks resident in the audio cache index. + * LEFT JOINs so playlists with zero cached tracks still appear + * (cachedCount = 0). Drives the offline "fully cached" greying. + */ + @Query( + "SELECT p.id AS playlistId, COUNT(a.trackId) AS cachedCount " + + "FROM cached_playlists p " + + "LEFT JOIN cached_playlist_tracks t ON t.playlistId = p.id " + + "LEFT JOIN audio_cache_index a ON a.trackId = t.trackId " + + "GROUP BY p.id", + ) + fun observeCachedCounts(): Flow> + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertAll(rows: List) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/PlaylistCachedCount.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/PlaylistCachedCount.kt new file mode 100644 index 00000000..3aea0e79 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/PlaylistCachedCount.kt @@ -0,0 +1,11 @@ +package com.fabledsword.minstrel.cache.db.dao + +/** + * Projection: how many of a playlist's member tracks are resident in the audio + * cache index. Backs the offline "fully cached" greying — a playlist is fully + * available offline when [cachedCount] reaches its `trackCount`. + */ +data class PlaylistCachedCount( + val playlistId: String, + val cachedCount: Int, +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/Playlist.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/Playlist.kt index a4610c05..d02a7e0f 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/models/Playlist.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/Playlist.kt @@ -20,6 +20,8 @@ data class PlaylistRef( val trackCount: Int = 0, val coverUrl: String = "", val ownerUsername: String = "", + /** All member tracks resident in the audio cache — playable fully offline. */ + val fullyCached: Boolean = false, ) { val isSystem: Boolean get() = systemVariant != null diff --git a/android/app/src/main/java/com/fabledsword/minstrel/playlists/data/PlaylistsRepository.kt b/android/app/src/main/java/com/fabledsword/minstrel/playlists/data/PlaylistsRepository.kt index 02bf1c26..b68db292 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/playlists/data/PlaylistsRepository.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/playlists/data/PlaylistsRepository.kt @@ -7,6 +7,7 @@ import retrofit2.HttpException import java.net.HttpURLConnection import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistDao import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao +import com.fabledsword.minstrel.cache.db.dao.PlaylistCachedCount import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistEntity import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistTrackEntity import com.fabledsword.minstrel.cache.mutations.MutationQueue @@ -18,6 +19,7 @@ import com.fabledsword.minstrel.models.wire.PlaylistTrackWire import com.fabledsword.minstrel.models.wire.PlaylistWire import com.fabledsword.minstrel.shared.resolveServerUrl import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map import retrofit2.Retrofit import retrofit2.create @@ -51,9 +53,16 @@ class PlaylistsRepository @Inject constructor( // ── Reads (Flow, cache-first; the cache is the source of truth) ── - /** Owned + public; UI splits by isSystem / userId comparison. */ + /** + * Owned + public; UI splits by isSystem / userId comparison. Joined with + * per-playlist cache counts so [PlaylistRef.fullyCached] drives the offline + * greying without each consumer re-querying the cache index. + */ fun observeAll(): Flow> = - playlistDao.observeAll().map { rows -> rows.map { it.toDomain() } } + combine( + playlistDao.observeAll(), + playlistDao.observeCachedCounts(), + ) { rows, counts -> mergePlaylistsWithCache(rows, counts) } /** User-owned playlists only (systemVariant IS NULL). */ fun observeUserPlaylists(): Flow> = @@ -233,6 +242,23 @@ fun List.toPlayableTrackRefs(): List = // ── Mappers (internal — keep Room + wire types out of the UI layer) ── +/** + * Maps cached playlist rows to domain refs, stamping [PlaylistRef.fullyCached] + * from the cache-index counts. A playlist is fully cached when it has tracks + * and every member track is resident (`cachedCount >= trackCount`). Pulled out + * of the Flow so the predicate is unit-testable without a Room database. + */ +internal fun mergePlaylistsWithCache( + rows: List, + counts: List, +): List { + val cachedById = counts.associate { it.playlistId to it.cachedCount } + return rows.map { row -> + val cached = cachedById[row.id] ?: 0 + row.toDomain().copy(fullyCached = row.trackCount > 0 && cached >= row.trackCount) + } +} + private fun CachedPlaylistEntity.toDomain(): PlaylistRef = PlaylistRef( id = id, diff --git a/android/app/src/test/java/com/fabledsword/minstrel/playlists/data/PlaylistsRepositoryCacheMergeTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/playlists/data/PlaylistsRepositoryCacheMergeTest.kt new file mode 100644 index 00000000..b93d938e --- /dev/null +++ b/android/app/src/test/java/com/fabledsword/minstrel/playlists/data/PlaylistsRepositoryCacheMergeTest.kt @@ -0,0 +1,64 @@ +package com.fabledsword.minstrel.playlists.data + +import com.fabledsword.minstrel.cache.db.dao.PlaylistCachedCount +import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistEntity +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class PlaylistsRepositoryCacheMergeTest { + + private fun playlist(id: String, trackCount: Int) = + CachedPlaylistEntity(id = id, userId = "u", name = id, trackCount = trackCount) + + @Test + fun `fully cached when every member track is resident`() { + val out = mergePlaylistsWithCache( + rows = listOf(playlist("p", trackCount = 3)), + counts = listOf(PlaylistCachedCount("p", cachedCount = 3)), + ) + assertTrue(out.single().fullyCached) + } + + @Test + fun `not fully cached when only some tracks are resident`() { + val out = mergePlaylistsWithCache( + rows = listOf(playlist("p", trackCount = 3)), + counts = listOf(PlaylistCachedCount("p", cachedCount = 2)), + ) + assertFalse(out.single().fullyCached) + } + + @Test + fun `not fully cached when no tracks are resident (missing count row)`() { + val out = mergePlaylistsWithCache( + rows = listOf(playlist("p", trackCount = 3)), + counts = emptyList(), + ) + assertFalse(out.single().fullyCached) + } + + @Test + fun `empty playlist is never fully cached`() { + val out = mergePlaylistsWithCache( + rows = listOf(playlist("p", trackCount = 0)), + counts = listOf(PlaylistCachedCount("p", cachedCount = 0)), + ) + assertFalse(out.single().fullyCached) + } + + @Test + fun `each playlist gets its own cache verdict`() { + val out = mergePlaylistsWithCache( + rows = listOf(playlist("full", 2), playlist("partial", 2)), + counts = listOf( + PlaylistCachedCount("full", cachedCount = 2), + PlaylistCachedCount("partial", cachedCount = 1), + ), + ) + val byId = out.associateBy { it.id } + assertEquals(true, byId.getValue("full").fullyCached) + assertEquals(false, byId.getValue("partial").fullyCached) + } +}