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
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 */
32class 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}