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

ServersViewModel.kt

package dev.stave.ui.servers

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dev.stave.domain.model.MediaServer
import dev.stave.upnp.ContentDirectoryClient
import dev.stave.upnp.MinimServerControl
import dev.stave.upnp.UpnpServiceHolder
import kotlinx.coroutines.Job
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull

/** Per-server lifecycle of a MinimServer library rescan. Ported from Earhorn
 *  Android so both products show the same states and copy. */
sealed class RescanState {
    data object Idle : RescanState()
    /** POST in flight — `rescan` sent, not yet acknowledged. */
    data class Requesting(val udn: String, val friendlyName: String) : RescanState()
    /** Accepted; watching the registry for the server to leave (rescan
     *  started) and come back (rescan finished). */
    data class WaitingForServer(
        val udn: String,
        val friendlyName: String,
        val seenDisappear: Boolean,
    ) : RescanState()
    /** Server came back. Held briefly so the UI can show success. */
    data class Completed(val udn: String, val friendlyName: String) : RescanState()
    data class Failed(val udn: String, val friendlyName: String, val message: String) : RescanState()

    /** True while a rescan is actually running (not just showing a result). */
    val inFlight: Boolean get() = this is Requesting || this is WaitingForServer

    /** Plain-language line for a status area / toast. Same copy as Android. */
    val description: String?
        get() = when (this) {
            Idle -> null
            is Requesting -> "Asking $friendlyName to rescan its library…"
            is WaitingForServer ->
                if (seenDisappear) "Rescanning $friendlyName — server restarting…"
                else "Waiting for $friendlyName to start rescanning…"
            is Completed -> "Rescan complete — $friendlyName library refreshed"
            is Failed -> "Rescan of $friendlyName failed: $message"
        }
}

/**
 * Exposes the live list of discovered MediaServers, an SSDP rescan trigger, and
 * (new, parity with Android) a MinimServer **library** rescan with observable
 * progress. Thin wrapper around [UpnpServiceHolder] so the UI doesn't import
 * UPnP types.
 */
class ServersViewModel(
    private val holder: UpnpServiceHolder,
    private val cd: ContentDirectoryClient,
    private val minim: MinimServerControl,
) : ViewModel() {

    val servers: StateFlow<List<MediaServer>> = holder.servers

    private val _rescanState = MutableStateFlow<RescanState>(RescanState.Idle)
    val rescanState: StateFlow<RescanState> = _rescanState.asStateFlow()

    private var rescanJob: Job? = null

    /** The last finished rescan outcome the UI has already announced. The
     *  outcome is held on screen for a few seconds; without this, a toolbar
     *  that re-enters composition during that hold would announce it (and
     *  reset browse) twice. Identity, not equality: two rescans of the same
     *  server produce equal-but-distinct states. */
    private var announcedOutcome: RescanState? = null

    /** True exactly once per finished (Completed/Failed) rescan outcome. */
    @Synchronized
    fun claimOutcome(state: RescanState): Boolean {
        if (state !is RescanState.Completed && state !is RescanState.Failed) return false
        if (state === announcedOutcome) return false
        announcedOutcome = state
        return true
    }

    /** Force an SSDP M-SEARCH ("Refresh devices"). Does not touch the server's
     *  library — see [rescanLibrary] for that. Also restarts the
     *  [searchSettled] window, so the empty state says "Searching…" again. */
    fun rescan() {
        holder.rescan()
        searchRound.value++
    }

    private val searchRound = MutableStateFlow(0)

    /**
     * False for [SEARCH_SETTLE_MS] after launch and after each [rescan], then
     * true. With no servers, the UI shows "Searching…" while this is false and
     * "No servers found" after (Android ServersScreen; Android: an endless
     * "Searching…" is a dead screen).
     */
    @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
    val searchSettled: StateFlow<Boolean> = searchRound
        .transformLatest {
            emit(false)
            delay(SEARCH_SETTLE_MS)
            emit(true)
        }
        .stateIn(viewModelScope, SharingStarted.Eagerly, false)

    /** Ask the MinimServer at [udn] to re-index its library, then follow the
     *  restart cycle: Requesting → WaitingForServer(seenDisappear=false) →
     *  WaitingForServer(true) → Completed (held ~4 s) → Idle, or Failed
     *  (held ~4 s) → Idle. No-op while another rescan is in flight. */
    fun rescanLibrary(udn: String) {
        if (_rescanState.value.inFlight) return
        val friendlyName = holder.servers.value.firstOrNull { it.udn == udn }?.friendlyName ?: udn
        rescanJob?.cancel()
        rescanJob = viewModelScope.launch {
            _rescanState.value = RescanState.Requesting(udn, friendlyName)
            when (val r = minim.rescanLibrary(udn)) {
                is MinimServerControl.Result.Failure -> fail(udn, friendlyName, r.message)
                MinimServerControl.Result.Success -> awaitServerCycle(udn, friendlyName)
            }
        }
    }

    private suspend fun fail(udn: String, friendlyName: String, message: String) {
        _rescanState.value = RescanState.Failed(udn, friendlyName, message)
        delay(FAILURE_HOLD_MS)
        if (_rescanState.value is RescanState.Failed) _rescanState.value = RescanState.Idle
    }

    private suspend fun awaitServerCycle(udn: String, friendlyName: String) {
        _rescanState.value = RescanState.WaitingForServer(udn, friendlyName, seenDisappear = false)

        // Phase 1: the server should drop out of the registry within seconds.
        val disappeared = withTimeoutOrNull(DISAPPEAR_TIMEOUT_MS) {
            holder.servers.first { list -> list.none { it.udn == udn } }
        }
        if (disappeared == null) {
            fail(udn, friendlyName, "the server didn't restart, so the rescan may not have happened")
            return
        }
        _rescanState.value = RescanState.WaitingForServer(udn, friendlyName, seenDisappear = true)

        // Phase 2: wait for it to come back, nudging SSDP every few seconds —
        // and KEEP nudging through the Completed hold: jUPnP's grip on a
        // freshly-restarted MinimServer is fragile and it can expire the
        // device again before the server's own NOTIFY heartbeat arrives.
        val reappeared = coroutineScope {
            val ssdpJob = launch {
                while (true) {
                    holder.rescan()
                    delay(SSDP_NUDGE_INTERVAL_MS)
                }
            }
            try {
                val r = withTimeoutOrNull(REAPPEAR_TIMEOUT_MS) {
                    holder.servers.first { list -> list.any { it.udn == udn } }
                } ?: return@coroutineScope null
                // Library changed — the random pool is stale. Browse caches
                // refetch lazily on next browse.
                cd.invalidateRandomCache()
                _rescanState.value = RescanState.Completed(udn, friendlyName)
                delay(COMPLETED_HOLD_MS)
                if (_rescanState.value is RescanState.Completed) _rescanState.value = RescanState.Idle
                r
            } finally {
                ssdpJob.cancel()
            }
        }
        if (reappeared == null) {
            fail(udn, friendlyName, "the server didn't come back after 10 minutes — try Refresh devices")
        }
    }

    companion object {
        private const val COMPLETED_HOLD_MS = 4_000L
        private const val FAILURE_HOLD_MS = 4_000L
        private const val DISAPPEAR_TIMEOUT_MS = 30_000L
        private const val REAPPEAR_TIMEOUT_MS = 10 * 60 * 1000L
        private const val SSDP_NUDGE_INTERVAL_MS = 3_000L
        /** Android's empty-state grace before "No servers found". */
        private const val SEARCH_SETTLE_MS = 6_000L
    }
}