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