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

ServersViewModel.kt

249 lines · 12.2 KB

Changed in: 0.5.0

1package dev.stave.ui.servers
2
3import androidx.lifecycle.ViewModel
4import androidx.lifecycle.viewModelScope
5import dagger.hilt.android.lifecycle.HiltViewModel
6import dev.stave.data.SettingsStore
7import dev.stave.domain.model.MediaServer
8import dev.stave.upnp.ContentDirectoryClient
9import dev.stave.upnp.MinimServerControl
10import dev.stave.upnp.UpnpServiceHolder
11import kotlinx.coroutines.Job
12import kotlinx.coroutines.coroutineScope
13import kotlinx.coroutines.delay
14import kotlinx.coroutines.flow.Flow
15import kotlinx.coroutines.flow.MutableStateFlow
16import kotlinx.coroutines.flow.StateFlow
17import kotlinx.coroutines.flow.asStateFlow
18import kotlinx.coroutines.flow.firstOrNull
19import kotlinx.coroutines.launch
20import kotlinx.coroutines.withTimeoutOrNull
21import 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. */
27sealed 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
46class 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}