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

SessionStore.kt

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,
)