diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/AppDatabase.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/AppDatabase.kt index eb2d7ba0..cb525e30 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/AppDatabase.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/AppDatabase.kt @@ -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 } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/AudioCacheIndexDao.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/AudioCacheIndexDao.kt new file mode 100644 index 00000000..01eb267d --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/AudioCacheIndexDao.kt @@ -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> + + @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): List + + /** All cached track IDs — bucket-membership lookup for eviction. */ + @Query("SELECT trackId FROM audio_cache_index") + suspend fun allCachedTrackIds(): List + + /** 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 + + /** 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) + + @Query("DELETE FROM audio_cache_index") + suspend fun clear() +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/AudioCacheIndexEntity.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/AudioCacheIndexEntity.kt new file mode 100644 index 00000000..efdc9a87 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/AudioCacheIndexEntity.kt @@ -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, +)