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.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<NetworkInterface> =
Collections.list(NetworkInterface.getNetworkInterfaces())
for (intf in interfaces) {
// Skip loopback and inactive interfaces
if (intf.isLoopback || !intf.isUp) continue
val addrs: List<InetAddress> = 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
}
}

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 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<String, String>, file: File,
header: MutableMap<String, String>,
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)
}
}
fun getCurrentServerPort(): Int = currentServerPort