SessionStore.kt
204 lines · 7.7 KB
Changed in: 0.5.0
| 1 | package dev.stave.data |
| 2 | |
| 3 | import dev.stave.domain.model.DidlItem |
| 4 | import kotlinx.coroutines.Dispatchers |
| 5 | import kotlinx.coroutines.withContext |
| 6 | import kotlinx.serialization.SerialName |
| 7 | import kotlinx.serialization.Serializable |
| 8 | import kotlinx.serialization.json.Json |
| 9 | import java.io.File |
| 10 | import kotlin.time.Duration |
| 11 | import kotlin.time.Duration.Companion.milliseconds |
| 12 | |
| 13 | /** |
| 14 | * Persists the last playback session to disk so launching Earhorn picks up where the |
| 15 | * user left off. Writes on graceful shutdown; reads once at startup. |
| 16 | * |
| 17 | * Stored fields: |
| 18 | * - Queue tracks (full [DidlItem.Track] records so we don't need the server to |
| 19 | * re-resolve them by id — URLs are the source of truth) |
| 20 | * - Queue index — which track was currently loaded |
| 21 | * - Position — how far into that track (restored on resume, approximately — libvlc |
| 22 | * seek accuracy depends on the codec) |
| 23 | * |
| 24 | * Not stored: playback state (play vs pause). Resume always starts paused so the |
| 25 | * user isn't surprised by music blasting the moment they open the app. They press |
| 26 | * play themselves if they want to continue. |
| 27 | * |
| 28 | * Location: `$XDG_DATA_HOME/earhorn/session.json` (falls back to |
| 29 | * `~/.local/share/earhorn/session.json`), owner-only (0600) since the queue is a |
| 30 | * listening record and stream URLs may carry server tokens. Same base dir as the playlist library — both |
| 31 | * are "user data" in XDG terms. |
| 32 | * |
| 33 | * **Storage format.** JSON via kotlinx.serialization. The track list can be dozens |
| 34 | * of entries deep with arbitrary extras maps, so Properties-file shape doesn't fit |
| 35 | * like it does for SettingsStore. File is a few KB at most. |
| 36 | */ |
| 37 | class SessionStore( |
| 38 | dataDir: File = StavePaths.dataDir, |
| 39 | ) { |
| 40 | private val sessionFile: File = File(dataDir, "session.json").also { |
| 41 | StavePaths.ensurePrivateDir(dataDir) |
| 42 | StavePaths.deleteStaleTemps(dataDir, TMP_PREFIX) |
| 43 | StavePaths.restrictToOwner(it) |
| 44 | } |
| 45 | |
| 46 | /** One save at a time, so a slow save can't land after (and undo) a newer one. */ |
| 47 | private val saveLock = Any() |
| 48 | |
| 49 | private val json = Json { |
| 50 | ignoreUnknownKeys = true |
| 51 | // Pretty-print for the sake of the user who greps this file while debugging. |
| 52 | prettyPrint = true |
| 53 | } |
| 54 | |
| 55 | /** |
| 56 | * Read the previous session from disk, or null if there's nothing saved / the |
| 57 | * file is corrupt. Called once from StaveApp at startup. |
| 58 | * |
| 59 | * Runs on [Dispatchers.IO] — file size is small but disk latency still happens. |
| 60 | */ |
| 61 | suspend fun load(): StoredSession? = withContext(Dispatchers.IO) { |
| 62 | if (!sessionFile.exists()) return@withContext null |
| 63 | runCatching { |
| 64 | val env = json.decodeFromString<SessionEnvelope>(sessionFile.readText(Charsets.UTF_8)) |
| 65 | env.toStoredSession() |
| 66 | }.getOrNull() |
| 67 | } |
| 68 | |
| 69 | /** |
| 70 | * Persist the current session. Called on graceful shutdown (Quit menu item, |
| 71 | * window close). An empty queue wipes the file — useful if the user cleared |
| 72 | * everything and expects resume-on-launch to start from scratch. |
| 73 | */ |
| 74 | suspend fun save(session: StoredSession): Unit = withContext(Dispatchers.IO) { |
| 75 | synchronized(saveLock) { |
| 76 | try { |
| 77 | if (session.queue.isEmpty()) { |
| 78 | sessionFile.delete() |
| 79 | return@synchronized |
| 80 | } |
| 81 | val text = json.encodeToString(SessionEnvelope.serializer(), SessionEnvelope.from(session)) |
| 82 | StavePaths.writeAtomically(sessionFile, TMP_PREFIX) { out -> |
| 83 | out.write(text.toByteArray(Charsets.UTF_8)) |
| 84 | } |
| 85 | } catch (e: java.io.IOException) { |
| 86 | System.err.println("[Earhorn/Session] save failed: ${e.message}") |
| 87 | } catch (e: kotlinx.serialization.SerializationException) { |
| 88 | System.err.println("[Earhorn/Session] save failed: ${e.message}") |
| 89 | } |
| 90 | } |
| 91 | } |
| 92 | |
| 93 | // ---- Serialization envelope -------------------------------------------------- |
| 94 | // |
| 95 | // kotlinx.serialization can't directly encode DidlItem.Track because Duration |
| 96 | // isn't a primitive. Envelope transforms to/from plain-Long millis. |
| 97 | |
| 98 | @Serializable |
| 99 | private data class SessionEnvelope( |
| 100 | @SerialName("queue") val queue: List<StoredTrack>, |
| 101 | @SerialName("queueIndex") val queueIndex: Int, |
| 102 | @SerialName("positionMs") val positionMs: Long, |
| 103 | ) { |
| 104 | fun toStoredSession(): StoredSession = StoredSession( |
| 105 | queue = queue.map { it.toDidlTrack() }, |
| 106 | queueIndex = queueIndex, |
| 107 | position = positionMs.milliseconds, |
| 108 | ) |
| 109 | |
| 110 | companion object { |
| 111 | fun from(s: StoredSession): SessionEnvelope = SessionEnvelope( |
| 112 | queue = s.queue.map { StoredTrack.from(it) }, |
| 113 | queueIndex = s.queueIndex, |
| 114 | positionMs = s.position.inWholeMilliseconds, |
| 115 | ) |
| 116 | } |
| 117 | } |
| 118 | |
| 119 | @Serializable |
| 120 | private data class StoredTrack( |
| 121 | val id: String, |
| 122 | val parentId: String, |
| 123 | val title: String, |
| 124 | val artist: String? = null, |
| 125 | val albumArtist: String? = null, |
| 126 | val album: String? = null, |
| 127 | val composer: String? = null, |
| 128 | val genre: String? = null, |
| 129 | val date: String? = null, |
| 130 | val trackNumber: Int? = null, |
| 131 | val discNumber: Int? = null, |
| 132 | val durationMs: Long? = null, |
| 133 | val albumArtUri: String? = null, |
| 134 | val resourceUrl: String, |
| 135 | val mimeType: String? = null, |
| 136 | val sampleFrequency: Int? = null, |
| 137 | val bitsPerSample: Int? = null, |
| 138 | val channels: Int? = null, |
| 139 | val bitrate: Int? = null, |
| 140 | val size: Long? = null, |
| 141 | val extras: Map<String, String> = emptyMap(), |
| 142 | ) { |
| 143 | fun toDidlTrack(): DidlItem.Track = DidlItem.Track( |
| 144 | id = id, |
| 145 | parentId = parentId, |
| 146 | title = title, |
| 147 | artist = artist, |
| 148 | albumArtist = albumArtist, |
| 149 | album = album, |
| 150 | composer = composer, |
| 151 | genre = genre, |
| 152 | date = date, |
| 153 | trackNumber = trackNumber, |
| 154 | discNumber = discNumber, |
| 155 | duration = durationMs?.milliseconds, |
| 156 | albumArtUri = albumArtUri, |
| 157 | resourceUrl = resourceUrl, |
| 158 | mimeType = mimeType, |
| 159 | sampleFrequency = sampleFrequency, |
| 160 | bitsPerSample = bitsPerSample, |
| 161 | channels = channels, |
| 162 | bitrate = bitrate, |
| 163 | size = size, |
| 164 | extras = extras, |
| 165 | ) |
| 166 | |
| 167 | companion object { |
| 168 | fun from(t: DidlItem.Track): StoredTrack = StoredTrack( |
| 169 | id = t.id, |
| 170 | parentId = t.parentId, |
| 171 | title = t.title, |
| 172 | artist = t.artist, |
| 173 | albumArtist = t.albumArtist, |
| 174 | album = t.album, |
| 175 | composer = t.composer, |
| 176 | genre = t.genre, |
| 177 | date = t.date, |
| 178 | trackNumber = t.trackNumber, |
| 179 | discNumber = t.discNumber, |
| 180 | durationMs = t.duration?.inWholeMilliseconds, |
| 181 | albumArtUri = t.albumArtUri, |
| 182 | resourceUrl = t.resourceUrl, |
| 183 | mimeType = t.mimeType, |
| 184 | sampleFrequency = t.sampleFrequency, |
| 185 | bitsPerSample = t.bitsPerSample, |
| 186 | channels = t.channels, |
| 187 | bitrate = t.bitrate, |
| 188 | size = t.size, |
| 189 | extras = t.extras, |
| 190 | ) |
| 191 | } |
| 192 | } |
| 193 | |
| 194 | companion object { |
| 195 | private const val TMP_PREFIX = "session.json." |
| 196 | } |
| 197 | } |
| 198 | |
| 199 | /** Snapshot of a playback session suitable for persisting. */ |
| 200 | data class StoredSession( |
| 201 | val queue: List<DidlItem.Track>, |
| 202 | val queueIndex: Int, |
| 203 | val position: Duration, |
| 204 | ) |


