package dev.stave.data
import dev.stave.domain.model.DidlItem
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.io.File
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
/**
* Persists the last playback session to disk so launching Earhorn picks up where the
* user left off. Writes on graceful shutdown; reads once at startup.
*
* Stored fields:
* - Queue tracks (full [DidlItem.Track] records so we don't need the server to
* re-resolve them by id — URLs are the source of truth)
* - Queue index — which track was currently loaded
* - Position — how far into that track (restored on resume, approximately — libvlc
* seek accuracy depends on the codec)
*
* Not stored: playback state (play vs pause). Resume always starts paused so the
* user isn't surprised by music blasting the moment they open the app. They press
* play themselves if they want to continue.
*
* Location: `$XDG_DATA_HOME/earhorn/session.json` (falls back to
* `~/.local/share/earhorn/session.json`), owner-only (0600) since the queue is a
* listening record and stream URLs may carry server tokens. Same base dir as the playlist library — both
* are "user data" in XDG terms.
*
* **Storage format.** JSON via kotlinx.serialization. The track list can be dozens
* of entries deep with arbitrary extras maps, so Properties-file shape doesn't fit
* like it does for SettingsStore. File is a few KB at most.
*/
class SessionStore(
dataDir: File = StavePaths.dataDir,
) {
private val sessionFile: File = File(dataDir, "session.json").also {
StavePaths.ensurePrivateDir(dataDir)
StavePaths.deleteStaleTemps(dataDir, TMP_PREFIX)
StavePaths.restrictToOwner(it)
}
/** One save at a time, so a slow save can't land after (and undo) a newer one. */
private val saveLock = Any()
private val json = Json {
ignoreUnknownKeys = true
// Pretty-print for the sake of the user who greps this file while debugging.
prettyPrint = true
}
/**
* Read the previous session from disk, or null if there's nothing saved / the
* file is corrupt. Called once from StaveApp at startup.
*
* Runs on [Dispatchers.IO] — file size is small but disk latency still happens.
*/
suspend fun load(): StoredSession? = withContext(Dispatchers.IO) {
if (!sessionFile.exists()) return@withContext null
runCatching {
val env = json.decodeFromString<SessionEnvelope>(sessionFile.readText(Charsets.UTF_8))
env.toStoredSession()
}.getOrNull()
}
/**
* Persist the current session. Called on graceful shutdown (Quit menu item,
* window close). An empty queue wipes the file — useful if the user cleared
* everything and expects resume-on-launch to start from scratch.
*/
suspend fun save(session: StoredSession): Unit = withContext(Dispatchers.IO) {
synchronized(saveLock) {
try {
if (session.queue.isEmpty()) {
sessionFile.delete()
return@synchronized
}
val text = json.encodeToString(SessionEnvelope.serializer(), SessionEnvelope.from(session))
StavePaths.writeAtomically(sessionFile, TMP_PREFIX) { out ->
out.write(text.toByteArray(Charsets.UTF_8))
}
} catch (e: java.io.IOException) {
System.err.println("[Earhorn/Session] save failed: ${e.message}")
} catch (e: kotlinx.serialization.SerializationException) {
System.err.println("[Earhorn/Session] save failed: ${e.message}")
}
}
}
// ---- Serialization envelope --------------------------------------------------
//
// kotlinx.serialization can't directly encode DidlItem.Track because Duration
// isn't a primitive. Envelope transforms to/from plain-Long millis.
@Serializable
private data class SessionEnvelope(
@SerialName("queue") val queue: List<StoredTrack>,
@SerialName("queueIndex") val queueIndex: Int,
@SerialName("positionMs") val positionMs: Long,
) {
fun toStoredSession(): StoredSession = StoredSession(
queue = queue.map { it.toDidlTrack() },
queueIndex = queueIndex,
position = positionMs.milliseconds,
)
companion object {
fun from(s: StoredSession): SessionEnvelope = SessionEnvelope(
queue = s.queue.map { StoredTrack.from(it) },
queueIndex = s.queueIndex,
positionMs = s.position.inWholeMilliseconds,
)
}
}
@Serializable
private data class StoredTrack(
val id: String,
val parentId: String,
val title: String,
val artist: String? = null,
val albumArtist: String? = null,
val album: String? = null,
val composer: String? = null,
val genre: String? = null,
val date: String? = null,
val trackNumber: Int? = null,
val discNumber: Int? = null,
val durationMs: Long? = null,
val albumArtUri: String? = null,
val resourceUrl: String,
val mimeType: String? = null,
val sampleFrequency: Int? = null,
val bitsPerSample: Int? = null,
val channels: Int? = null,
val bitrate: Int? = null,
val size: Long? = null,
val extras: Map<String, String> = emptyMap(),
) {
fun toDidlTrack(): DidlItem.Track = DidlItem.Track(
id = id,
parentId = parentId,
title = title,
artist = artist,
albumArtist = albumArtist,
album = album,
composer = composer,
genre = genre,
date = date,
trackNumber = trackNumber,
discNumber = discNumber,
duration = durationMs?.milliseconds,
albumArtUri = albumArtUri,
resourceUrl = resourceUrl,
mimeType = mimeType,
sampleFrequency = sampleFrequency,
bitsPerSample = bitsPerSample,
channels = channels,
bitrate = bitrate,
size = size,
extras = extras,
)
companion object {
fun from(t: DidlItem.Track): StoredTrack = StoredTrack(
id = t.id,
parentId = t.parentId,
title = t.title,
artist = t.artist,
albumArtist = t.albumArtist,
album = t.album,
composer = t.composer,
genre = t.genre,
date = t.date,
trackNumber = t.trackNumber,
discNumber = t.discNumber,
durationMs = t.duration?.inWholeMilliseconds,
albumArtUri = t.albumArtUri,
resourceUrl = t.resourceUrl,
mimeType = t.mimeType,
sampleFrequency = t.sampleFrequency,
bitsPerSample = t.bitsPerSample,
channels = t.channels,
bitrate = t.bitrate,
size = t.size,
extras = t.extras,
)
}
}
companion object {
private const val TMP_PREFIX = "session.json."
}
}
/** Snapshot of a playback session suitable for persisting. */
data class StoredSession(
val queue: List<DidlItem.Track>,
val queueIndex: Int,
val position: Duration,
)