mirror of
https://github.com/cmclark00/RetroMusicPlayer.git
synced 2025-05-17 23:55:21 +01:00
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:
parent
95bd479a2f
commit
b6eab9d651
3 changed files with 198 additions and 63 deletions
|
@ -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 {
|
||||
}
|
||||
}
|
||||
}
|
||||
Log.e("RetroUtil", "No suitable network interface found")
|
||||
} catch (e: Exception) {
|
||||
Log.e("RetroUtil", "Error getting IP address: ${e.message}")
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (ignored: Exception) {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
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()
|
||||
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) {
|
||||
return errorResponse()
|
||||
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()
|
||||
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)
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
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
|
Loading…
Add table
Add a link
Reference in a new issue