feat(android): port cached_artists / cached_albums / cached_tracks (M8 4.2 slice 1)

Mirrors flutter_client/lib/cache/db.dart's CachedArtists / CachedAlbums /
CachedTracks Drift tables. Library cache foundation — LibraryRepository
(Phase 5.2) reads cache-first through these DAOs and refreshes from
server via the sync controller (Phase 12.4).

Column names follow Kotlin idiom (camelCase) instead of Drift's
snake_case; the schema is internal to the native client and the wire
JSON conversion happens in feature-level mappers.

Each DAO carries:
  - observe* (Flow) for cache-first reads in ViewModels
  - getById/getByIds (suspend) for one-shot lookups
  - upsertAll (suspend, REPLACE) for sync writes
  - deleteByIds (suspend) for sync-driven deletes

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-22 13:28:54 -04:00
parent a10079e8ef
commit 17a3e7dd4f
7 changed files with 168 additions and 4 deletions
@@ -3,7 +3,13 @@ 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.CachedAlbumDao
import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
import com.fabledsword.minstrel.cache.db.dao.SyncMetadataDao
import com.fabledsword.minstrel.cache.db.entities.CachedAlbumEntity
import com.fabledsword.minstrel.cache.db.entities.CachedArtistEntity
import com.fabledsword.minstrel.cache.db.entities.CachedTrackEntity
import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
/**
@@ -16,14 +22,15 @@ import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
* change with explicit `Migration` entries; `fallbackToDestructiveMigration`
* in `DatabaseModule` is the safety net while we're pre-v1.
*
* Most entity families land in Task 4.2 (one commit per Drift table
* family). `SyncMetadataEntity` was pulled forward into Task 4.1 to
* satisfy Room's "at least one entity required" compile-time check;
* its consumer (SyncController) arrives in Phase 12.4.
* Entity families land in Task 4.2 slices. The set will grow until the
* whole Drift schema is mirrored.
*/
@Database(
entities = [
SyncMetadataEntity::class,
CachedArtistEntity::class,
CachedAlbumEntity::class,
CachedTrackEntity::class,
],
version = 1,
exportSchema = true,
@@ -31,4 +38,7 @@ import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
@TypeConverters(MinstrelTypeConverters::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun syncMetadataDao(): SyncMetadataDao
abstract fun cachedArtistDao(): CachedArtistDao
abstract fun cachedAlbumDao(): CachedAlbumDao
abstract fun cachedTrackDao(): CachedTrackDao
}
@@ -0,0 +1,29 @@
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.CachedAlbumEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface CachedAlbumDao {
@Query("SELECT * FROM cached_albums ORDER BY sortTitle COLLATE NOCASE ASC")
fun observeAll(): Flow<List<CachedAlbumEntity>>
@Query(
"SELECT * FROM cached_albums " +
"WHERE artistId = :artistId ORDER BY sortTitle COLLATE NOCASE ASC",
)
fun observeByArtist(artistId: String): Flow<List<CachedAlbumEntity>>
@Query("SELECT * FROM cached_albums WHERE id = :id")
suspend fun getById(id: String): CachedAlbumEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertAll(rows: List<CachedAlbumEntity>)
@Query("DELETE FROM cached_albums WHERE id IN (:ids)")
suspend fun deleteByIds(ids: List<String>)
}
@@ -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.CachedArtistEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface CachedArtistDao {
@Query("SELECT * FROM cached_artists ORDER BY sortName COLLATE NOCASE ASC")
fun observeAll(): Flow<List<CachedArtistEntity>>
@Query("SELECT * FROM cached_artists WHERE id = :id")
suspend fun getById(id: String): CachedArtistEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertAll(rows: List<CachedArtistEntity>)
@Query("DELETE FROM cached_artists WHERE id IN (:ids)")
suspend fun deleteByIds(ids: List<String>)
}
@@ -0,0 +1,29 @@
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.CachedTrackEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface CachedTrackDao {
@Query(
"SELECT * FROM cached_tracks " +
"WHERE albumId = :albumId ORDER BY discNumber ASC, trackNumber ASC",
)
fun observeByAlbum(albumId: String): Flow<List<CachedTrackEntity>>
@Query("SELECT * FROM cached_tracks WHERE id = :id")
suspend fun getById(id: String): CachedTrackEntity?
@Query("SELECT * FROM cached_tracks WHERE id IN (:ids)")
suspend fun getByIds(ids: List<String>): List<CachedTrackEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertAll(rows: List<CachedTrackEntity>)
@Query("DELETE FROM cached_tracks WHERE id IN (:ids)")
suspend fun deleteByIds(ids: List<String>)
}
@@ -0,0 +1,22 @@
package com.fabledsword.minstrel.cache.db.entities
import androidx.room.Entity
import androidx.room.PrimaryKey
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
/**
* Cache row for one album. Mirrors `flutter_client/lib/cache/db.dart`'s
* `CachedAlbums` Drift table.
*/
@Entity(tableName = "cached_albums")
data class CachedAlbumEntity(
@PrimaryKey val id: String,
val artistId: String,
val title: String,
val sortTitle: String,
val releaseDate: String? = null,
val coverPath: String? = null,
val mbid: String? = null,
val fetchedAt: Instant = Clock.System.now(),
)
@@ -0,0 +1,26 @@
package com.fabledsword.minstrel.cache.db.entities
import androidx.room.Entity
import androidx.room.PrimaryKey
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
/**
* Cache row for one artist. Mirrors `flutter_client/lib/cache/db.dart`'s
* `CachedArtists` Drift table.
*
* Column names follow Kotlin idiom (camelCase) rather than Drift's
* snake_case — the schema is purely internal to the native client; the
* sync controller exchanges snake_case JSON with the server and our
* mappers translate at the boundary.
*/
@Entity(tableName = "cached_artists")
data class CachedArtistEntity(
@PrimaryKey val id: String,
val name: String,
val sortName: String,
val mbid: String? = null,
val artistThumbPath: String? = null,
val artistFanartPath: String? = null,
val fetchedAt: Instant = Clock.System.now(),
)
@@ -0,0 +1,25 @@
package com.fabledsword.minstrel.cache.db.entities
import androidx.room.Entity
import androidx.room.PrimaryKey
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
/**
* Cache row for one track. Mirrors `flutter_client/lib/cache/db.dart`'s
* `CachedTracks` Drift table.
*/
@Entity(tableName = "cached_tracks")
data class CachedTrackEntity(
@PrimaryKey val id: String,
val albumId: String,
val artistId: String,
val title: String,
val durationMs: Int = 0,
val trackNumber: Int? = null,
val discNumber: Int? = null,
val filePath: String? = null,
val fileFormat: String? = null,
val genre: String? = null,
val fetchedAt: Instant = Clock.System.now(),
)