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
/**
* Client for MinimServer's [Web API](https://minimserver.com/ug-scripting.html#Web%20API).
* Ported from Earhorn Android (`dev.stave.upnp.MinimServerControl`) so the desktop
* can trigger a library rescan on the server without leaving the app.
*
* 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 [dev.stave.ui.servers.ServersViewModel] observes the cycle.
*
* Port-discovery strategy: try the UPnP descriptor URL's port first (for a
* stock MinimServer config usually 9791 — the ohnet.port — but custom setups
* sometimes collapse both onto 9790). Fall back to 9790 if the descriptor port
* refuses or 404s.
*
* The request only ever goes to the host the server's own UPnP descriptor
* names — never to an address the user typed or a third party supplied.
*/
class MinimServerControl(
private val holder: UpnpServiceHolder,
) {
sealed class Result {
data 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–15 s on a busy
// server.
.readTimeout(30, TimeUnit.SECONDS)
.build()
/** POST `rescan` to the server identified by [udn]. Returns when the
* receiver acknowledges the command (the rescan has STARTED, not
* finished — wait for the server to leave and rejoin the registry). */
suspend fun rescanLibrary(udn: String): Result = withContext(Dispatchers.IO) {
val device = holder.findDevice(udn)
?: return@withContext Result.Failure("Server is no longer on the network")
val descriptor = device.identity.descriptorURL
?: return@withContext Result.Failure("Server didn't say where its control page is")
val host = descriptor.host
?: return@withContext Result.Failure("Server didn't say where its control page is")
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()
try {
http.newCall(request).execute().use { response ->
if (response.isSuccessful) return@withContext Result.Success
lastError = "server answered HTTP ${response.code}"
}
} catch (e: java.io.IOException) {
lastError = e.message?.let { "couldn't reach it ($it)" } ?: "couldn't reach it"
}
}
Result.Failure(lastError ?: "the request failed")
}
companion object {
private const val MINIMSERVER_DEFAULT_HTTP_PORT = 9790
private val TEXT_PLAIN = "text/plain; charset=utf-8".toMediaType()
}
}