feat(android): port audio_cache_index (M8 4.2 slice 5)

Mirrors flutter_client/lib/cache/db.dart's AudioCacheIndex — one row
per fully-downloaded audio file. Drives the 2-bucket LRU eviction
policy that Phase 12's AudioCacheEvictionWorker will execute.

DAO surface tailored to the eviction worker:
  - totalBytes / bytesBySource — sum-of-sizeBytes for cap checks
  - evictionCandidates(source) — oldest lastPlayedAt within a source
    bucket, NULL lastPlayedAt sorted first (never-played candidates
    evict before played ones)
  - touchLastPlayed(trackId, instant) — single-column update from
    the player on every "ready+playing" transition
  - bulk delete by track-id list for batch evictions

CacheSource enum (MANUAL / INCIDENTAL / AUTO_PREFETCH) ported in
Task 4.1's TypeConverters.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-22 14:53:57 -04:00
parent c18ad19418
commit e63034ec9c
3 changed files with 95 additions and 0 deletions
@@ -3,6 +3,7 @@ package com.fabledsword.minstrel.cache.db
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import com.fabledsword.minstrel.cache.db.dao.AudioCacheIndexDao
import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao
import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao
import com.fabledsword.minstrel.cache.db.dao.CachedLikeDao
@@ -11,6 +12,7 @@ import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao
import com.fabledsword.minstrel.cache.db.dao.CachedQuarantineDao
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
import com.fabledsword.minstrel.cache.db.dao.SyncMetadataDao
import com.fabledsword.minstrel.cache.db.entities.AudioCacheIndexEntity
import com.fabledsword.minstrel.cache.db.entities.CachedAlbumEntity
import com.fabledsword.minstrel.cache.db.entities.CachedArtistEntity
import com.fabledsword.minstrel.cache.db.entities.CachedLikeEntity
@@ -43,6 +45,7 @@ import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
CachedPlaylistEntity::class,
CachedPlaylistTrackEntity::class,
CachedQuarantineEntity::class,
AudioCacheIndexEntity::class,
],
version = 1,
exportSchema = true,
@@ -57,4 +60,5 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun cachedPlaylistDao(): CachedPlaylistDao
abstract fun cachedPlaylistTrackDao(): CachedPlaylistTrackDao
abstract fun cachedQuarantineDao(): CachedQuarantineDao
abstract fun audioCacheIndexDao(): AudioCacheIndexDao
}
@@ -0,0 +1,60 @@
package com.fabledsword.minstrel.cache.db.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.fabledsword.minstrel.cache.db.CacheSource
import com.fabledsword.minstrel.cache.db.entities.AudioCacheIndexEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface AudioCacheIndexDao {
/** Used to render the "Downloaded" / "Offline" listing. */
@Query("SELECT * FROM audio_cache_index ORDER BY lastPlayedAt DESC NULLS LAST")
fun observeAll(): Flow<List<AudioCacheIndexEntity>>
@Query("SELECT * FROM audio_cache_index WHERE trackId = :trackId")
suspend fun get(trackId: String): AudioCacheIndexEntity?
@Query("SELECT * FROM audio_cache_index WHERE trackId IN (:trackIds)")
suspend fun getByTrackIds(trackIds: List<String>): List<AudioCacheIndexEntity>
/** All cached track IDs — bucket-membership lookup for eviction. */
@Query("SELECT trackId FROM audio_cache_index")
suspend fun allCachedTrackIds(): List<String>
/** Eviction-candidate ordering — oldest lastPlayedAt first within a source. */
@Query(
"SELECT * FROM audio_cache_index " +
"WHERE source = :source " +
"ORDER BY lastPlayedAt ASC NULLS FIRST, cachedAt ASC",
)
suspend fun evictionCandidates(source: CacheSource): List<AudioCacheIndexEntity>
/** Sum of bytes across rows; the rolling/liked bucket caps compare against this. */
@Query("SELECT COALESCE(SUM(sizeBytes), 0) FROM audio_cache_index")
suspend fun totalBytes(): Long
@Query(
"SELECT COALESCE(SUM(sizeBytes), 0) FROM audio_cache_index WHERE source = :source",
)
suspend fun bytesBySource(source: CacheSource): Long
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(row: AudioCacheIndexEntity)
@Query(
"UPDATE audio_cache_index SET lastPlayedAt = :at WHERE trackId = :trackId",
)
suspend fun touchLastPlayed(trackId: String, at: kotlinx.datetime.Instant)
@Query("DELETE FROM audio_cache_index WHERE trackId = :trackId")
suspend fun deleteByTrackId(trackId: String)
@Query("DELETE FROM audio_cache_index WHERE trackId IN (:trackIds)")
suspend fun deleteByTrackIds(trackIds: List<String>)
@Query("DELETE FROM audio_cache_index")
suspend fun clear()
}
@@ -0,0 +1,31 @@
package com.fabledsword.minstrel.cache.db.entities
import androidx.room.Entity
import androidx.room.PrimaryKey
import com.fabledsword.minstrel.cache.db.CacheSource
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
/**
* One row per fully-downloaded audio file. Mirrors
* `flutter_client/lib/cache/db.dart`'s `AudioCacheIndex` Drift table.
*
* Drives the 2-bucket LRU eviction (Phase 12 AudioCacheEvictionWorker):
* - `incidental` files (streamed-and-cached side effect) evict first
* - `autoPrefetch` second
* - `manual` (user explicitly pinned) evict last
*
* `cachedAt` = when we downloaded. `lastPlayedAt` = when the user last
* played it; drives the offline "recently played" ordering and is the
* tiebreaker for LRU eviction within a source bucket. Nullable on
* brand-new rows until the first post-cache play touches them.
*/
@Entity(tableName = "audio_cache_index")
data class AudioCacheIndexEntity(
@PrimaryKey val trackId: String,
val path: String,
val sizeBytes: Int,
val cachedAt: Instant = Clock.System.now(),
val lastPlayedAt: Instant? = null,
val source: CacheSource,
)