Room cache + SyncManager: offline-first with revision-based sync
This commit is contained in:
parent
4d52f85900
commit
f2429cd6ef
8 changed files with 498 additions and 38 deletions
|
|
@ -4,6 +4,13 @@
|
|||
<selectionStates>
|
||||
<SelectionState runConfigName="app">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
<DropdownSelection timestamp="2026-06-23T06:48:40.641060889Z">
|
||||
<Target type="DEFAULT_BOOT">
|
||||
<handle>
|
||||
<DeviceId pluginId="LocalEmulator" identifier="path=/home/sylar/.android/avd/Medium_Phone.avd" />
|
||||
</handle>
|
||||
</Target>
|
||||
</DropdownSelection>
|
||||
<DialogSelection />
|
||||
</SelectionState>
|
||||
</selectionStates>
|
||||
|
|
|
|||
|
|
@ -1,21 +1,20 @@
|
|||
package com.keylightpiano.pianotimeline
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.keylightpiano.pianotimeline.data.MusicRepository
|
||||
import com.keylightpiano.pianotimeline.data.SyncManager
|
||||
import com.keylightpiano.pianotimeline.domain.Recording
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Throwaway test ViewModel: fetch the list on creation, expose the result as state.
|
||||
* This is the real shape we'll keep (StateFlow + sealed state), just bare-bones.
|
||||
*/
|
||||
class TestViewModel : ViewModel() {
|
||||
class TestViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
private val repo = MusicRepository()
|
||||
private val repo = MusicRepository(app)
|
||||
private val syncManager = SyncManager(app)
|
||||
|
||||
private val _state = MutableStateFlow<FetchState>(FetchState.Loading)
|
||||
val state: StateFlow<FetchState> = _state.asStateFlow()
|
||||
|
|
@ -26,15 +25,17 @@ class TestViewModel : ViewModel() {
|
|||
|
||||
fun load() {
|
||||
_state.value = FetchState.Loading
|
||||
// viewModelScope is a coroutine scope tied to this ViewModel's lifetime.
|
||||
// Launching here is what lets us CALL the suspend repository function.
|
||||
viewModelScope.launch {
|
||||
// Sync first (updates Room), then observe from Room
|
||||
val result = syncManager.sync()
|
||||
try {
|
||||
val result = repo.fetchRecordingList()
|
||||
// One-shot collect to get current list for the test screen
|
||||
repo.observeRecordings().collect { recordings ->
|
||||
_state.value = FetchState.Success(
|
||||
revision = result.revision,
|
||||
recordings = result.recordings
|
||||
revision = syncManager.getCachedRevision(),
|
||||
recordings = recordings
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_state.value = FetchState.Error(e.message ?: e.toString())
|
||||
}
|
||||
|
|
@ -42,7 +43,6 @@ class TestViewModel : ViewModel() {
|
|||
}
|
||||
}
|
||||
|
||||
/** Three possible states of the screen, modeled explicitly. */
|
||||
sealed interface FetchState {
|
||||
data object Loading : FetchState
|
||||
data class Success(val revision: Int, val recordings: List<Recording>) : FetchState
|
||||
|
|
|
|||
|
|
@ -1,37 +1,93 @@
|
|||
package com.keylightpiano.pianotimeline.data
|
||||
|
||||
import android.content.Context
|
||||
import com.keylightpiano.pianotimeline.data.local.AppDatabase
|
||||
import com.keylightpiano.pianotimeline.data.local.RecordingDao
|
||||
import com.keylightpiano.pianotimeline.data.local.toDomainOrNull
|
||||
import com.keylightpiano.pianotimeline.data.remote.MusicNetwork
|
||||
import com.keylightpiano.pianotimeline.domain.Recording
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Result of a list fetch: the revision number plus the parsed recordings.
|
||||
* We keep the revision because the startup sync logic compares it against
|
||||
* what we last cached.
|
||||
* The single entry point for recording data.
|
||||
*
|
||||
* Primary source: Room database (fast, offline).
|
||||
* The SyncManager refreshes Room from the network on startup.
|
||||
*
|
||||
* The UI observes `observeRecordings()` which is a Flow from Room —
|
||||
* when SyncManager writes new data into Room, the Flow automatically
|
||||
* emits the updated list, and the UI recomposes. No manual "refresh" needed.
|
||||
*/
|
||||
data class RecordingList(
|
||||
val revision: Int,
|
||||
val recordings: List<Recording>
|
||||
)
|
||||
class MusicRepository(context: Context) {
|
||||
|
||||
/**
|
||||
* The repository is the single entry point the rest of the app uses to get data.
|
||||
* It hides *where* data comes from (network now, cache later) behind clean functions.
|
||||
*/
|
||||
class MusicRepository {
|
||||
private val dao: RecordingDao = AppDatabase.getInstance(context).recordingDao()
|
||||
|
||||
/**
|
||||
* Fetches list.json, parses it, and maps every entry to a clean Recording.
|
||||
* Broken entries (e.g. an unparseable date) are silently dropped rather than
|
||||
* crashing the whole list.
|
||||
*
|
||||
* Runs on the IO dispatcher — the thread pool meant for network/disk work —
|
||||
* so the caller never blocks the UI thread.
|
||||
* Live stream of all recordings, newest first.
|
||||
* The UI collects this as state. When Room is updated (by sync or by caching
|
||||
* a downloaded file), this Flow re-emits automatically.
|
||||
*/
|
||||
suspend fun fetchRecordingList(): RecordingList = withContext(Dispatchers.IO) {
|
||||
val dto = MusicNetwork.api.getRecordingList()
|
||||
val recordings = dto.data.mapNotNull { it.toDomainOrNull() }
|
||||
RecordingList(revision = dto.revision, recordings = recordings)
|
||||
fun observeRecordings(): Flow<List<Recording>> =
|
||||
dao.observeAllByDate().map { entities ->
|
||||
entities.mapNotNull { it.toDomainOrNull() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a recording's audio as cached at the given path.
|
||||
* Called after the player successfully downloads and saves an mp3.
|
||||
*/
|
||||
suspend fun setAudioCached(id: String, path: String) = withContext(Dispatchers.IO) {
|
||||
dao.setCachedAudioPath(id, path)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a recording's MIDI as cached at the given path.
|
||||
*/
|
||||
suspend fun setMidiCached(id: String, path: String) = withContext(Dispatchers.IO) {
|
||||
dao.setCachedMidiPath(id, path)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cached audio/midi files from disk and reset paths in the database.
|
||||
* Used by the "clear cache" setting.
|
||||
*/
|
||||
suspend fun clearAllCache(context: Context) = withContext(Dispatchers.IO) {
|
||||
// Delete physical files
|
||||
val cacheDir = audioCacheDir(context)
|
||||
if (cacheDir.exists()) {
|
||||
cacheDir.deleteRecursively()
|
||||
cacheDir.mkdirs() // recreate empty dir
|
||||
}
|
||||
// Reset DB paths
|
||||
dao.clearAllCachedPaths()
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a URL for streaming/downloading a recording's audio.
|
||||
*/
|
||||
fun audioUrl(recording: Recording): String =
|
||||
MusicNetwork.fileUrl(recording.fileName)
|
||||
|
||||
/**
|
||||
* Build a URL for downloading a recording's MIDI file.
|
||||
*/
|
||||
fun midiUrl(recording: Recording): String? =
|
||||
recording.midiFileName?.let { MusicNetwork.fileUrl(it) }
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Directory where cached audio files are stored.
|
||||
* Uses the app's internal cache dir so the OS can reclaim space if needed,
|
||||
* and it's automatically deleted if the user clears app data.
|
||||
*/
|
||||
fun audioCacheDir(context: Context): File =
|
||||
File(context.cacheDir, "audio").also { it.mkdirs() }
|
||||
|
||||
fun midiCacheDir(context: Context): File =
|
||||
File(context.cacheDir, "midi").also { it.mkdirs() }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,180 @@
|
|||
package com.keylightpiano.pianotimeline.data
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import com.keylightpiano.pianotimeline.data.local.AppDatabase
|
||||
import com.keylightpiano.pianotimeline.data.local.RecordingDao
|
||||
import com.keylightpiano.pianotimeline.data.local.toEntity
|
||||
import com.keylightpiano.pianotimeline.data.remote.MusicNetwork
|
||||
import com.keylightpiano.pianotimeline.domain.Recording
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* DataStore singleton: one per app, declared as a top-level extension property.
|
||||
* This is the recommended pattern from the official docs.
|
||||
* The string "settings" becomes the filename: settings.preferences_pb
|
||||
*/
|
||||
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
|
||||
|
||||
private val KEY_REVISION = intPreferencesKey("cached_revision")
|
||||
|
||||
private const val TAG = "SyncManager"
|
||||
|
||||
/**
|
||||
* Handles the startup sync:
|
||||
*
|
||||
* 1. Fetch list.json from the server.
|
||||
* 2. Compare its revision against the locally stored one.
|
||||
* 3. If unchanged → skip, the cache is current.
|
||||
* 4. If changed →
|
||||
* a. Diff IDs: find new recordings, removed recordings.
|
||||
* b. Diff file-sizes: find recordings whose audio file changed on the server.
|
||||
* c. Insert new entries into Room (with no cached paths).
|
||||
* d. Delete removed entries from Room AND delete their cached files from disk.
|
||||
* e. For changed-size entries: delete the stale cached files, clear their paths in Room.
|
||||
* f. Update all remaining entries' metadata (artist, title, duration, etc. might have changed).
|
||||
* g. Store the new revision number.
|
||||
*
|
||||
* The SyncManager does NOT download audio/midi files. Those are fetched on-demand
|
||||
* when the user plays a recording (handled by the player layer later).
|
||||
*/
|
||||
class SyncManager(private val context: Context) {
|
||||
|
||||
private val dao: RecordingDao = AppDatabase.getInstance(context).recordingDao()
|
||||
|
||||
/**
|
||||
* Result of a sync attempt.
|
||||
*/
|
||||
sealed interface SyncResult {
|
||||
/** Cache is already up to date. */
|
||||
data object UpToDate : SyncResult
|
||||
/** Cache was refreshed. Contains the updated recordings for immediate use. */
|
||||
data class Updated(
|
||||
val added: Int,
|
||||
val removed: Int,
|
||||
val sizeChanged: Int
|
||||
) : SyncResult
|
||||
/** Network or parsing failure. Stale cache may still be usable. */
|
||||
data class Failed(val message: String) : SyncResult
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the full sync. Safe to call from any coroutine — does its own dispatching.
|
||||
*/
|
||||
suspend fun sync(): SyncResult = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
// 1. Fetch fresh list from server
|
||||
val dto = MusicNetwork.api.getRecordingList()
|
||||
val serverRevision = dto.revision
|
||||
val serverRecordings = dto.data.mapNotNull { it.toDomainOrNull() }
|
||||
|
||||
// 2. Read our last-known revision
|
||||
val prefs = context.dataStore.data.first()
|
||||
val cachedRevision = prefs[KEY_REVISION] ?: -1
|
||||
|
||||
if (serverRevision == cachedRevision) {
|
||||
Log.d(TAG, "Revision $serverRevision unchanged — cache is current")
|
||||
return@withContext SyncResult.UpToDate
|
||||
}
|
||||
|
||||
Log.d(TAG, "Revision changed: $cachedRevision → $serverRevision — syncing")
|
||||
|
||||
// 3. Build lookup maps for diffing
|
||||
val serverById = serverRecordings.associateBy { it.id }
|
||||
val cachedIds = dao.getAllIds().toSet()
|
||||
val serverIds = serverById.keys
|
||||
|
||||
// 4a. New recordings (on server but not in cache)
|
||||
val addedIds = serverIds - cachedIds
|
||||
// 4b. Removed recordings (in cache but not on server)
|
||||
val removedIds = cachedIds - serverIds
|
||||
// 4c. Recordings that exist in both — check for file-size changes
|
||||
val commonIds = cachedIds.intersect(serverIds)
|
||||
|
||||
val cachedSizes = dao.getIdAndSizes().associate { it.id to it.fileSize }
|
||||
val sizeChangedIds = commonIds.filter { id ->
|
||||
val serverSize = serverById[id]?.fileSize ?: 0L
|
||||
val cachedSize = cachedSizes[id] ?: 0L
|
||||
serverSize != cachedSize
|
||||
}.toSet()
|
||||
|
||||
// 5. Delete removed entries and their files
|
||||
if (removedIds.isNotEmpty()) {
|
||||
deleteFilesForIds(removedIds)
|
||||
dao.deleteByIds(removedIds)
|
||||
Log.d(TAG, "Removed ${removedIds.size} deleted recordings")
|
||||
}
|
||||
|
||||
// 6. Clear cached files for size-changed entries
|
||||
for (id in sizeChangedIds) {
|
||||
deleteFilesForId(id)
|
||||
dao.clearCachedPaths(id)
|
||||
}
|
||||
if (sizeChangedIds.isNotEmpty()) {
|
||||
Log.d(TAG, "${sizeChangedIds.size} recordings had file-size changes — cleared cache")
|
||||
}
|
||||
|
||||
// 7. Upsert ALL server recordings into the database.
|
||||
// For entries that already exist and haven't changed size,
|
||||
// we preserve their cached file paths.
|
||||
val entitiesToUpsert = serverRecordings.map { recording ->
|
||||
val existingEntity = if (recording.id in commonIds && recording.id !in sizeChangedIds) {
|
||||
dao.getById(recording.id)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
recording.toEntity(
|
||||
cachedAudioPath = existingEntity?.cachedAudioPath,
|
||||
cachedMidiPath = existingEntity?.cachedMidiPath
|
||||
)
|
||||
}
|
||||
dao.upsert(entitiesToUpsert)
|
||||
|
||||
// 8. Store the new revision
|
||||
context.dataStore.edit { it[KEY_REVISION] = serverRevision }
|
||||
|
||||
Log.d(TAG, "Sync complete: +${addedIds.size} -${removedIds.size} ~${sizeChangedIds.size}")
|
||||
|
||||
SyncResult.Updated(
|
||||
added = addedIds.size,
|
||||
removed = removedIds.size,
|
||||
sizeChanged = sizeChangedIds.size
|
||||
)
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Sync failed", e)
|
||||
SyncResult.Failed(e.message ?: e.toString())
|
||||
}
|
||||
}
|
||||
|
||||
/** Get the stored revision, or -1 if we've never synced. */
|
||||
suspend fun getCachedRevision(): Int {
|
||||
val prefs = context.dataStore.data.first()
|
||||
return prefs[KEY_REVISION] ?: -1
|
||||
}
|
||||
|
||||
/** True if we have any cached recordings at all (i.e. not first launch). */
|
||||
suspend fun hasCachedData(): Boolean = dao.count() > 0
|
||||
|
||||
// ── File cleanup helpers ───────────────────────────────────────────
|
||||
|
||||
private suspend fun deleteFilesForIds(ids: Set<String>) {
|
||||
for (id in ids) {
|
||||
deleteFilesForId(id)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun deleteFilesForId(id: String) {
|
||||
val entity = dao.getById(id) ?: return
|
||||
entity.cachedAudioPath?.let { File(it).delete() }
|
||||
entity.cachedMidiPath?.let { File(it).delete() }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
package com.keylightpiano.pianotimeline.data.local
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Database
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
|
||||
/**
|
||||
* The Room database. version = 1 for our first schema.
|
||||
* If we ever change RecordingEntity's columns, we bump the version
|
||||
* and provide a migration (or fallbackToDestructiveMigration for dev).
|
||||
*
|
||||
* The companion object provides a thread-safe singleton via Double-Checked Locking.
|
||||
* This is the standard Room pattern — you want exactly one database instance per app.
|
||||
*/
|
||||
@Database(entities = [RecordingEntity::class], version = 1, exportSchema = false)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
|
||||
abstract fun recordingDao(): RecordingDao
|
||||
|
||||
companion object {
|
||||
@Volatile
|
||||
private var INSTANCE: AppDatabase? = null
|
||||
|
||||
fun getInstance(context: Context): AppDatabase {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
INSTANCE ?: Room.databaseBuilder(
|
||||
context.applicationContext,
|
||||
AppDatabase::class.java,
|
||||
"piano_timeline.db"
|
||||
)
|
||||
.fallbackToDestructiveMigration() // fine during dev; revisit for production
|
||||
.build()
|
||||
.also { INSTANCE = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
package com.keylightpiano.pianotimeline.data.local
|
||||
|
||||
import com.keylightpiano.pianotimeline.domain.Recording
|
||||
import java.time.LocalDate
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
private val DATE_FORMAT: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy")
|
||||
|
||||
/**
|
||||
* Entity → Domain. Used when reading from the cache to give the UI clean types.
|
||||
* Returns null if the date is somehow corrupted (shouldn't happen if we wrote it,
|
||||
* but defensive coding never hurt).
|
||||
*/
|
||||
fun RecordingEntity.toDomainOrNull(): Recording? {
|
||||
val parsedDate = try {
|
||||
LocalDate.parse(date.trim(), DATE_FORMAT)
|
||||
} catch (e: Exception) {
|
||||
return null
|
||||
}
|
||||
|
||||
return Recording(
|
||||
id = id,
|
||||
date = parsedDate,
|
||||
artist = artist,
|
||||
title = title,
|
||||
extraInfo = extraInfo,
|
||||
performanceNumber = performanceNumber,
|
||||
totalPerformances = totalPerformances,
|
||||
fileName = fileName,
|
||||
midiFileName = midiFileName,
|
||||
durationSeconds = durationSeconds,
|
||||
soundStartSeconds = soundStartSeconds,
|
||||
fileSize = fileSize
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain → Entity. Used when storing fresh server data into Room.
|
||||
* cachedAudioPath/cachedMidiPath default to null (not yet downloaded).
|
||||
*/
|
||||
fun Recording.toEntity(
|
||||
cachedAudioPath: String? = null,
|
||||
cachedMidiPath: String? = null
|
||||
): RecordingEntity = RecordingEntity(
|
||||
id = id,
|
||||
date = date.format(DATE_FORMAT),
|
||||
artist = artist,
|
||||
title = title,
|
||||
extraInfo = extraInfo,
|
||||
performanceNumber = performanceNumber,
|
||||
totalPerformances = totalPerformances,
|
||||
fileName = fileName,
|
||||
midiFileName = midiFileName,
|
||||
durationSeconds = durationSeconds,
|
||||
soundStartSeconds = soundStartSeconds,
|
||||
fileSize = fileSize,
|
||||
cachedAudioPath = cachedAudioPath,
|
||||
cachedMidiPath = cachedMidiPath
|
||||
)
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
package com.keylightpiano.pianotimeline.data.local
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/**
|
||||
* DAO = Data Access Object. Room generates the SQL implementation at compile time
|
||||
* from these annotated functions. Every function is either suspend (one-shot)
|
||||
* or returns a Flow (live-updating stream).
|
||||
*
|
||||
* Convention: suspend for writes and one-shot reads, Flow for reads the UI observes.
|
||||
*/
|
||||
@Dao
|
||||
interface RecordingDao {
|
||||
|
||||
// ── Reads the UI will observe ──────────────────────────────────────
|
||||
|
||||
/** All recordings, newest first. Returns a Flow so the list auto-updates. */
|
||||
@Query("SELECT * FROM recordings ORDER BY " +
|
||||
"substr(date, 7, 4) DESC, " + // year
|
||||
"substr(date, 4, 2) DESC, " + // month
|
||||
"substr(date, 1, 2) DESC") // day
|
||||
fun observeAllByDate(): Flow<List<RecordingEntity>>
|
||||
|
||||
// ── One-shot reads for sync logic ──────────────────────────────────
|
||||
|
||||
/** All IDs currently in the cache. Used to diff against the fresh server list. */
|
||||
@Query("SELECT id FROM recordings")
|
||||
suspend fun getAllIds(): List<String>
|
||||
|
||||
/** IDs paired with their file sizes. Used to detect changed files. */
|
||||
@Query("SELECT id, fileSize FROM recordings")
|
||||
suspend fun getIdAndSizes(): List<IdAndSize>
|
||||
|
||||
/** Get a single recording by ID. */
|
||||
@Query("SELECT * FROM recordings WHERE id = :id")
|
||||
suspend fun getById(id: String): RecordingEntity?
|
||||
|
||||
// ── Writes ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Insert or replace recordings. REPLACE means: if a row with the same
|
||||
* primary key (id) already exists, delete it and insert the new one.
|
||||
* This is the simplest upsert strategy and works perfectly for our sync
|
||||
* because the new row carries the fresh metadata from the server.
|
||||
*
|
||||
* IMPORTANT: REPLACE deletes the old row first, which means cachedAudioPath
|
||||
* and cachedMidiPath would be lost. The sync logic must preserve those
|
||||
* fields explicitly (copy them from the old row onto the new entity before
|
||||
* inserting). See SyncManager for how this is handled.
|
||||
*/
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsert(recordings: List<RecordingEntity>)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsert(recording: RecordingEntity)
|
||||
|
||||
/** Delete specific recordings by ID. Used when the server removed entries. */
|
||||
@Query("DELETE FROM recordings WHERE id IN (:ids)")
|
||||
suspend fun deleteByIds(ids: Set<String>)
|
||||
|
||||
/** Nuke cached file paths for a recording (e.g. file-size changed, must re-download). */
|
||||
@Query("UPDATE recordings SET cachedAudioPath = NULL, cachedMidiPath = NULL WHERE id = :id")
|
||||
suspend fun clearCachedPaths(id: String)
|
||||
|
||||
/** Set the cached audio path after a successful download. */
|
||||
@Query("UPDATE recordings SET cachedAudioPath = :path WHERE id = :id")
|
||||
suspend fun setCachedAudioPath(id: String, path: String)
|
||||
|
||||
/** Set the cached MIDI path after a successful download. */
|
||||
@Query("UPDATE recordings SET cachedMidiPath = :path WHERE id = :id")
|
||||
suspend fun setCachedMidiPath(id: String, path: String)
|
||||
|
||||
/** Clear ALL cached file paths. Used by the "clear cache" setting. */
|
||||
@Query("UPDATE recordings SET cachedAudioPath = NULL, cachedMidiPath = NULL")
|
||||
suspend fun clearAllCachedPaths()
|
||||
|
||||
/** Total number of recordings. Handy for the UI or debugging. */
|
||||
@Query("SELECT COUNT(*) FROM recordings")
|
||||
suspend fun count(): Int
|
||||
}
|
||||
|
||||
/** Lightweight projection for the file-size diff check. */
|
||||
data class IdAndSize(val id: String, val fileSize: Long)
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
package com.keylightpiano.pianotimeline.data.local
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
/**
|
||||
* One row in the Room database = one recording's metadata + cache state.
|
||||
*
|
||||
* Room needs an @Entity class to know what columns the table has.
|
||||
* Each `val` becomes a column. Room handles String, Int, Long, Double natively.
|
||||
* It does NOT know LocalDate, so we store the date as a String ("dd.MM.yyyy")
|
||||
* and parse it when mapping back to the domain model.
|
||||
*
|
||||
* The two nullable path fields track whether we've downloaded the actual files.
|
||||
* null = not cached yet. Non-null = file lives at that path on disk.
|
||||
*/
|
||||
@Entity(tableName = "recordings")
|
||||
data class RecordingEntity(
|
||||
@PrimaryKey val id: String, // MD5 of filename — stable key
|
||||
val date: String, // stored as "dd.MM.yyyy", parsed on read
|
||||
val artist: String,
|
||||
val title: String,
|
||||
val extraInfo: String,
|
||||
val performanceNumber: Int,
|
||||
val totalPerformances: Int,
|
||||
val fileName: String, // raw filename (not URL-encoded)
|
||||
val midiFileName: String?, // null = no MIDI file exists for this piece
|
||||
val durationSeconds: Double,
|
||||
val soundStartSeconds: Double,
|
||||
val fileSize: Long, // from list.json — used for change detection
|
||||
val cachedAudioPath: String? = null, // null = not downloaded yet
|
||||
val cachedMidiPath: String? = null // null = not downloaded yet
|
||||
)
|
||||
Loading…
Reference in a new issue