feat(android): port cached_resume_state + fix CacheSource enum (M8 4.2 slice 8)

Two related fixes from re-reading the Drift source for slice 8:

  1. The plan called this slice "last_played" but the Drift table is
     `cached_resume_state` — kept Drift's name for cross-reference
     during the port. Single-row JSON-blob pattern (queue, currentIndex,
     positionMs, source) — ResumeController (Phase 6.5) handles the
     Kotlin-side encode/decode so the schema stays stable across
     resume-shape evolution.

  2. CacheSource enum was incomplete: Task 4.1 ported only 3 of the 5
     Drift variants. Added AUTO_LIKED + AUTO_PLAYLIST (used by the
     auto-cache prefetcher to tag cached files by reason — drives the
     bucket eviction priority order INCIDENTAL > AUTO_PREFETCH >
     AUTO_PLAYLIST > AUTO_LIKED > MANUAL).

No data migration needed — schema version is still 1 and we have no
real users yet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-22 16:35:10 -04:00
parent 0b1ccd59b1
commit f3a0c44460
4 changed files with 63 additions and 3 deletions
@@ -9,6 +9,7 @@ import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao
import com.fabledsword.minstrel.cache.db.dao.CachedLikeDao
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistDao
import com.fabledsword.minstrel.cache.db.dao.CachedResumeStateDao
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao
import com.fabledsword.minstrel.cache.db.dao.CachedQuarantineDao
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
@@ -19,6 +20,7 @@ import com.fabledsword.minstrel.cache.db.entities.CachedArtistEntity
import com.fabledsword.minstrel.cache.db.entities.CachedLikeEntity
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistEntity
import com.fabledsword.minstrel.cache.db.entities.CachedResumeStateEntity
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistTrackEntity
import com.fabledsword.minstrel.cache.db.entities.CachedQuarantineEntity
import com.fabledsword.minstrel.cache.db.entities.CachedTrackEntity
@@ -49,6 +51,7 @@ import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
CachedQuarantineEntity::class,
AudioCacheIndexEntity::class,
CachedMutationEntity::class,
CachedResumeStateEntity::class,
],
version = 1,
exportSchema = true,
@@ -65,4 +68,5 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun cachedQuarantineDao(): CachedQuarantineDao
abstract fun audioCacheIndexDao(): AudioCacheIndexDao
abstract fun cachedMutationDao(): CachedMutationDao
abstract fun cachedResumeStateDao(): CachedResumeStateDao
}
@@ -5,13 +5,18 @@ import kotlinx.datetime.Instant
/**
* Source category for a cached audio file. Mirrors the Drift `CacheSource`
* enum used by the Flutter client's `audio_cache_index` table.
* enum used by the Flutter client's `audio_cache_index` table 1:1.
*
* - `MANUAL`: user explicitly pinned (e.g. "save for offline")
* - `INCIDENTAL`: cached as a side effect of playback streaming
* - `AUTO_LIKED`: auto-cached because the track is liked
* - `AUTO_PLAYLIST`: auto-cached because it's in a pinned playlist
* - `AUTO_PREFETCH`: pre-warmed by background prefetch heuristics
* - `INCIDENTAL`: cached as a side effect of playback streaming
*
* Eviction priority order (first to evict): INCIDENTAL → AUTO_PREFETCH
* → AUTO_PLAYLIST → AUTO_LIKED → MANUAL.
*/
enum class CacheSource { MANUAL, INCIDENTAL, AUTO_PREFETCH }
enum class CacheSource { MANUAL, AUTO_LIKED, AUTO_PLAYLIST, AUTO_PREFETCH, INCIDENTAL }
/**
* Cross-cutting Room type converters. Registered on `@Database` via
@@ -0,0 +1,23 @@
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.entities.CachedResumeStateEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface CachedResumeStateDao {
@Query("SELECT * FROM cached_resume_state WHERE id = 1")
suspend fun get(): CachedResumeStateEntity?
@Query("SELECT * FROM cached_resume_state WHERE id = 1")
fun observe(): Flow<CachedResumeStateEntity?>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(row: CachedResumeStateEntity)
@Query("DELETE FROM cached_resume_state")
suspend fun clear()
}
@@ -0,0 +1,28 @@
package com.fabledsword.minstrel.cache.db.entities
import androidx.room.Entity
import androidx.room.PrimaryKey
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
/**
* Single-row snapshot of the last playback session — queue (as JSON),
* current index, position, and source tag. Mirrors
* `flutter_client/lib/cache/db.dart`'s `CachedResumeState` Drift table.
*
* Lets a torn-down session (the player's idle/dismissed teardown)
* resume on next launch; without it the headset / lock-screen play
* button has nothing to act on. The Flutter codebase persists this on
* every queue change and reads on cold start.
*
* The `json` field carries the entire ResumeState shape (queue trackrefs,
* currentIndex, positionMs, sourceTag). Kotlin-side deserialization
* happens in the ResumeController (Phase 6.5) so the schema stays
* stable across resume-shape changes.
*/
@Entity(tableName = "cached_resume_state")
data class CachedResumeStateEntity(
@PrimaryKey val id: Int = 1,
val json: String,
val updatedAt: Instant = Clock.System.now(),
)