feat(android): per-playlist fullyCached via cache-index join
android / Build + lint + test (push) Successful in 3m27s
android / Build + lint + test (push) Successful in 3m27s
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) <noreply@anthropic.com>
This commit is contained in:
+14
@@ -31,6 +31,20 @@ interface CachedPlaylistDao {
|
|||||||
@Query("SELECT * FROM cached_playlists WHERE id = :id")
|
@Query("SELECT * FROM cached_playlists WHERE id = :id")
|
||||||
suspend fun getById(id: String): CachedPlaylistEntity?
|
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<List<PlaylistCachedCount>>
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
suspend fun upsertAll(rows: List<CachedPlaylistEntity>)
|
suspend fun upsertAll(rows: List<CachedPlaylistEntity>)
|
||||||
|
|
||||||
|
|||||||
+11
@@ -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,
|
||||||
|
)
|
||||||
@@ -20,6 +20,8 @@ data class PlaylistRef(
|
|||||||
val trackCount: Int = 0,
|
val trackCount: Int = 0,
|
||||||
val coverUrl: String = "",
|
val coverUrl: String = "",
|
||||||
val ownerUsername: 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
|
val isSystem: Boolean get() = systemVariant != null
|
||||||
|
|
||||||
|
|||||||
+28
-2
@@ -7,6 +7,7 @@ import retrofit2.HttpException
|
|||||||
import java.net.HttpURLConnection
|
import java.net.HttpURLConnection
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao
|
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.CachedPlaylistEntity
|
||||||
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistTrackEntity
|
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistTrackEntity
|
||||||
import com.fabledsword.minstrel.cache.mutations.MutationQueue
|
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.models.wire.PlaylistWire
|
||||||
import com.fabledsword.minstrel.shared.resolveServerUrl
|
import com.fabledsword.minstrel.shared.resolveServerUrl
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
import retrofit2.create
|
import retrofit2.create
|
||||||
@@ -51,9 +53,16 @@ class PlaylistsRepository @Inject constructor(
|
|||||||
|
|
||||||
// ── Reads (Flow, cache-first; the cache is the source of truth) ──
|
// ── 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<List<PlaylistRef>> =
|
fun observeAll(): Flow<List<PlaylistRef>> =
|
||||||
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). */
|
/** User-owned playlists only (systemVariant IS NULL). */
|
||||||
fun observeUserPlaylists(): Flow<List<PlaylistRef>> =
|
fun observeUserPlaylists(): Flow<List<PlaylistRef>> =
|
||||||
@@ -233,6 +242,23 @@ fun List<PlaylistTrackRef>.toPlayableTrackRefs(): List<TrackRef> =
|
|||||||
|
|
||||||
// ── Mappers (internal — keep Room + wire types out of the UI layer) ──
|
// ── 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<CachedPlaylistEntity>,
|
||||||
|
counts: List<PlaylistCachedCount>,
|
||||||
|
): List<PlaylistRef> {
|
||||||
|
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 =
|
private fun CachedPlaylistEntity.toDomain(): PlaylistRef =
|
||||||
PlaylistRef(
|
PlaylistRef(
|
||||||
id = id,
|
id = id,
|
||||||
|
|||||||
+64
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user