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.likes.data.LikesRepository
import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.player.PlayerFactory
import javax.inject.Inject
import javax.inject.Singleton
@@ -19,6 +20,12 @@ private const val POOL_LIMIT = 100
* storage/eviction-only and never filters playback — exactly what
* 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 +
* plays the pool from the cache.
*/
@@ -27,18 +34,26 @@ class ShuffleSource @Inject constructor(
private val cacheIndexDao: AudioCacheIndexDao,
private val trackDao: CachedTrackDao,
private val likes: LikesRepository,
private val playerFactory: PlayerFactory,
) {
/** Cached tracks, most-recently-played first (liked included). */
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. */
suspend fun liked(limit: Int = POOL_LIMIT): List<TrackRef> {
val likedSet = likes.likedTrackIds()
val ids = cacheIndexDao.trackIdsByRecency().filter { it in likedSet }
val ids = residentIdsByRecency().filter { it in likedSet }
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. */
private suspend fun materialize(orderedIds: List<String>): List<TrackRef> {
if (orderedIds.isEmpty()) return emptyList()