fix: Improve Google Cast server implementation and logging

- Enhanced IP address resolution in RetroUtil.kt: Added better filtering of network interfaces, skip loopback and inactive interfaces, added detailed logging using Android Log class

- Improved RetroWebServer.kt: Added CORS headers for Cast device access, enhanced request logging including remote IP and headers, improved range request handling, added detailed file serving logs, fixed imports and dependencies

- Added CastServerUtils.kt: Implemented port availability checking, added comprehensive logging system, log file stored in app's external files directory

This commit aims to fix Cast playback issues by improving network interface selection, adding proper CORS headers, and implementing detailed logging for debugging.
This commit is contained in:
cmclark00 2025-03-19 22:36:17 -04:00
parent 95bd479a2f
commit b6eab9d651
3 changed files with 198 additions and 63 deletions

View file

@ -21,6 +21,7 @@ import java.net.InetAddress
import java.net.NetworkInterface import java.net.NetworkInterface
import java.text.DecimalFormat import java.text.DecimalFormat
import java.util.* import java.util.*
import android.util.Log
object RetroUtil { object RetroUtil {
fun formatValue(numValue: Float): String { fun formatValue(numValue: Float): String {
@ -81,37 +82,42 @@ object RetroUtil {
val interfaces: List<NetworkInterface> = val interfaces: List<NetworkInterface> =
Collections.list(NetworkInterface.getNetworkInterfaces()) Collections.list(NetworkInterface.getNetworkInterfaces())
for (intf in interfaces) { for (intf in interfaces) {
// Skip loopback and inactive interfaces
if (intf.isLoopback || !intf.isUp) continue
val addrs: List<InetAddress> = Collections.list(intf.inetAddresses) val addrs: List<InetAddress> = Collections.list(intf.inetAddresses)
for (addr in addrs) { for (addr in addrs) {
if (!addr.isLoopbackAddress) { if (!addr.isLoopbackAddress) {
val sAddr = addr.hostAddress val sAddr = addr.hostAddress
if (sAddr != null) { if (sAddr != null) {
val isIPv4 = sAddr.indexOf(':') < 0 val isIPv4 = sAddr.indexOf(':') < 0
if (useIPv4) { if (useIPv4) {
if (isIPv4) return sAddr // Skip local and link-local addresses
if (isIPv4 && !addr.isLinkLocalAddress && !addr.isSiteLocalAddress) continue
if (isIPv4) {
Log.d("RetroUtil", "Using IP address: $sAddr")
return sAddr
}
} else { } else {
if (!isIPv4) { if (!isIPv4) {
val delim = sAddr.indexOf('%') // drop ip6 zone suffix val delim = sAddr.indexOf('%')
return if (delim < 0) { val processedAddr = if (delim < 0) {
sAddr.uppercase() sAddr.uppercase()
} else { } else {
sAddr.substring( sAddr.substring(0, delim).uppercase()
0,
delim
).uppercase()
} }
Log.d("RetroUtil", "Using IPv6 address: $processedAddr")
return processedAddr
} }
} }
} else {
return null
} }
} }
} }
} }
} catch (ignored: Exception) { Log.e("RetroUtil", "No suitable network interface found")
} catch (e: Exception) {
Log.e("RetroUtil", "Error getting IP address: ${e.message}")
} }
return "" return null
} }
} }

View file

