refactor(media-servers): cache health probes, prune stale status, save tab

Follow-up on the audit of the Media Servers redesign:

- Add a process-wide TTL cache (60s) to MediaServerHealthProbe so probe
  results survive the screen's ViewModel being recreated on each open;
  the ViewModel reuses fresh cached status instead of re-hitting the
  network, and skips launching a probe when one is already in flight.
- Prune the _health map on refresh/remove so it can't grow unbounded as
  servers are added and removed within a session.
- Persist the selected tab across configuration changes (rememberSaveable).
- Restore the screen's intro caption, reusing the previously orphaned
  set_preferred_media_servers string.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJ77Hm5L7fXEbPWbUds1iA
This commit is contained in:
Claude
2026-07-17 21:33:26 +00:00
parent f759ef6ca7
commit 40acd69781
3 changed files with 60 additions and 4 deletions
@@ -42,7 +42,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -108,11 +108,18 @@ fun MediaServersScaffold(
).consumeWindowInsets(padding)
.imePadding(),
) {
var selectedTab by remember { mutableIntStateOf(TAB_SERVERS) }
var selectedTab by rememberSaveable { mutableIntStateOf(TAB_SERVERS) }
val tabs = listOf(R.string.media_servers_tab_servers, R.string.media_servers_tab_cache)
Text(
text = stringRes(id = R.string.set_preferred_media_servers),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.grayText,
modifier = Modifier.padding(top = 10.dp),
)
SingleChoiceSegmentedButtonRow(
modifier = Modifier.fillMaxWidth().padding(top = 12.dp, bottom = 8.dp),
modifier = Modifier.fillMaxWidth().padding(top = 10.dp, bottom = 8.dp),
) {
tabs.forEachIndexed { index, labelRes ->
SegmentedButton(
@@ -77,6 +77,7 @@ class BlossomServersViewModel : ViewModel() {
}
}
}
pruneHealth()
}
/** Moves a server to a new position; list order is the upload/fallback priority. */
@@ -91,13 +92,23 @@ class BlossomServersViewModel : ViewModel() {
isModified = true
}
/** Re-probes every server currently in the list. */
/** Re-probes every server currently in the list. Fresh cached results are reused. */
fun checkAllHealth() {
_fileServers.value.forEach { probeServer(it.baseUrl) }
}
private fun probeServer(serverUrl: String) {
val builder = httpClientBuilder ?: return
// A probe is already in flight for this URL — don't launch a duplicate.
if (_health.value[serverUrl] == ServerHealth.Checking) return
// Reuse a still-fresh cached status instead of hitting the network again.
MediaServerHealthProbe.cached(serverUrl)?.let { cachedStatus ->
_health.update { it + (serverUrl to cachedStatus) }
return
}
_health.update { it + (serverUrl to ServerHealth.Checking) }
viewModelScope.launch(Dispatchers.IO) {
val result = MediaServerHealthProbe.probe(serverUrl, builder::okHttpClientForPreview)
@@ -105,6 +116,12 @@ class BlossomServersViewModel : ViewModel() {
}
}
/** Drops health entries for servers no longer in the list so the map can't grow unbounded. */
private fun pruneHealth() {
val liveUrls = _fileServers.value.mapTo(HashSet()) { it.baseUrl }
_health.update { statuses -> statuses.filterKeys { it in liveUrls } }
}
fun addServerList(serverList: List<String>) {
serverList.forEach { serverUrl ->
addServer(serverUrl)
@@ -157,6 +174,7 @@ class BlossomServersViewModel : ViewModel() {
ServerName(serverName, serverUrl, ServerType.Blossom),
)
}
pruneHealth()
isModified = true
}
}
@@ -25,6 +25,7 @@ import kotlinx.coroutines.CancellationException
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.coroutines.executeAsync
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit
/**
@@ -63,9 +64,39 @@ object MediaServerHealthProbe {
const val SLOW_THRESHOLD_MS: Long = 1_000L
private const val PROBE_TIMEOUT_MS: Long = 5_000L
/**
* How long a probe result is reused before the server is re-checked. The cache is
* process-wide (this is a singleton) so results survive the screen's ViewModel being
* recreated on each open, mirroring [com.vitorpamplona.amethyst.service.uploads.blossom.bud10.LocalBlossomCacheProbe].
*/
private const val CACHE_TTL_MS: Long = 60_000L
private class CachedResult(
val status: ServerHealth,
val atMs: Long,
)
private val cache = ConcurrentHashMap<String, CachedResult>()
/** The cached status for [baseUrl] if still within [CACHE_TTL_MS], else null. */
fun cached(baseUrl: String): ServerHealth? {
val entry = cache[baseUrl] ?: return null
return if (TimeUtils.nowMillis() - entry.atMs < CACHE_TTL_MS) entry.status else null
}
suspend fun probe(
baseUrl: String,
clientForUrl: (String) -> OkHttpClient,
): ServerHealth {
cached(baseUrl)?.let { return it }
val result = runProbe(baseUrl, clientForUrl)
cache[baseUrl] = CachedResult(result, TimeUtils.nowMillis())
return result
}
private suspend fun runProbe(
baseUrl: String,
clientForUrl: (String) -> OkHttpClient,
): ServerHealth =
try {
val client =