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")
|
||||
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)
|
||||
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 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
|
||||
|
||||
|
||||
+28
-2
@@ -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<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). */
|
||||
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) ──
|
||||
|
||||
/**
|
||||
* 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 =
|
||||
PlaylistRef(
|
||||
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