ServersViewModel.kt
249 lines · 12.2 KB
Changed in: 0.5.0
| 1 | package dev.stave.ui.servers |
| 2 | |
| 3 | import androidx.lifecycle.ViewModel |
| 4 | import androidx.lifecycle.viewModelScope |
| 5 | import dagger.hilt.android.lifecycle.HiltViewModel |
| 6 | import dev.stave.data.SettingsStore |
| 7 | import dev.stave.domain.model.MediaServer |
| 8 | import dev.stave.upnp.ContentDirectoryClient |
| 9 | import dev.stave.upnp.MinimServerControl |
| 10 | import dev.stave.upnp.UpnpServiceHolder |
| 11 | import kotlinx.coroutines.Job |
| 12 | import kotlinx.coroutines.coroutineScope |
| 13 | import kotlinx.coroutines.delay |
| 14 | import kotlinx.coroutines.flow.Flow |
| 15 | import kotlinx.coroutines.flow.MutableStateFlow |
| 16 | import kotlinx.coroutines.flow.StateFlow |
| 17 | import kotlinx.coroutines.flow.asStateFlow |
| 18 | import kotlinx.coroutines.flow.firstOrNull |
| 19 | import kotlinx.coroutines.launch |
| 20 | import kotlinx.coroutines.withTimeoutOrNull |
| 21 | import javax.inject.Inject |
| 22 | |
| 23 | /** Per-server lifecycle of a MinimServer rescan request. The screen reads this |
| 24 | * flow to decide whether each server row shows its normal Rescan affordance, |
| 25 | * an in-flight spinner, a "waiting for server" message, or a transient |
| 26 | * success/failure indicator before snapping back to Idle. */ |
| 27 | sealed class RescanState { |
| 28 | object Idle : RescanState() |
| 29 | /** POST in flight — we've sent `rescan` but the server hasn't yet |
| 30 | * acknowledged with 200 OK. */ |
| 31 | data class Requesting(val udn: String, val friendlyName: String) : RescanState() |
| 32 | /** Receiver accepted; we're now watching the UPnP registry for it to |
| 33 | * disappear (rescan started) and reappear (rescan finished). */ |
| 34 | data class WaitingForServer( |
| 35 | val udn: String, |
| 36 | val friendlyName: String, |
| 37 | val seenDisappear: Boolean, |
| 38 | ) : RescanState() |
| 39 | /** Server came back. Held briefly so the row can flash a check before |
| 40 | * returning to Idle. */ |
| 41 | data class Completed(val udn: String, val friendlyName: String) : RescanState() |
| 42 | data class Failed(val udn: String, val friendlyName: String, val message: String) : RescanState() |
| 43 | } |
| 44 | |
| 45 | @HiltViewModel |
| 46 | class ServersViewModel @Inject constructor( |
| 47 | private val holder: UpnpServiceHolder, |
| 48 | private val settings: SettingsStore, |
| 49 | private val cd: ContentDirectoryClient, |
| 50 | private val minim: MinimServerControl, |
| 51 | ) : ViewModel() { |
| 52 | val servers: StateFlow<List<MediaServer>> = holder.servers |
| 53 | val preferredServerUdn: Flow<String?> = settings.preferredServerUdn |
| 54 | |
| 55 | private val _rescanState = MutableStateFlow<RescanState>(RescanState.Idle) |
| 56 | val rescanState: StateFlow<RescanState> = _rescanState.asStateFlow() |
| 57 | |
| 58 | /** Tracks the in-flight rescan coroutine so a second tap on the same row |
| 59 | * while one is still running is a no-op rather than starting a parallel |
| 60 | * request. Also lets us cancel if the user navigates away. */ |
| 61 | private var rescanJob: Job? = null |
| 62 | |
| 63 | /** Called when the user taps a server row — persists the choice so next launch can |
| 64 | * auto-navigate to Browse once the server reappears on the network. */ |
| 65 | fun rememberChoice(udn: String) { |
| 66 | viewModelScope.launch { settings.setPreferredServer(udn) } |
| 67 | } |
| 68 | |
| 69 | /** Rescan the network for UPnP servers without touching the user's current preference. |
| 70 | * Used when they're already on the picker and want to refresh. */ |
| 71 | fun rescan() { |
| 72 | holder.rescan() |
| 73 | } |
| 74 | |
| 75 | /** Clear the persisted preferred server AND rescan. Used from Settings → "Choose |
| 76 | * server" when the user wants to switch libraries — this sends them through the |
| 77 | * picker again, and next app launch will hit WELCOME until they pick. Also drops |
| 78 | * any cached random-library list since that list is server-specific. */ |
| 79 | fun forgetAndRescan() { |
| 80 | viewModelScope.launch { settings.setPreferredServer(null) } |
| 81 | cd.invalidateRandomCache() |
| 82 | holder.rescan() |
| 83 | } |
| 84 | |
| 85 | // Maintainer, 2026-10-29: Settings → Choose server now uses this instead |
| 86 | // of forgetAndRescan — opening the picker no longer deletes the saved |
| 87 | // server; the preference changes only when the user picks one |
| 88 | // (rememberChoice). Random cache dropped as before. |
| 89 | fun rescanForPick() { |
| 90 | cd.invalidateRandomCache() |
| 91 | holder.rescan() |
| 92 | } |
| 93 | |
| 94 | /** Trigger a library rescan on the MinimServer at [udn]. |
| 95 | * |
| 96 | * Flow of states the UI observes via [rescanState]: |
| 97 | * 1. Idle (initial; also after Completed/Failed snap back) |
| 98 | * 2. Requesting(udn) — POST in flight |
| 99 | * 3. WaitingForServer(udn, seenDisappear=false) — server hasn't yet |
| 100 | * dropped off the registry. Some MinimServer versions take a few |
| 101 | * seconds to actually go silent after acking the rescan command. |
| 102 | * 4. WaitingForServer(udn, seenDisappear=true) — server is gone, |
| 103 | * indexing in progress. |
| 104 | * 5. Completed(udn) — server came back. Held COMPLETED_HOLD_MS so the UI can flash |
| 105 | * a check. Library caches are invalidated at this point. |
| 106 | * 6. Failed(udn, message) on any error. Held ~4s. |
| 107 | * |
| 108 | * No-ops if a rescan is already running (any non-Idle state). */ |
| 109 | fun rescanLibrary(udn: String) { |
| 110 | if (_rescanState.value !is RescanState.Idle) return |
| 111 | // Snapshot the friendly name now — once the rescan starts, the |
| 112 | // server will disappear from `holder.servers` and we'd lose the |
| 113 | // ability to label the placeholder row. Fall back to the UDN if |
| 114 | // for some reason the name isn't currently known (shouldn't |
| 115 | // happen since the row only renders Rescan if the server is in |
| 116 | // the list at tap time). |
| 117 | val friendlyName = holder.servers.value.firstOrNull { it.udn == udn }?.friendlyName ?: udn |
| 118 | rescanJob?.cancel() |
| 119 | rescanJob = viewModelScope.launch { |
| 120 | _rescanState.value = RescanState.Requesting(udn, friendlyName) |
| 121 | when (val r = minim.rescanLibrary(udn)) { |
| 122 | is MinimServerControl.Result.Failure -> { |
| 123 | _rescanState.value = RescanState.Failed(udn, friendlyName, r.message) |
| 124 | delay(FAILURE_HOLD_MS) |
| 125 | if (_rescanState.value is RescanState.Failed) { |
| 126 | _rescanState.value = RescanState.Idle |
| 127 | } |
| 128 | } |
| 129 | MinimServerControl.Result.Success -> { |
| 130 | awaitServerCycle(udn, friendlyName) |
| 131 | } |
| 132 | } |
| 133 | } |
| 134 | } |
| 135 | |
| 136 | /** Wait for the rescanning server to (a) leave the registry and (b) come |
| 137 | * back. Both transitions are observed via the holder's flow. We also |
| 138 | * fire periodic SSDP M-SEARCHes during the waiting phase to expedite |
| 139 | * re-discovery — MinimServer announces itself via NOTIFY on startup but |
| 140 | * the search shortens the worst-case delay from ~30s to a few seconds. */ |
| 141 | private suspend fun awaitServerCycle(udn: String, friendlyName: String) { |
| 142 | _rescanState.value = RescanState.WaitingForServer(udn, friendlyName, seenDisappear = false) |
| 143 | |
| 144 | // Phase 1: wait for the server to disappear. Generous timeout — most |
| 145 | // MinimServer rescans drop the registry presence within 10–15s. |
| 146 | val disappeared = withTimeoutOrNull(DISAPPEAR_TIMEOUT_MS) { |
| 147 | holder.servers.firstOrNull { list -> |
| 148 | list.none { it.udn == udn } |
| 149 | } |
| 150 | } |
| 151 | if (disappeared == null) { |
| 152 | // The server never dropped off — likely the rescan didn't |
| 153 | // actually trigger, or this server isn't a real MinimServer. |
| 154 | // Surface as a soft failure rather than wedging the UI. |
| 155 | _rescanState.value = RescanState.Failed( |
| 156 | udn, |
| 157 | friendlyName, |
| 158 | "Server didn't restart — rescan may not have happened", |
| 159 | ) |
| 160 | delay(FAILURE_HOLD_MS) |
| 161 | if (_rescanState.value is RescanState.Failed) _rescanState.value = RescanState.Idle |
| 162 | return |
| 163 | } |
| 164 | _rescanState.value = RescanState.WaitingForServer(udn, friendlyName, seenDisappear = true) |
| 165 | |
| 166 | // Phase 2: wait for the server to come back. Library rescans can take |
| 167 | // minutes-to-hours for large collections; cap at 10 minutes so the |
| 168 | // UI doesn't sit there forever if the server failed to come back at |
| 169 | // all. Fire periodic SSDP searches to nudge re-discovery — without |
| 170 | // this, the registry waits for the server's own NOTIFY which can be |
| 171 | // slow. coroutineScope gives `launch` a receiver and confines the |
| 172 | // SSDP-nudge job's lifetime to this phase; cancelling it on exit |
| 173 | // lets the scope complete cleanly. |
| 174 | // |
| 175 | // CRITICAL: the SSDP nudge keeps running through the Completed hold |
| 176 | // too (not just until first detection). jUPnP's grip on a |
| 177 | // freshly-restarted MinimServer is fragile — the first SSDP search |
| 178 | // response gets the device into the registry, but if no further |
| 179 | // SSDP traffic arrives before the device's max-age ticks down, the |
| 180 | // registry drops it. The user then sees the placeholder disappear |
| 181 | // followed by an empty list until they manually hit refresh. |
| 182 | // Holding the nudge keeps a fresh M-SEARCH arriving every few |
| 183 | // seconds; the server responds, jUPnP's registry resets its |
| 184 | // expiration timer, and the device stays put until its own NOTIFY |
| 185 | // heartbeats take over. |
| 186 | val reappeared = coroutineScope { |
| 187 | val ssdpJob = launch { |
| 188 | while (true) { |
| 189 | // Nudge first, then delay — without this, the first |
| 190 | // nudge wouldn't fire until SSDP_NUDGE_INTERVAL_MS |
| 191 | // after the loop launches, leaving a quiet window |
| 192 | // where jUPnP can drop the device before any fresh |
| 193 | // SSDP traffic arrives. |
| 194 | holder.rescan() |
| 195 | delay(SSDP_NUDGE_INTERVAL_MS) |
| 196 | } |
| 197 | } |
| 198 | try { |
| 199 | val r = withTimeoutOrNull(REAPPEAR_TIMEOUT_MS) { |
| 200 | holder.servers.firstOrNull { list -> |
| 201 | list.any { it.udn == udn } |
| 202 | } |
| 203 | } |
| 204 | if (r == null) return@coroutineScope null |
| 205 | // Library has changed — invalidate the random-album |
| 206 | // cache so the next Random pick reflects the new |
| 207 | // state. Browse caches are re-fetched lazily on the |
| 208 | // next browse, so no further invalidation is needed. |
| 209 | cd.invalidateRandomCache() |
| 210 | _rescanState.value = RescanState.Completed(udn, friendlyName) |
| 211 | // Hold Completed (UI shows the check) while the SSDP |
| 212 | // nudge above keeps the registry warm. |
| 213 | delay(COMPLETED_HOLD_MS) |
| 214 | if (_rescanState.value is RescanState.Completed) _rescanState.value = RescanState.Idle |
| 215 | r |
| 216 | } finally { |
| 217 | ssdpJob.cancel() |
| 218 | } |
| 219 | } |
| 220 | |
| 221 | if (reappeared == null) { |
| 222 | _rescanState.value = RescanState.Failed( |
| 223 | udn, |
| 224 | friendlyName, |
| 225 | "Server didn't come back after rescan (try again from the picker)", |
| 226 | ) |
| 227 | delay(FAILURE_HOLD_MS) |
| 228 | if (_rescanState.value is RescanState.Failed) _rescanState.value = RescanState.Idle |
| 229 | return |
| 230 | } |
| 231 | } |
| 232 | |
| 233 | companion object { |
| 234 | // Hold transient states briefly so the UI can flash success/failure |
| 235 | // before the row returns to its normal Rescan affordance. |
| 236 | private const val COMPLETED_HOLD_MS = 4_000L |
| 237 | private const val FAILURE_HOLD_MS = 4_000L |
| 238 | // Phase timeouts. Disappear is short (rescan should restart the |
| 239 | // server fast); reappear is long (indexing a big library is slow). |
| 240 | private const val DISAPPEAR_TIMEOUT_MS = 30_000L |
| 241 | private const val REAPPEAR_TIMEOUT_MS = 10 * 60 * 1000L |
| 242 | // How often to fire SSDP M-SEARCH while waiting for the server to |
| 243 | // come back. Tight enough that at least one nudge fires during |
| 244 | // the Completed hold (4s hold > 3s interval) so jUPnP doesn't |
| 245 | // drop the freshly-rediscovered device before its first NOTIFY |
| 246 | // heartbeat arrives. |
| 247 | private const val SSDP_NUDGE_INTERVAL_MS = 3_000L |
| 248 | } |
| 249 | } |


