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() } }