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

MinimServerControl.kt

package dev.stave.upnp

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton

/**
 * Client for MinimServer's [Web API](https://minimserver.com/ug-scripting.html#Web%20API).
 *
 * MinimServer exposes a tiny HTTP/POST control endpoint on its HTTP server port
 * (default 9790, set by the `http.port` property). The body is a plain-text
 * command script — for our purposes a single line: `rescan`. The receiver
 * restarts the server and begins indexing; a 200 OK comes back when the rescan
 * has started (not finished). MinimServer briefly disappears from the UPnP
 * registry during the rescan, then reappears when indexing is complete —
 * that's how [ServersViewModel] observes the cycle to know we're done.
 *
 * Port-discovery strategy: try the UPnP descriptor URL's port first (that's
 * what `findDevice(udn).identity.descriptorURL` carries; for a stock MinimServer
 * config it's usually 9791 — the ohnet.port — but custom setups sometimes
 * collapse both onto 9790). Fall back to 9790 if the descriptor port refuses
 * or 404s. We don't try other ports because there isn't a documented one.
 */
@Singleton
class MinimServerControl @Inject constructor(
    private val holder: UpnpServiceHolder,
) {
    sealed class Result {
        object Success : Result()
        data class Failure(val message: String) : Result()
    }

    private val http: OkHttpClient = OkHttpClient.Builder()
        .connectTimeout(5, TimeUnit.SECONDS)
        // Longer read timeout: the server takes the POST, restarts internally,
        // and only then responds 200. That round-trip can be 5–15s on a busy
        // server. 30s is a generous ceiling — most failures will surface as
        // connection refusal much faster.
        .readTimeout(30, TimeUnit.SECONDS)
        .build()

    /** POST `rescan` to the server identified by [udn]. Returns when the
     *  receiver acknowledges the command (which means the rescan has started,
     *  not that it has finished — wait for the server to disappear/reappear
     *  in the UPnP registry for that). */
    suspend fun rescanLibrary(udn: String): Result = withContext(Dispatchers.IO) {
        val device = holder.findDevice(udn)
            ?: return@withContext Result.Failure("Server no longer in registry")
        val descriptor = device.identity.descriptorURL
            ?: return@withContext Result.Failure("Server has no descriptor URL")
        val host = descriptor.host
            ?: return@withContext Result.Failure("Server descriptor URL has no host")

        // Ports to try, in order. De-duped so we don't double-request when
        // descriptor port already IS 9790.
        val descriptorPort = descriptor.port.takeIf { it > 0 } ?: 80
        val portsToTry = linkedSetOf(descriptorPort, MINIMSERVER_DEFAULT_HTTP_PORT)

        var lastError: String? = null
        for (port in portsToTry) {
            val url = "http://$host:$port/"
            val request = Request.Builder()
                .url(url)
                .post("rescan\n".toRequestBody(TEXT_PLAIN))
                .build()
            runCatching {
                http.newCall(request).execute().use { response ->
                    if (response.isSuccessful) {
                        return@withContext Result.Success
                    }
                    lastError = "HTTP ${response.code} on $url"
                }
            }.onFailure { t ->
                lastError = "${t.javaClass.simpleName}: ${t.message ?: "connection failed"} ($url)"
            }
        }
        Result.Failure(lastError ?: "Rescan request failed")
    }

    companion object {
        private const val MINIMSERVER_DEFAULT_HTTP_PORT = 9790
        private val TEXT_PLAIN = "text/plain; charset=utf-8".toMediaType()
    }
}