diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml
index ca16a99..ed7f619 100644
--- a/.idea/deploymentTargetSelector.xml
+++ b/.idea/deploymentTargetSelector.xml
@@ -4,6 +4,13 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/keylightpiano/pianotimeline/TestViewModel.kt b/app/src/main/java/com/keylightpiano/pianotimeline/TestViewModel.kt
index 6e4279c..d2f1495 100644
--- a/app/src/main/java/com/keylightpiano/pianotimeline/TestViewModel.kt
+++ b/app/src/main/java/com/keylightpiano/pianotimeline/TestViewModel.kt
@@ -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.Loading)
val state: StateFlow = _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()
- _state.value = FetchState.Success(
- revision = result.revision,
- recordings = result.recordings
- )
+ // One-shot collect to get current list for the test screen
+ repo.observeRecordings().collect { recordings ->
+ _state.value = FetchState.Success(
+ revision = syncManager.getCachedRevision(),
+ recordings = recordings
+ )
+ }
} catch (e: Exception) {
_state.value = FetchState.Error(e.message ?: e.toString())
}
@@ -42,9 +43,8 @@ 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) : FetchState
data class Error(val message: String) : FetchState
-}
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/keylightpiano/pianotimeline/data/MusicRepository.kt b/app/src/main/java/com/keylightpiano/pianotimeline/data/MusicRepository.kt
index 0021043..e9846c9 100644
--- a/app/src/main/java/com/keylightpiano/pianotimeline/data/MusicRepository.kt
+++ b/app/src/main/java/com/keylightpiano/pianotimeline/data/MusicRepository.kt
@@ -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
-)
+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> =
+ 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() }
}
}
diff --git a/app/src/main/java/com/keylightpiano/pianotimeline/data/SyncManager.kt b/app/src/main/java/com/keylightpiano/pianotimeline/data/SyncManager.kt
new file mode 100644
index 0000000..9c6e31c
--- /dev/null
+++ b/app/src/main/java/com/keylightpiano/pianotimeline/data/SyncManager.kt
@@ -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 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) {
+ 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() }
+ }
+}
diff --git a/app/src/main/java/com/keylightpiano/pianotimeline/data/local/AppDatabase.kt b/app/src/main/java/com/keylightpiano/pianotimeline/data/local/AppDatabase.kt
new file mode 100644
index 0000000..c34e24d
--- /dev/null
+++ b/app/src/main/java/com/keylightpiano/pianotimeline/data/local/AppDatabase.kt
@@ -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 }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/keylightpiano/pianotimeline/data/local/EntityMappers.kt b/app/src/main/java/com/keylightpiano/pianotimeline/data/local/EntityMappers.kt
new file mode 100644
index 0000000..7e8b80a
--- /dev/null
+++ b/app/src/main/java/com/keylightpiano/pianotimeline/data/local/EntityMappers.kt
@@ -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
+)
diff --git a/app/src/main/java/com/keylightpiano/pianotimeline/data/local/RecordingDao.kt b/app/src/main/java/com/keylightpiano/pianotimeline/data/local/RecordingDao.kt
new file mode 100644
index 0000000..79e0e52
--- /dev/null
+++ b/app/src/main/java/com/keylightpiano/pianotimeline/data/local/RecordingDao.kt
@@ -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>
+
+ // ── 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
+
+ /** IDs paired with their file sizes. Used to detect changed files. */
+ @Query("SELECT id, fileSize FROM recordings")
+ suspend fun getIdAndSizes(): List
+
+ /** 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)
+
+ @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)
+
+ /** 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)
diff --git a/app/src/main/java/com/keylightpiano/pianotimeline/data/local/RecordingEntity.kt b/app/src/main/java/com/keylightpiano/pianotimeline/data/local/RecordingEntity.kt
new file mode 100644
index 0000000..b765502
--- /dev/null
+++ b/app/src/main/java/com/keylightpiano/pianotimeline/data/local/RecordingEntity.kt
@@ -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
+)