From b6eab9d651a155755ffe74a30bdb2888c6db80b1 Mon Sep 17 00:00:00 2001 From: cmclark00 Date: Wed, 19 Mar 2025 22:36:17 -0400 Subject: [PATCH] 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. --- .../name/monkey/retromusic/util/RetroUtil.kt | 32 ++-- .../monkey/retromusic/cast/CastServerUtils.kt | 68 ++++++++ .../monkey/retromusic/cast/RetroWebServer.kt | 161 ++++++++++++------ 3 files changed, 198 insertions(+), 63 deletions(-) create mode 100644 app/src/normal/java/code/name/monkey/retromusic/cast/CastServerUtils.kt diff --git a/app/src/main/java/code/name/monkey/retromusic/util/RetroUtil.kt b/app/src/main/java/code/name/monkey/retromusic/util/RetroUtil.kt index 60f4f5823..b2e5c730a 100644 --- a/app/src/main/java/code/name/monkey/retromusic/util/RetroUtil.kt +++ b/app/src/main/java/code/name/monkey/retromusic/util/RetroUtil.kt @@ -21,6 +21,7 @@ import java.net.InetAddress import java.net.NetworkInterface import java.text.DecimalFormat import java.util.* +import android.util.Log object RetroUtil { fun formatValue(numValue: Float): String { @@ -81,37 +82,42 @@ object RetroUtil { val interfaces: List = Collections.list(NetworkInterface.getNetworkInterfaces()) for (intf in interfaces) { + // Skip loopback and inactive interfaces + if (intf.isLoopback || !intf.isUp) continue + val addrs: List = Collections.list(intf.inetAddresses) for (addr in addrs) { if (!addr.isLoopbackAddress) { val sAddr = addr.hostAddress - if (sAddr != null) { val isIPv4 = sAddr.indexOf(':') < 0 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 { if (!isIPv4) { - val delim = sAddr.indexOf('%') // drop ip6 zone suffix - return if (delim < 0) { + val delim = sAddr.indexOf('%') + val processedAddr = if (delim < 0) { sAddr.uppercase() } else { - sAddr.substring( - 0, - delim - ).uppercase() + sAddr.substring(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 } } \ No newline at end of file diff --git a/app/src/normal/java/code/name/monkey/retromusic/cast/CastServerUtils.kt b/app/src/normal/java/code/name/monkey/retromusic/cast/CastServerUtils.kt new file mode 100644 index 000000000..0798adc46 --- /dev/null +++ b/app/src/normal/java/code/name/monkey/retromusic/cast/CastServerUtils.kt @@ -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 + } +} \ No newline at end of file diff --git a/app/src/normal/java/code/name/monkey/retromusic/cast/RetroWebServer.kt b/app/src/normal/java/code/name/monkey/retromusic/cast/RetroWebServer.kt index 658e1bf88..d1d86860a 100644 --- a/app/src/normal/java/code/name/monkey/retromusic/cast/RetroWebServer.kt +++ b/app/src/normal/java/code/name/monkey/retromusic/cast/RetroWebServer.kt @@ -2,14 +2,15 @@ package code.name.monkey.retromusic.cast import android.content.Context import code.name.monkey.retromusic.util.MusicUtil +import code.name.monkey.retromusic.util.RetroUtil import fi.iki.elonen.NanoHTTPD import fi.iki.elonen.NanoHTTPD.Response.Status 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(SERVER_PORT) { +class RetroWebServer(val context: Context) : NanoHTTPD(findAndInitializePort()) { companion object { private const val MIME_TYPE_IMAGE = "image/jpg" 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_SONG = "song" 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 { - if (session?.uri?.contains(PART_COVER_ART) == true) { - val albumId = session.parameters?.get(PARAM_ID)?.get(0) ?: return errorResponse() - val albumArtUri = MusicUtil.getMediaStoreAlbumCoverUri(albumId.toLong()) - val fis: InputStream? - try { - fis = context.contentResolver.openInputStream(albumArtUri) - } catch (e: FileNotFoundException) { - return errorResponse() + try { + CastServerUtils.logInfo("Received request: ${session?.uri} from ${session?.remoteIpAddress}") + CastServerUtils.logInfo("Headers: ${session?.headers}") + + if (session?.uri?.contains(PART_COVER_ART) == true) { + val albumId = session.parameters?.get(PARAM_ID)?.get(0) ?: return errorResponse("Missing album ID") + val albumArtUri = MusicUtil.getMediaStoreAlbumCoverUri(albumId.toLong()) + 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) { - val songId = session.parameters?.get(PARAM_ID)?.get(0) ?: return errorResponse() - val songUri = MusicUtil.getSongFileUri(songId.toLong()) - val songPath = MusicUtil.getSongFilePath(context, songUri) - val song = File(songPath) - return serveFile(session.headers!!, song, MIME_TYPE_AUDIO) + + CastServerUtils.logError("Invalid request URI: ${session?.uri}") + return newFixedLengthResponse(Status.NOT_FOUND, MIME_PLAINTEXT, "Not Found") + } catch (e: Exception) { + CastServerUtils.logError("Error serving request: ${e.message}") + return errorResponse("Internal server error") } - return newFixedLengthResponse(Status.NOT_FOUND, MIME_PLAINTEXT, "Not Found") } private fun serveFile( - header: MutableMap, file: File, + header: MutableMap, + file: File, mime: String ): Response { var res: Response try { - // Support (simple) skipping: + CastServerUtils.logInfo("Serving file: ${file.path} with MIME type: $mime") + CastServerUtils.logInfo("Request headers: $header") + var startFrom: Long = 0 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"] if (range != null) { + CastServerUtils.logInfo("Range request: $range") if (range.startsWith("bytes=")) { range = range.substring("bytes=".length) val minus = range.indexOf('-') try { if (minus > 0) { - startFrom = range - .substring(0, minus).toLong() + startFrom = range.substring(0, minus).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() if (range != null && startFrom >= 0) { if (startFrom >= fileLen) { - res = newFixedLengthResponse( - Status.RANGE_NOT_SATISFIABLE, - MIME_PLAINTEXT, "" - ) + CastServerUtils.logError("Requested range start ($startFrom) exceeds file length ($fileLen)") + res = newFixedLengthResponse(Status.RANGE_NOT_SATISFIABLE, MIME_PLAINTEXT, "") res.addHeader("Content-Range", "bytes 0-0/$fileLen") } else { if (endAt < 0) { @@ -94,34 +153,36 @@ class RetroWebServer(val context: Context) : NanoHTTPD(SERVER_PORT) { } } fis.skip(startFrom) - res = newChunkedResponse( - Status.PARTIAL_CONTENT, mime, - fis - ) + res = newChunkedResponse(Status.PARTIAL_CONTENT, mime, fis) res.addHeader("Content-Length", "" + dataLen) - res.addHeader( - "Content-Range", "bytes " + startFrom + "-" - + endAt + "/" + fileLen - ) + res.addHeader("Content-Range", "bytes $startFrom-$endAt/$fileLen") + CastServerUtils.logInfo("Serving partial content: bytes $startFrom-$endAt/$fileLen") } } else { - res = newFixedLengthResponse( - Status.OK, mime, - file.inputStream(), file.length() - ) + res = newFixedLengthResponse(Status.OK, mime, file.inputStream(), file.length()) res.addHeader("Accept-Ranges", "bytes") 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) { - res = newFixedLengthResponse( - Status.FORBIDDEN, - MIME_PLAINTEXT, "FORBIDDEN: Reading file failed." - ) + val errorMsg = "Failed to read file: ${ioe.message}" + CastServerUtils.logError(errorMsg) + res = newFixedLengthResponse(Status.FORBIDDEN, MIME_PLAINTEXT, "FORBIDDEN: $errorMsg") } return res } private fun errorResponse(message: String = "Error Occurred"): Response { + CastServerUtils.logError("Returning error response: $message") return newFixedLengthResponse(Status.INTERNAL_ERROR, MIME_PLAINTEXT, message) } -} \ No newline at end of file +} + +fun getCurrentServerPort(): Int = currentServerPort \ No newline at end of file