package dev.stave.cast import okhttp3.OkHttpClient import okhttp3.Request import java.io.BufferedInputStream import java.io.IOException import java.io.InputStream import java.io.OutputStream import java.net.DatagramSocket import java.net.Inet6Address import java.net.InetAddress import java.net.ServerSocket import java.net.Socket import java.util.concurrent.RejectedExecutionException import java.util.concurrent.SynchronousQueue import java.util.concurrent.ThreadPoolExecutor import java.util.concurrent.TimeUnit /** * Lightweight HTTP proxy between UPnP media servers (MinimServer, etc.) * and Chromecast speaker groups. Group leaders make HTTP Range requests * to synchronize playback across members; when fetching the raw UPnP * URL directly, the server's responses can cause broken audio on group * members. The proxy normalizes HTTP behavior — proper Content-Length, * Accept-Ranges, Content-Range headers on every response — so the group * leader can seek and sync reliably. * * Single speakers don't need this; they handle server quirks internally. * The proxy is only used when [CastIntegration] detects the target is a * speaker group. Same architecture as BubbleUPnP's internal cast-proxy. * * **Exposure.** The socket is reachable from the network for the whole group * session, so (Team 4 #6, #7): * - it listens only on the address this computer uses to reach the Cast * device — never every interface, never a VPN or container bridge the * group can't use — and hands out no `127.0.0.1` links a speaker can't * open. With no such address, [proxyUrl] returns null and the group * fetches from the server directly; * - only links it issued are served (128-bit token, 403 otherwise — * Android parity, 2026-09-04), and at most [MAX_TOKENS] are kept; * - requests are parsed with bounded lines, a header-count cap and a * deadline, on a bounded pool of threads, so a peer can't exhaust memory * or threads. * * Desktop port note: the Android version also had a "local content://" * code path for proxying on-device MediaStore files through to Cast. * That's been removed here — desktop's "local files" feature uses * regular filesystem paths and will need its own (simpler) proxy or * direct-serve when ported. UPnP-source playback is the path that * actually flows through this proxy in normal use. */ class CastStreamProxy { private val tag = "EarhornProxy" private val client = OkHttpClient.Builder() .connectTimeout(10, TimeUnit.SECONDS) .readTimeout(120, TimeUnit.SECONDS) .build() private var server: ServerSocket? = null private var boundAddress: InetAddress? = null private var port: Int = 0 @Volatile private var running = false /** token → upstream URL. Only links this proxy issued are served * (Android parity, 2026-09-04): anything else is 403. * Least-recently-used entries go first once [MAX_TOKENS] are held — a * group fetches only the current track and the next. */ private val tokens: MutableMap = java.util.Collections.synchronizedMap( object : LinkedHashMap(64, 0.75f, true) { override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean = size > MAX_TOKENS } ) private val rng = java.security.SecureRandom() private fun newToken(): String { val b = ByteArray(16); rng.nextBytes(b) return b.joinToString("") { "%02x".format(it) } } /** Request handlers. Bounded: when all [MAX_HANDLERS] are busy a new * connection is closed at once instead of getting a thread of its own. */ private val pool = ThreadPoolExecutor( 0, MAX_HANDLERS, 30, TimeUnit.SECONDS, SynchronousQueue(), { r -> Thread(r, "cast-proxy-req").apply { isDaemon = true } }, ThreadPoolExecutor.AbortPolicy(), ) /** Listen on [bindAddr] (a random port). Restarts if it was listening on * another address — the route to the device changed (DHCP, Wi-Fi * switch). Issued links keep working on the new address's port only. */ @Synchronized private fun start(bindAddr: InetAddress) { if (running && boundAddress == bindAddr && server?.isClosed == false) return if (running) closeServer() val ss = ServerSocket(0, 50, bindAddr) port = ss.localPort server = ss boundAddress = bindAddr running = true System.err.println("[$tag] Started on ${bindAddr.hostAddress}:$port") Thread(null, { while (running && !ss.isClosed) { try { val socket = ss.accept() try { pool.execute { handleConnection(socket) } } catch (_: RejectedExecutionException) { runCatching { socket.close() } } } catch (e: Exception) { if (running && !ss.isClosed) System.err.println("[$tag] Accept error: ${e.message}") } } }, "cast-proxy-accept").apply { isDaemon = true }.start() } @Synchronized private fun closeServer() { running = false runCatching { server?.close() } server = null boundAddress = null } @Synchronized fun stop() { closeServer() tokens.clear() } /** Wrap a UPnP resource URL so the Cast device at [castHost] fetches from * this proxy instead. Starts the proxy if not already running. Returns * null when this computer has no usable address toward [castHost]: the * caller then hands the device the original URL. */ @Synchronized fun proxyUrl(originalUrl: String, castHost: String?): String? { val bindAddr = castHost?.let(::localAddressToward) ?: run { System.err.println("[$tag] No local address toward the Cast device — not proxying") return null } try { start(bindAddr) } catch (e: IOException) { System.err.println("[$tag] Could not listen on ${bindAddr.hostAddress}: ${e.message}") return null } val token = newToken() tokens[token] = originalUrl val host = bindAddr.hostAddress.substringBefore('%').let { if (bindAddr is Inet6Address) "[$it]" else it } return "http://$host:$port/stream?t=$token" } /** The local address the kernel would use to reach [host] — the interface * the Cast device can reach us on. Connecting a UDP socket sends no * packet. Null for loopback / wildcard, or when there's no route. */ private fun localAddressToward(host: String): InetAddress? = try { DatagramSocket().use { s -> s.connect(InetAddress.getByName(host), 8009) s.localAddress }?.takeIf { !it.isAnyLocalAddress && !it.isLoopbackAddress } } catch (e: Exception) { // UnknownHost / no route (IOException, or UncheckedIOException from // connect on newer JDKs), a bad address, a security manager. System.err.println("[$tag] No route to the Cast device: ${e.message}") null } private fun handleConnection(socket: Socket) { try { // Short timeout and an overall deadline while reading the request, // so a peer trickling bytes can't hold a handler for long. socket.soTimeout = HEADER_READ_TIMEOUT_MS socket.sendBufferSize = 262144 // 256KB — matches the read buffer socket.tcpNoDelay = true // disable Nagle — send chunks immediately val input = BufferedInputStream(socket.getInputStream()) val output = socket.getOutputStream() val deadline = System.currentTimeMillis() + HEADER_DEADLINE_MS val requestLine = readLineBounded(input, deadline) ?: return val parts = requestLine.split(' ') val method = parts.getOrNull(0) ?: "GET" val path = parts.getOrNull(1) ?: "" // Read headers (bounded count; every line bounded in length). val headers = mutableMapOf() var count = 0 var line = readLineBounded(input, deadline) while (line != null && line.isNotBlank()) { if (++count > MAX_HEADERS) { sendError(output, 431, "Request Header Fields Too Large") return } val colon = line.indexOf(':') if (colon > 0) { headers[line.substring(0, colon).trim().lowercase()] = line.substring(colon + 1).trim() } line = readLineBounded(input, deadline) } socket.soTimeout = BODY_TIMEOUT_MS val rangeHeader = headers["range"] val token = path.substringAfter("t=", "").substringBefore('&').substringBefore(' ') val originalUrl = tokens[token] if (originalUrl == null) { sendError(output, 403, "Forbidden") return } // Not the upstream URL: some servers put credentials in the query // string, and this log goes into bug reports (Team 4 #16). System.err.println("[$tag] $method token=${token.take(6)}… range=$rangeHeader") val reqBuilder = Request.Builder().url(originalUrl) if (rangeHeader != null) { reqBuilder.header("Range", rangeHeader) } if (method == "HEAD") { reqBuilder.head() } // `use`: the upstream response is closed however this ends — // a group leader dropping a Range probe mid-headers is common. client.newCall(reqBuilder.build()).execute().use { response -> val body = response.body val contentType = response.header("Content-Type") ?: "application/octet-stream" val contentLength = response.header("Content-Length") val contentRange = response.header("Content-Range") val statusCode = response.code val statusText = when (statusCode) { 200 -> "OK" 206 -> "Partial Content" 404 -> "Not Found" 416 -> "Range Not Satisfiable" else -> response.message.filter { it >= ' ' }.ifBlank { "Error" } } val sb = StringBuilder() sb.append("HTTP/1.1 $statusCode $statusText\r\n") sb.append("Content-Type: $contentType\r\n") sb.append("Accept-Ranges: bytes\r\n") sb.append("Access-Control-Allow-Origin: *\r\n") if (contentLength != null) { sb.append("Content-Length: $contentLength\r\n") } if (contentRange != null) { sb.append("Content-Range: $contentRange\r\n") } sb.append("Connection: close\r\n") sb.append("\r\n") output.write(sb.toString().toByteArray()) output.flush() if (method != "HEAD" && body != null) { val buf = ByteArray(32768) try { body.byteStream().use { bodyStream -> var read: Int while (bodyStream.read(buf).also { read = it } != -1) { output.write(buf, 0, read) } } output.flush() } catch (e: IOException) { // Client disconnected — normal during seek } } } } catch (e: Exception) { System.err.println("[$tag] Connection error: ${e.message}") } finally { runCatching { socket.close() } } } /** One CRLF/LF-terminated header line, at most [MAX_LINE] bytes, before * [deadline]. Null at end of stream with nothing read. */ private fun readLineBounded(input: InputStream, deadline: Long): String? { val sb = StringBuilder() while (true) { if (System.currentTimeMillis() > deadline) throw IOException("request too slow") val c = input.read() if (c < 0) return if (sb.isEmpty()) null else sb.toString() if (c == '\n'.code) return sb.toString().trimEnd('\r') if (sb.length >= MAX_LINE) throw IOException("request line too long") sb.append(c.toChar()) } } private fun sendError(output: OutputStream, code: Int, message: String) { val body = message.toByteArray() val response = "HTTP/1.1 $code $message\r\n" + "Content-Type: text/plain\r\n" + "Content-Length: ${body.size}\r\n" + "Connection: close\r\n" + "\r\n" output.write(response.toByteArray()) output.write(body) output.flush() } private companion object { /** Concurrent requests served; a group leader uses a handful. */ const val MAX_HANDLERS = 16 /** Issued links kept (least recently used dropped first). */ const val MAX_TOKENS = 512 const val MAX_LINE = 8192 const val MAX_HEADERS = 64 const val HEADER_READ_TIMEOUT_MS = 10_000 const val HEADER_DEADLINE_MS = 15_000L const val BODY_TIMEOUT_MS = 30_000 } }