EARHORNMUSIC UPnP · Android TV · Phones · Android Auto · GNU/Linux
Release 0.5.0

SessionStore.kt

Each line, with the release that last changed it.

0.5.01package dev.stave.data
2
3import dev.stave.domain.model.DidlItem
4import kotlinx.coroutines.Dispatchers
5import kotlinx.coroutines.withContext
6import kotlinx.serialization.SerialName
7import kotlinx.serialization.Serializable
8import kotlinx.serialization.json.Json
9import java.io.File
10import kotlin.time.Duration
11import 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 */
37class 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. */
200data class StoredSession(
201 val queue: List<DidlItem.Track>,
202 val queueIndex: Int,
203 val position: Duration,
204)