@ -0,0 +1,68 @@
package code.name.monkey.retromusic.cast
import android.content.Context
import android.util.Log
import java.io.File
import java.io.FileWriter
import java.net.ServerSocket
import java.text.SimpleDateFormat
import java.util.*
object CastServerUtils {
private const val TAG = "CastServer"
private const val LOG_FILE_NAME = "cast_server.log"
fun isPortAvailable(port: Int): Boolean {
return try {
ServerSocket(port).use { true }
} catch (e: Exception) {
logError("Port $port is not available: ${e.message}")
false
}
}
fun findAvailablePort(startPort: Int, endPort: Int = startPort + 10): Int {
for (port in startPort..endPort) {
if (isPortAvailable(port)) {
logInfo("Found available port: $port")
return port
}
}
logError("No available ports found in range $startPort-$endPort")
return -1
}
fun logInfo(message: String) {
Log.i(TAG, message)
writeToLogFile("INFO", message)
}
fun logError(message: String) {
Log.e(TAG, message)
writeToLogFile("ERROR", message)
}
private fun writeToLogFile(level: String, message: String) {
try {
val logDir = File(context.getExternalFilesDir(null), "logs")
if (!logDir.exists()) {
logDir.mkdirs()
}
val logFile = File(logDir, LOG_FILE_NAME)
val timestamp = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.US).format(Date())
FileWriter(logFile, true).use { writer ->
writer.append("$timestamp [$level] $message\n")
}
} catch (e: Exception) {
Log.e(TAG, "Failed to write to log file: ${e.message}")
}
}
private lateinit var context: Context
fun init(appContext: Context) {
context = appContext.applicationContext
}
}

View file

