Bootstrap Castarr from NodeCast prototype
Imported the native TV app (Kotlin, ExoPlayer, embedded remote server, QR pairing) plus CONTEXT.md and ADRs 0001-0005. Rename, Compose for TV UI and the Dispatcharr integration follow as tracked issues. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
244
app/src/main/java/com/nodecast/tv/server/ControlServer.kt
Normal file
244
app/src/main/java/com/nodecast/tv/server/ControlServer.kt
Normal file
@@ -0,0 +1,244 @@
|
||||
package com.nodecast.tv.server
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import com.nodecast.tv.pairing.Pairing
|
||||
import com.nodecast.tv.playlist.Channel
|
||||
import fi.iki.elonen.NanoHTTPD
|
||||
import fi.iki.elonen.NanoWSD
|
||||
import org.json.JSONObject
|
||||
import java.io.IOException
|
||||
import java.util.Timer
|
||||
import java.util.TimerTask
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
|
||||
/**
|
||||
* Embedded HTTP + WebSocket server. Serves the phone remote (a single HTML
|
||||
* page) over HTTP and takes playback commands over a WebSocket. A client
|
||||
* authorizes itself with the four-digit pairing code from the QR code.
|
||||
*/
|
||||
class ControlServer(
|
||||
private val context: Context,
|
||||
private val listener: Listener,
|
||||
) : NanoWSD(Pairing.PORT) {
|
||||
|
||||
interface Listener {
|
||||
fun onPlay(url: String, name: String, group: String)
|
||||
fun onTogglePlay()
|
||||
fun onPause()
|
||||
fun onResume()
|
||||
fun onStopCast()
|
||||
fun onSeek(deltaSeconds: Long)
|
||||
fun onVolume(value: Float)
|
||||
fun onSetPlaylist(url: String)
|
||||
fun onClientsChanged(count: Int, newestName: String?)
|
||||
fun currentStatus(): JSONObject
|
||||
fun currentChannels(): List<Channel>
|
||||
fun currentPlaylistUrl(): String
|
||||
}
|
||||
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private val clients = CopyOnWriteArrayList<RemoteSocket>()
|
||||
private val pairingCode = Pairing.code(context)
|
||||
private val pairingToken = Pairing.token(context)
|
||||
private var pingTimer: Timer? = null
|
||||
|
||||
// Rate limit for the human-typable 4-digit code (the QR token is not
|
||||
// brute-forceable and stays exempt).
|
||||
private val codeAttempts = ArrayDeque<Long>()
|
||||
|
||||
@Synchronized
|
||||
private fun codeAttemptAllowed(): Boolean {
|
||||
val now = System.currentTimeMillis()
|
||||
while (codeAttempts.isNotEmpty() && now - codeAttempts.first() > CODE_ATTEMPT_WINDOW_MS) {
|
||||
codeAttempts.removeFirst()
|
||||
}
|
||||
if (codeAttempts.size >= CODE_ATTEMPT_MAX) return false
|
||||
codeAttempts.addLast(now)
|
||||
return true
|
||||
}
|
||||
|
||||
fun startServer() {
|
||||
start(0, true)
|
||||
pingTimer = Timer("ws-ping", true).also {
|
||||
it.schedule(object : TimerTask() {
|
||||
override fun run() = pingClients()
|
||||
}, PING_INTERVAL_MS, PING_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
|
||||
fun stopServer() {
|
||||
pingTimer?.cancel()
|
||||
pingTimer = null
|
||||
stop()
|
||||
}
|
||||
|
||||
// --- HTTP ---
|
||||
|
||||
override fun serveHttp(session: IHTTPSession): Response {
|
||||
return when (session.uri) {
|
||||
"/", "/index.html" -> {
|
||||
val html = context.assets.open("remote/index.html").bufferedReader().use { it.readText() }
|
||||
newFixedLengthResponse(Response.Status.OK, "text/html; charset=utf-8", html).apply {
|
||||
addHeader("Cache-Control", "no-store")
|
||||
}
|
||||
}
|
||||
else -> newFixedLengthResponse(Response.Status.NOT_FOUND, NanoHTTPD.MIME_PLAINTEXT, "not found")
|
||||
}
|
||||
}
|
||||
|
||||
// --- WebSocket ---
|
||||
|
||||
override fun openWebSocket(handshake: IHTTPSession): WebSocket = RemoteSocket(handshake)
|
||||
|
||||
fun broadcast(message: JSONObject) {
|
||||
val payload = message.toString()
|
||||
clients.forEach { client ->
|
||||
if (client.authorized) client.trySend(payload)
|
||||
}
|
||||
}
|
||||
|
||||
fun broadcastStatus() {
|
||||
broadcast(listener.currentStatus().put("type", "status"))
|
||||
}
|
||||
|
||||
fun broadcastChannels() {
|
||||
broadcast(channelsMessage())
|
||||
}
|
||||
|
||||
fun broadcastToast(message: String) {
|
||||
broadcast(JSONObject().put("type", "toast").put("message", message))
|
||||
}
|
||||
|
||||
private fun channelsMessage(): JSONObject = JSONObject()
|
||||
.put("type", "channels")
|
||||
.put("playlistUrl", listener.currentPlaylistUrl())
|
||||
.put("channels", Channel.listToJson(listener.currentChannels()))
|
||||
|
||||
private fun pingClients() {
|
||||
clients.forEach { client ->
|
||||
try {
|
||||
client.ping(PING_PAYLOAD)
|
||||
} catch (e: IOException) {
|
||||
Log.d(TAG, "ping failed, dropping client", e)
|
||||
clients.remove(client)
|
||||
}
|
||||
}
|
||||
notifyClientsChanged(null)
|
||||
}
|
||||
|
||||
private fun notifyClientsChanged(newestName: String?) {
|
||||
val count = clients.count { it.authorized }
|
||||
mainHandler.post { listener.onClientsChanged(count, newestName) }
|
||||
}
|
||||
|
||||
inner class RemoteSocket(handshake: IHTTPSession) : WebSocket(handshake) {
|
||||
|
||||
@Volatile
|
||||
var authorized = false
|
||||
private set
|
||||
|
||||
private var deviceName: String = ""
|
||||
|
||||
fun trySend(payload: String) {
|
||||
try {
|
||||
send(payload)
|
||||
} catch (e: IOException) {
|
||||
Log.d(TAG, "send failed, dropping client", e)
|
||||
clients.remove(this)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onOpen() {
|
||||
clients.add(this)
|
||||
}
|
||||
|
||||
override fun onClose(code: WebSocketFrame.CloseCode?, reason: String?, initiatedByRemote: Boolean) {
|
||||
clients.remove(this)
|
||||
notifyClientsChanged(null)
|
||||
}
|
||||
|
||||
override fun onMessage(message: WebSocketFrame) {
|
||||
val msg = runCatching { JSONObject(message.textPayload) }.getOrNull() ?: return
|
||||
val type = msg.optString("type")
|
||||
if (!authorized) {
|
||||
if (type == "hello") handleHello(msg)
|
||||
return
|
||||
}
|
||||
when (type) {
|
||||
"play" -> post {
|
||||
listener.onPlay(
|
||||
msg.optString("url"),
|
||||
msg.optString("name"),
|
||||
msg.optString("group"),
|
||||
)
|
||||
}
|
||||
"toggle" -> post { listener.onTogglePlay() }
|
||||
"pause" -> post { listener.onPause() }
|
||||
"resume" -> post { listener.onResume() }
|
||||
"stop" -> post { listener.onStopCast() }
|
||||
"seek" -> post { listener.onSeek(msg.optLong("delta")) }
|
||||
"volume" -> post { listener.onVolume(msg.optDouble("value", 1.0).toFloat().coerceIn(0f, 1f)) }
|
||||
"set_playlist" -> post { listener.onSetPlaylist(msg.optString("url")) }
|
||||
"get_state" -> {
|
||||
trySend(listener.currentStatus().put("type", "status").toString())
|
||||
trySend(channelsMessage().toString())
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPong(pong: WebSocketFrame?) = Unit
|
||||
|
||||
override fun onException(exception: IOException?) {
|
||||
clients.remove(this)
|
||||
}
|
||||
|
||||
private fun handleHello(msg: JSONObject) {
|
||||
val token = msg.optString("token")
|
||||
val tokenOk = token.isNotEmpty() && token == pairingToken
|
||||
val codeOk = !tokenOk && msg.optString("code").let { code ->
|
||||
code.isNotEmpty() && when {
|
||||
!codeAttemptAllowed() -> {
|
||||
trySend(JSONObject().put("type", "error").put("error", "rate_limited").toString())
|
||||
runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "rate limited", false) }
|
||||
return
|
||||
}
|
||||
else -> code == pairingCode
|
||||
}
|
||||
}
|
||||
if (!tokenOk && !codeOk) {
|
||||
trySend(JSONObject().put("type", "error").put("error", "bad_code").toString())
|
||||
runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "bad code", false) }
|
||||
return
|
||||
}
|
||||
authorized = true
|
||||
deviceName = msg.optString("name").ifEmpty { "Handy" }
|
||||
trySend(
|
||||
JSONObject()
|
||||
.put("type", "welcome")
|
||||
.put("device", android.os.Build.MODEL)
|
||||
.put("token", pairingToken)
|
||||
.put("status", listener.currentStatus())
|
||||
.put("playlistUrl", listener.currentPlaylistUrl())
|
||||
.put("channels", Channel.listToJson(listener.currentChannels()))
|
||||
.toString()
|
||||
)
|
||||
notifyClientsChanged(deviceName)
|
||||
}
|
||||
|
||||
private fun post(action: () -> Unit) {
|
||||
mainHandler.post(action)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "ControlServer"
|
||||
const val PING_INTERVAL_MS = 8_000L
|
||||
const val CODE_ATTEMPT_WINDOW_MS = 60_000L
|
||||
const val CODE_ATTEMPT_MAX = 5
|
||||
val PING_PAYLOAD = byteArrayOf(0x6e, 0x63)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user