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

MinimServerControl.kt

Each line, with the release that last changed it.

0.5.01package dev.stave.upnp
2
3import kotlinx.coroutines.Dispatchers
4import kotlinx.coroutines.withContext
5import okhttp3.MediaType.Companion.toMediaType
6import okhttp3.OkHttpClient
7import okhttp3.Request
8import okhttp3.RequestBody.Companion.toRequestBody
9import java.util.concurrent.TimeUnit
10import javax.inject.Inject
11import javax.inject.Singleton
12
13/**
14 * Client for MinimServer's [Web API](https://minimserver.com/ug-scripting.html#Web%20API).
15 *
16 * MinimServer exposes a tiny HTTP/POST control endpoint on its HTTP server port
17 * (default 9790, set by the `http.port` property). The body is a plain-text
18 * command script — for our purposes a single line: `rescan`. The receiver
19 * restarts the server and begins indexing; a 200 OK comes back when the rescan
20 * has started (not finished). MinimServer briefly disappears from the UPnP
21 * registry during the rescan, then reappears when indexing is complete —
22 * that's how [ServersViewModel] observes the cycle to know we're done.
23 *
24 * Port-discovery strategy: try the UPnP descriptor URL's port first (that's
25 * what `findDevice(udn).identity.descriptorURL` carries; for a stock MinimServer
26 * config it's usually 9791 — the ohnet.port — but custom setups sometimes
27 * collapse both onto 9790). Fall back to 9790 if the descriptor port refuses
28 * or 404s. We don't try other ports because there isn't a documented one.
29 */
30@Singleton
31class MinimServerControl @Inject constructor(
32 private val holder: UpnpServiceHolder,
33) {
34 sealed class Result {
35 object Success : Result()
36 data class Failure(val message: String) : Result()
37 }
38
39 private val http: OkHttpClient = OkHttpClient.Builder()
40 .connectTimeout(5, TimeUnit.SECONDS)
41 // Longer read timeout: the server takes the POST, restarts internally,
42 // and only then responds 200. That round-trip can be 5–15s on a busy
43 // server. 30s is a generous ceiling — most failures will surface as
44 // connection refusal much faster.
45 .readTimeout(30, TimeUnit.SECONDS)
46 .build()
47
48 /** POST `rescan` to the server identified by [udn]. Returns when the
49 * receiver acknowledges the command (which means the rescan has started,
50 * not that it has finished — wait for the server to disappear/reappear
51 * in the UPnP registry for that). */
52 suspend fun rescanLibrary(udn: String): Result = withContext(Dispatchers.IO) {
53 val device = holder.findDevice(udn)
54 ?: return@withContext Result.Failure("Server no longer in registry")
55 val descriptor = device.identity.descriptorURL
56 ?: return@withContext Result.Failure("Server has no descriptor URL")
57 val host = descriptor.host
58 ?: return@withContext Result.Failure("Server descriptor URL has no host")
59
60 // Ports to try, in order. De-duped so we don't double-request when
61 // descriptor port already IS 9790.
62 val descriptorPort = descriptor.port.takeIf { it > 0 } ?: 80
63 val portsToTry = linkedSetOf(descriptorPort, MINIMSERVER_DEFAULT_HTTP_PORT)
64
65 var lastError: String? = null
66 for (port in portsToTry) {
67 val url = "http://$host:$port/"
68 val request = Request.Builder()
69 .url(url)
70 .post("rescan\n".toRequestBody(TEXT_PLAIN))
71 .build()
72 runCatching {
73 http.newCall(request).execute().use { response ->
74 if (response.isSuccessful) {
75 return@withContext Result.Success
76 }
77 lastError = "HTTP ${response.code} on $url"
78 }
79 }.onFailure { t ->
80 lastError = "${t.javaClass.simpleName}: ${t.message ?: "connection failed"} ($url)"
81 }
82 }
83 Result.Failure(lastError ?: "Rescan request failed")
84 }
85
86 companion object {
87 private const val MINIMSERVER_DEFAULT_HTTP_PORT = 9790
88 private val TEXT_PLAIN = "text/plain; charset=utf-8".toMediaType()
89 }
90}