MinimServerControl.kt
85 lines · 3.9 KB
Changed in: 0.5.0
| 1 | package dev.stave.upnp |
| 2 | |
| 3 | import kotlinx.coroutines.Dispatchers |
| 4 | import kotlinx.coroutines.withContext |
| 5 | import okhttp3.MediaType.Companion.toMediaType |
| 6 | import okhttp3.OkHttpClient |
| 7 | import okhttp3.Request |
| 8 | import okhttp3.RequestBody.Companion.toRequestBody |
| 9 | import java.util.concurrent.TimeUnit |
| 10 | |
| 11 | /** |
| 12 | * Client for MinimServer's [Web API](https://minimserver.com/ug-scripting.html#Web%20API). |
| 13 | * Ported from Earhorn Android (`dev.stave.upnp.MinimServerControl`) so the desktop |
| 14 | * can trigger a library rescan on the server without leaving the app. |
| 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 [dev.stave.ui.servers.ServersViewModel] observes the cycle. |
| 23 | * |
| 24 | * Port-discovery strategy: try the UPnP descriptor URL's port first (for a |
| 25 | * stock MinimServer config usually 9791 — the ohnet.port — but custom setups |
| 26 | * sometimes collapse both onto 9790). Fall back to 9790 if the descriptor port |
| 27 | * refuses or 404s. |
| 28 | * |
| 29 | * The request only ever goes to the host the server's own UPnP descriptor |
| 30 | * names — never to an address the user typed or a third party supplied. |
| 31 | */ |
| 32 | class MinimServerControl( |
| 33 | private val holder: UpnpServiceHolder, |
| 34 | ) { |
| 35 | sealed class Result { |
| 36 | data object Success : Result() |
| 37 | data class Failure(val message: String) : Result() |
| 38 | } |
| 39 | |
| 40 | private val http: OkHttpClient = OkHttpClient.Builder() |
| 41 | .connectTimeout(5, TimeUnit.SECONDS) |
| 42 | // Longer read timeout: the server takes the POST, restarts internally, |
| 43 | // and only then responds 200. That round-trip can be 5–15 s on a busy |
| 44 | // server. |
| 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 (the rescan has STARTED, not |
| 50 | * finished — wait for the server to leave and rejoin the registry). */ |
| 51 | suspend fun rescanLibrary(udn: String): Result = withContext(Dispatchers.IO) { |
| 52 | val device = holder.findDevice(udn) |
| 53 | ?: return@withContext Result.Failure("Server is no longer on the network") |
| 54 | val descriptor = device.identity.descriptorURL |
| 55 | ?: return@withContext Result.Failure("Server didn't say where its control page is") |
| 56 | val host = descriptor.host |
| 57 | ?: return@withContext Result.Failure("Server didn't say where its control page is") |
| 58 | |
| 59 | val descriptorPort = descriptor.port.takeIf { it > 0 } ?: 80 |
| 60 | val portsToTry = linkedSetOf(descriptorPort, MINIMSERVER_DEFAULT_HTTP_PORT) |
| 61 | |
| 62 | var lastError: String? = null |
| 63 | for (port in portsToTry) { |
| 64 | val url = "http://$host:$port/" |
| 65 | val request = Request.Builder() |
| 66 | .url(url) |
| 67 | .post("rescan\n".toRequestBody(TEXT_PLAIN)) |
| 68 | .build() |
| 69 | try { |
| 70 | http.newCall(request).execute().use { response -> |
| 71 | if (response.isSuccessful) return@withContext Result.Success |
| 72 | lastError = "server answered HTTP ${response.code}" |
| 73 | } |
| 74 | } catch (e: java.io.IOException) { |
| 75 | lastError = e.message?.let { "couldn't reach it ($it)" } ?: "couldn't reach it" |
| 76 | } |
| 77 | } |
| 78 | Result.Failure(lastError ?: "the request failed") |
| 79 | } |
| 80 | |
| 81 | companion object { |
| 82 | private const val MINIMSERVER_DEFAULT_HTTP_PORT = 9790 |
| 83 | private val TEXT_PLAIN = "text/plain; charset=utf-8".toMediaType() |
| 84 | } |
| 85 | } |


