package dev.stave.ui.servers
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dev.stave.data.SettingsStore
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.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import javax.inject.Inject
/** Per-server lifecycle of a MinimServer rescan request. The screen reads this
* flow to decide whether each server row shows its normal Rescan affordance,
* an in-flight spinner, a "waiting for server" message, or a transient
* success/failure indicator before snapping back to Idle. */
sealed class RescanState {
object Idle : RescanState()
/** POST in flight — we've sent `rescan` but the server hasn't yet
* acknowledged with 200 OK. */
data class Requesting(val udn: String, val friendlyName: String) : RescanState()
/** Receiver accepted; we're now watching the UPnP registry for it to
* disappear (rescan started) and reappear (rescan finished). */
data class WaitingForServer(
val udn: String,
val friendlyName: String,
val seenDisappear: Boolean,
) : RescanState()
/** Server came back. Held briefly so the row can flash a check before
* returning to Idle. */
data class Completed(val udn: String, val friendlyName: String) : RescanState()
data class Failed(val udn: String, val friendlyName: String, val message: String) : RescanState()
}
@HiltViewModel
class ServersViewModel @Inject constructor(
private val holder: UpnpServiceHolder,
private val settings: SettingsStore,
private val cd: ContentDirectoryClient,
private val minim: MinimServerControl,
) : ViewModel() {
val servers: StateFlow<List<MediaServer>> = holder.servers
val preferredServerUdn: Flow<String?> = settings.preferredServerUdn
private val _rescanState = MutableStateFlow<RescanState>(RescanState.Idle)
val rescanState: StateFlow<RescanState> = _rescanState.asStateFlow()
/** Tracks the in-flight rescan coroutine so a second tap on the same row
* while one is still running is a no-op rather than starting a parallel
* request. Also lets us cancel if the user navigates away. */
private var rescanJob: Job? = null
/** Called when the user taps a server row — persists the choice so next launch can
* auto-navigate to Browse once the server reappears on the network. */
fun rememberChoice(udn: String) {
viewModelScope.launch { settings.setPreferredServer(udn) }
}
/** Rescan the network for UPnP servers without touching the user's current preference.
* Used when they're already on the picker and want to refresh. */
fun rescan() {
holder.rescan()
}
/** Clear the persisted preferred server AND rescan. Used from Settings → "Choose
* server" when the user wants to switch libraries — this sends them through the
* picker again, and next app launch will hit WELCOME until they pick. Also drops
* any cached random-library list since that list is server-specific. */
fun forgetAndRescan() {
viewModelScope.launch { settings.setPreferredServer(null) }
cd.invalidateRandomCache()
holder.rescan()
}
// Maintainer, 2026-10-29: Settings → Choose server now uses this instead
// of forgetAndRescan — opening the picker no longer deletes the saved
// server; the preference changes only when the user picks one
// (rememberChoice). Random cache dropped as before.
fun rescanForPick() {
cd.invalidateRandomCache()
holder.rescan()
}
/** Trigger a library rescan on the MinimServer at [udn].
*
* Flow of states the UI observes via [rescanState]:
* 1. Idle (initial; also after Completed/Failed snap back)
* 2. Requesting(udn) — POST in flight
* 3. WaitingForServer(udn, seenDisappear=false) — server hasn't yet
* dropped off the registry. Some MinimServer versions take a few
* seconds to actually go silent after acking the rescan command.
* 4. WaitingForServer(udn, seenDisappear=true) — server is gone,
* indexing in progress.
* 5. Completed(udn) — server came back. Held COMPLETED_HOLD_MS so the UI can flash
* a check. Library caches are invalidated at this point.
* 6. Failed(udn, message) on any error. Held ~4s.
*
* No-ops if a rescan is already running (any non-Idle state). */
fun rescanLibrary(udn: String) {
if (_rescanState.value !is RescanState.Idle) return
// Snapshot the friendly name now — once the rescan starts, the
// server will disappear from `holder.servers` and we'd lose the
// ability to label the placeholder row. Fall back to the UDN if
// for some reason the name isn't currently known (shouldn't
// happen since the row only renders Rescan if the server is in
// the list at tap time).
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 -> {
_rescanState.value = RescanState.Failed(udn, friendlyName, r.message)
delay(FAILURE_HOLD_MS)
if (_rescanState.value is RescanState.Failed) {
_rescanState.value = RescanState.Idle
}
}
MinimServerControl.Result.Success -> {
awaitServerCycle(udn, friendlyName)
}
}
}
}
/** Wait for the rescanning server to (a) leave the registry and (b) come
* back. Both transitions are observed via the holder's flow. We also
* fire periodic SSDP M-SEARCHes during the waiting phase to expedite
* re-discovery — MinimServer announces itself via NOTIFY on startup but
* the search shortens the worst-case delay from ~30s to a few seconds. */
private suspend fun awaitServerCycle(udn: String, friendlyName: String) {
_rescanState.value = RescanState.WaitingForServer(udn, friendlyName, seenDisappear = false)
// Phase 1: wait for the server to disappear. Generous timeout — most
// MinimServer rescans drop the registry presence within 10–15s.
val disappeared = withTimeoutOrNull(DISAPPEAR_TIMEOUT_MS) {
holder.servers.firstOrNull { list ->
list.none { it.udn == udn }
}
}
if (disappeared == null) {
// The server never dropped off — likely the rescan didn't
// actually trigger, or this server isn't a real MinimServer.
// Surface as a soft failure rather than wedging the UI.
_rescanState.value = RescanState.Failed(
udn,
friendlyName,
"Server didn't restart — rescan may not have happened",
)
delay(FAILURE_HOLD_MS)
if (_rescanState.value is RescanState.Failed) _rescanState.value = RescanState.Idle
return
}
_rescanState.value = RescanState.WaitingForServer(udn, friendlyName, seenDisappear = true)
// Phase 2: wait for the server to come back. Library rescans can take
// minutes-to-hours for large collections; cap at 10 minutes so the
// UI doesn't sit there forever if the server failed to come back at
// all. Fire periodic SSDP searches to nudge re-discovery — without
// this, the registry waits for the server's own NOTIFY which can be
// slow. coroutineScope gives `launch` a receiver and confines the
// SSDP-nudge job's lifetime to this phase; cancelling it on exit
// lets the scope complete cleanly.
//
// CRITICAL: the SSDP nudge keeps running through the Completed hold
// too (not just until first detection). jUPnP's grip on a
// freshly-restarted MinimServer is fragile — the first SSDP search
// response gets the device into the registry, but if no further
// SSDP traffic arrives before the device's max-age ticks down, the
// registry drops it. The user then sees the placeholder disappear
// followed by an empty list until they manually hit refresh.
// Holding the nudge keeps a fresh M-SEARCH arriving every few
// seconds; the server responds, jUPnP's registry resets its
// expiration timer, and the device stays put until its own NOTIFY
// heartbeats take over.
val reappeared = coroutineScope {
val ssdpJob = launch {
while (true) {
// Nudge first, then delay — without this, the first
// nudge wouldn't fire until SSDP_NUDGE_INTERVAL_MS
// after the loop launches, leaving a quiet window
// where jUPnP can drop the device before any fresh
// SSDP traffic arrives.
holder.rescan()
delay(SSDP_NUDGE_INTERVAL_MS)
}
}
try {
val r = withTimeoutOrNull(REAPPEAR_TIMEOUT_MS) {
holder.servers.firstOrNull { list ->
list.any { it.udn == udn }
}
}
if (r == null) return@coroutineScope null
// Library has changed — invalidate the random-album
// cache so the next Random pick reflects the new
// state. Browse caches are re-fetched lazily on the
// next browse, so no further invalidation is needed.
cd.invalidateRandomCache()
_rescanState.value = RescanState.Completed(udn, friendlyName)
// Hold Completed (UI shows the check) while the SSDP
// nudge above keeps the registry warm.
delay(COMPLETED_HOLD_MS)
if (_rescanState.value is RescanState.Completed) _rescanState.value = RescanState.Idle
r
} finally {
ssdpJob.cancel()
}
}
if (reappeared == null) {
_rescanState.value = RescanState.Failed(
udn,
friendlyName,
"Server didn't come back after rescan (try again from the picker)",
)
delay(FAILURE_HOLD_MS)
if (_rescanState.value is RescanState.Failed) _rescanState.value = RescanState.Idle
return
}
}
companion object {
// Hold transient states briefly so the UI can flash success/failure
// before the row returns to its normal Rescan affordance.
private const val COMPLETED_HOLD_MS = 4_000L
private const val FAILURE_HOLD_MS = 4_000L
// Phase timeouts. Disappear is short (rescan should restart the
// server fast); reappear is long (indexing a big library is slow).
private const val DISAPPEAR_TIMEOUT_MS = 30_000L
private const val REAPPEAR_TIMEOUT_MS = 10 * 60 * 1000L
// How often to fire SSDP M-SEARCH while waiting for the server to
// come back. Tight enough that at least one nudge fires during
// the Completed hold (4s hold > 3s interval) so jUPnP doesn't
// drop the freshly-rediscovered device before its first NOTIFY
// heartbeat arrives.
private const val SSDP_NUDGE_INTERVAL_MS = 3_000L
}
}