MinimServerControl.kt
Each line, with the release that last changed it.
| 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 | import javax.inject.Inject | |
| 11 | import 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 | |
| 31 | class 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 | } |


