feat(android): offline pools filter to cache-resident tracks (#38 slice 3)

ShuffleSource intersects the play-recency list with the Media3
SimpleCache key set, so the offline Recently-played / Liked pools only
offer tracks whose audio is actually on disk. Flutter's index holds
fully-cached tracks only; Android's records plays, so this intersection
restores parity — a played-then-evicted track no longer surfaces in a
pool where it would fail to play offline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-28 13:17:12 -04:00
parent 5e18d506fe
commit fbd6264037
@@ -5,6 +5,7 @@ import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
import com.fabledsword.minstrel.library.data.toDomain import com.fabledsword.minstrel.library.data.toDomain
import com.fabledsword.minstrel.likes.data.LikesRepository import com.fabledsword.minstrel.likes.data.LikesRepository
import com.fabledsword.minstrel.models.TrackRef import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.player.PlayerFactory
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -19,6 +20,12 @@ private const val POOL_LIMIT = 100
* storage/eviction-only and never filters playback — exactly what * storage/eviction-only and never filters playback — exactly what
* this relies on. * this relies on.
* *
* Divergence note: Flutter's `audioCacheIndex` only holds fully-cached
* tracks, so its pools are inherently resident. Android's index records
* plays (see `CacheIndexer`), so the recency list is intersected with
* true Media3 SimpleCache residency here — the pool never offers a
* played-then-evicted track that would fail to play offline.
*
* Surfaced on Home's Playlists row only when offline; tap shuffles + * Surfaced on Home's Playlists row only when offline; tap shuffles +
* plays the pool from the cache. * plays the pool from the cache.
*/ */
@@ -27,18 +34,26 @@ class ShuffleSource @Inject constructor(
private val cacheIndexDao: AudioCacheIndexDao, private val cacheIndexDao: AudioCacheIndexDao,
private val trackDao: CachedTrackDao, private val trackDao: CachedTrackDao,
private val likes: LikesRepository, private val likes: LikesRepository,
private val playerFactory: PlayerFactory,
) { ) {
/** Cached tracks, most-recently-played first (liked included). */ /** Cached tracks, most-recently-played first (liked included). */
suspend fun recentlyPlayed(limit: Int = POOL_LIMIT): List<TrackRef> = suspend fun recentlyPlayed(limit: Int = POOL_LIMIT): List<TrackRef> =
materialize(cacheIndexDao.trackIdsByRecency()).take(limit) materialize(residentIdsByRecency()).take(limit)
/** Cached tracks that are in the user's liked set, recency-ordered. */ /** Cached tracks that are in the user's liked set, recency-ordered. */
suspend fun liked(limit: Int = POOL_LIMIT): List<TrackRef> { suspend fun liked(limit: Int = POOL_LIMIT): List<TrackRef> {
val likedSet = likes.likedTrackIds() val likedSet = likes.likedTrackIds()
val ids = cacheIndexDao.trackIdsByRecency().filter { it in likedSet } val ids = residentIdsByRecency().filter { it in likedSet }
return materialize(ids).take(limit) return materialize(ids).take(limit)
} }
/** Recency-ordered ids whose audio is actually resident in the cache. */
private suspend fun residentIdsByRecency(): List<String> {
val resident = runCatching { playerFactory.simpleCache.keys.toSet() }
.getOrDefault(emptySet())
return cacheIndexDao.trackIdsByRecency().filter { it in resident }
}
/** Materialize ordered ids into TrackRefs from cached metadata, preserving order. */ /** Materialize ordered ids into TrackRefs from cached metadata, preserving order. */
private suspend fun materialize(orderedIds: List<String>): List<TrackRef> { private suspend fun materialize(orderedIds: List<String>): List<TrackRef> {
if (orderedIds.isEmpty()) return emptyList() if (orderedIds.isEmpty()) return emptyList()