@ -2,14 +2,15 @@ package code.name.monkey.retromusic.cast
import android.content.Context import android.content.Context
import code.name.monkey.retromusic.util.MusicUtil import code.name.monkey.retromusic.util.MusicUtil
import code.name.monkey.retromusic.util.RetroUtil
import fi.iki.elonen.NanoHTTPD import fi.iki.elonen.NanoHTTPD
import fi.iki.elonen.NanoHTTPD.Response.Status import fi.iki.elonen.NanoHTTPD.Response.Status
import java.io.* import java.io.*
const val DEFAULT_SERVER_PORT = 9090
private var currentServerPort = DEFAULT_SERVER_PORT
const val SERVER_PORT = 9090 class RetroWebServer(val context: Context) : NanoHTTPD(findAndInitializePort()) {
class RetroWebServer(val context: Context) : NanoHTTPD(SERVER_PORT) {
companion object { companion object {
private const val MIME_TYPE_IMAGE = "image/jpg" private const val MIME_TYPE_IMAGE = "image/jpg"
const val MIME_TYPE_AUDIO = "audio/mp3" const val MIME_TYPE_AUDIO = "audio/mp3"
@ -17,66 +18,124 @@ class RetroWebServer(val context: Context) : NanoHTTPD(SERVER_PORT) {
const val PART_COVER_ART = "coverart" const val PART_COVER_ART = "coverart"
const val PART_SONG = "song" const val PART_SONG = "song"
const val PARAM_ID = "id" const val PARAM_ID = "id"
private fun findAndInitializePort(): Int {
return if (CastServerUtils.isPortAvailable(DEFAULT_SERVER_PORT)) {
CastServerUtils.logInfo("Using default port: $DEFAULT_SERVER_PORT")
DEFAULT_SERVER_PORT
} else {
val port = CastServerUtils.findAvailablePort(DEFAULT_SERVER_PORT)
if (port != -1) {
CastServerUtils.logInfo("Using alternative port: $port")
currentServerPort = port
port
} else {
CastServerUtils.logError("Failed to find available port, using default")
DEFAULT_SERVER_PORT
}
}
}
}
init {
CastServerUtils.init(context)
}
override fun start() {
try {
super.start()
val ipAddress = RetroUtil.getIpAddress(true)
CastServerUtils.logInfo("Server started successfully on port $currentServerPort with IP: $ipAddress")
} catch (e: Exception) {
CastServerUtils.logError("Failed to start server: ${e.message}")
throw e
}
}
override fun stop() {
try {
super.stop()
CastServerUtils.logInfo("Server stopped")
} catch (e: Exception) {
CastServerUtils.logError("Error stopping server: ${e.message}")
}
} }
override fun serve(session: IHTTPSession?): Response { override fun serve(session: IHTTPSession?): Response {
if (session?.uri?.contains(PART_COVER_ART) == true) { try {
val albumId = session.parameters?.get(PARAM_ID)?.get(0) ?: return errorResponse() CastServerUtils.logInfo("Received request: ${session?.uri} from ${session?.remoteIpAddress}")
val albumArtUri = MusicUtil.getMediaStoreAlbumCoverUri(albumId.toLong()) CastServerUtils.logInfo("Headers: ${session?.headers}")
val fis: InputStream?
try { if (session?.uri?.contains(PART_COVER_ART) == true) {
fis = context.contentResolver.openInputStream(albumArtUri) val albumId = session.parameters?.get(PARAM_ID)?.get(0) ?: return errorResponse("Missing album ID")
} catch (e: FileNotFoundException) { val albumArtUri = MusicUtil.getMediaStoreAlbumCoverUri(albumId.toLong())
return errorResponse() val fis: InputStream?
try {
fis = context.contentResolver.openInputStream(albumArtUri)
CastServerUtils.logInfo("Serving album art for ID: $albumId")
} catch (e: FileNotFoundException) {
CastServerUtils.logError("Album art not found for ID: $albumId - ${e.message}")
return errorResponse("Album art not found")
}
return newChunkedResponse(Status.OK, MIME_TYPE_IMAGE, fis)
} else if (session?.uri?.contains(PART_SONG) == true) {
val songId = session.parameters?.get(PARAM_ID)?.get(0) ?: return errorResponse("Missing song ID")
val songUri = MusicUtil.getSongFileUri(songId.toLong())
val songPath = MusicUtil.getSongFilePath(context, songUri)
val song = File(songPath)
if (!song.exists()) {
CastServerUtils.logError("Song file not found: $songPath")
return errorResponse("Song file not found")
}
CastServerUtils.logInfo("Serving song: $songPath (${song.length()} bytes)")
return serveFile(session.headers!!, song, MIME_TYPE_AUDIO)
} }
return newChunkedResponse(Status.OK, MIME_TYPE_IMAGE, fis)
} else if (session?.uri?.contains(PART_SONG) == true) { CastServerUtils.logError("Invalid request URI: ${session?.uri}")
val songId = session.parameters?.get(PARAM_ID)?.get(0) ?: return errorResponse() return newFixedLengthResponse(Status.NOT_FOUND, MIME_PLAINTEXT, "Not Found")
val songUri = MusicUtil.getSongFileUri(songId.toLong()) } catch (e: Exception) {
val songPath = MusicUtil.getSongFilePath(context, songUri) CastServerUtils.logError("Error serving request: ${e.message}")
val song = File(songPath) return errorResponse("Internal server error")
return serveFile(session.headers!!, song, MIME_TYPE_AUDIO)
} }
return newFixedLengthResponse(Status.NOT_FOUND, MIME_PLAINTEXT, "Not Found")
} }
private fun serveFile( private fun serveFile(
header: MutableMap<String, String>, file: File, header: MutableMap<String, String>,
file: File,
mime: String mime: String
): Response { ): Response {
var res: Response var res: Response
try { try {
// Support (simple) skipping: CastServerUtils.logInfo("Serving file: ${file.path} with MIME type: $mime")
CastServerUtils.logInfo("Request headers: $header")
var startFrom: Long = 0 var startFrom: Long = 0
var endAt: Long = -1 var endAt: Long = -1
// The value of header range will be bytes=0-1024 something like this
// We get the value of from Bytes i.e. startFrom and toBytes i.e. endAt below
var range = header["range"] var range = header["range"]
if (range != null) { if (range != null) {
CastServerUtils.logInfo("Range request: $range")
if (range.startsWith("bytes=")) { if (range.startsWith("bytes=")) {
range = range.substring("bytes=".length) range = range.substring("bytes=".length)
val minus = range.indexOf('-') val minus = range.indexOf('-')
try { try {
if (minus > 0) { if (minus > 0) {
startFrom = range startFrom = range.substring(0, minus).toLong()
.substring(0, minus).toLong()
endAt = range.substring(minus + 1).toLong() endAt = range.substring(minus + 1).toLong()
CastServerUtils.logInfo("Parsed range - start: $startFrom, end: $endAt")
} }
} catch (ignored: NumberFormatException) { } catch (e: NumberFormatException) {
CastServerUtils.logError("Failed to parse range: ${e.message}")
} }
} }
} }
// Chunked Response is used when serving audio file
// Change return code and add Content-Range header when skipping is
// requested
val fileLen = file.length() val fileLen = file.length()
if (range != null && startFrom >= 0) { if (range != null && startFrom >= 0) {
if (startFrom >= fileLen) { if (startFrom >= fileLen) {
res = newFixedLengthResponse( CastServerUtils.logError("Requested range start ($startFrom) exceeds file length ($fileLen)")
Status.RANGE_NOT_SATISFIABLE, res = newFixedLengthResponse(Status.RANGE_NOT_SATISFIABLE, MIME_PLAINTEXT, "")
MIME_PLAINTEXT, ""
)
res.addHeader("Content-Range", "bytes 0-0/$fileLen") res.addHeader("Content-Range", "bytes 0-0/$fileLen")
} else { } else {
if (endAt < 0) { if (endAt < 0) {
@ -94,34 +153,36 @@ class RetroWebServer(val context: Context) : NanoHTTPD(SERVER_PORT) {
} }
} }
fis.skip(startFrom) fis.skip(startFrom)
res = newChunkedResponse( res = newChunkedResponse(Status.PARTIAL_CONTENT, mime, fis)
Status.PARTIAL_CONTENT, mime,
fis
)
res.addHeader("Content-Length", "" + dataLen) res.addHeader("Content-Length", "" + dataLen)
res.addHeader( res.addHeader("Content-Range", "bytes $startFrom-$endAt/$fileLen")
"Content-Range", "bytes " + startFrom + "-" CastServerUtils.logInfo("Serving partial content: bytes $startFrom-$endAt/$fileLen")
+ endAt + "/" + fileLen
)
} }
} else { } else {
res = newFixedLengthResponse( res = newFixedLengthResponse(Status.OK, mime, file.inputStream(), file.length())
Status.OK, mime,
file.inputStream(), file.length()
)
res.addHeader("Accept-Ranges", "bytes") res.addHeader("Accept-Ranges", "bytes")
res.addHeader("Content-Length", "" + fileLen) res.addHeader("Content-Length", "" + fileLen)
CastServerUtils.logInfo("Serving full file: $fileLen bytes")
} }
// Add CORS headers to allow Cast device access
res.addHeader("Access-Control-Allow-Origin", "*")
res.addHeader("Access-Control-Allow-Methods", "GET, HEAD")
res.addHeader("Access-Control-Allow-Headers", "Range")
return res
} catch (ioe: IOException) { } catch (ioe: IOException) {
res = newFixedLengthResponse( val errorMsg = "Failed to read file: ${ioe.message}"
Status.FORBIDDEN, CastServerUtils.logError(errorMsg)
MIME_PLAINTEXT, "FORBIDDEN: Reading file failed." res = newFixedLengthResponse(Status.FORBIDDEN, MIME_PLAINTEXT, "FORBIDDEN: $errorMsg")
)
} }
return res return res
} }
private fun errorResponse(message: String = "Error Occurred"): Response { private fun errorResponse(message: String = "Error Occurred"): Response {
CastServerUtils.logError("Returning error response: $message")
return newFixedLengthResponse(Status.INTERNAL_ERROR, MIME_PLAINTEXT, message) return newFixedLengthResponse(Status.INTERNAL_ERROR, MIME_PLAINTEXT, message)
} }
} }
fun getCurrentServerPort(): Int = currentServerPort