From 2246f8cee2948ec5272bb2443e3366f0eb24939a Mon Sep 17 00:00:00 2001 From: Koen Date: Tue, 21 Nov 2023 15:12:09 +0000 Subject: [PATCH] Finished implementation of HLS proxying. --- .../futo/platformplayer/Extensions_Network.kt | 47 ++++ .../futo/platformplayer/Extensions_Syntax.kt | 4 + .../api/http/server/HttpContext.kt | 3 +- .../api/http/server/ManagedHttpServer.kt | 53 +++- .../api/http/server/handlers/HttpHandler.kt | 1 + .../http/server/handlers/HttpProxyHandler.kt | 148 +++++++++- .../platformplayer/builders/HlsBuilder.kt | 37 --- .../platformplayer/casting/StateCasting.kt | 150 +++++++++- .../com/futo/platformplayer/parsers/HLS.kt | 266 ++++++++++++++++++ .../parsers/HttpResponseParser.kt | 64 +++++ 10 files changed, 706 insertions(+), 67 deletions(-) delete mode 100644 app/src/main/java/com/futo/platformplayer/builders/HlsBuilder.kt create mode 100644 app/src/main/java/com/futo/platformplayer/parsers/HLS.kt create mode 100644 app/src/main/java/com/futo/platformplayer/parsers/HttpResponseParser.kt diff --git a/app/src/main/java/com/futo/platformplayer/Extensions_Network.kt b/app/src/main/java/com/futo/platformplayer/Extensions_Network.kt index f2310bfe..b99b33d5 100644 --- a/app/src/main/java/com/futo/platformplayer/Extensions_Network.kt +++ b/app/src/main/java/com/futo/platformplayer/Extensions_Network.kt @@ -1,11 +1,15 @@ package com.futo.platformplayer import com.google.common.base.CharMatcher +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.io.InputStream import java.net.Inet4Address import java.net.InetAddress import java.net.InetSocketAddress import java.net.Socket import java.nio.ByteBuffer +import java.nio.charset.Charset private const val IPV4_PART_COUNT = 4; @@ -273,3 +277,46 @@ fun getConnectedSocket(addresses: List, port: Int): Socket? { return connectedSocket; } + +fun InputStream.readHttpHeaderBytes() : ByteArray { + val headerBytes = ByteArrayOutputStream() + var crlfCount = 0 + + while (crlfCount < 4) { + val b = read() + if (b == -1) { + throw IOException("Unexpected end of stream while reading headers") + } + + if (b == 0x0D || b == 0x0A) { // CR or LF + crlfCount++ + } else { + crlfCount = 0 + } + + headerBytes.write(b) + } + + return headerBytes.toByteArray() +} + +fun InputStream.readLine() : String? { + val line = ByteArrayOutputStream() + var crlfCount = 0 + + while (crlfCount < 2) { + val b = read() + if (b == -1) { + return null + } + + if (b == 0x0D || b == 0x0A) { // CR or LF + crlfCount++ + } else { + crlfCount = 0 + line.write(b) + } + } + + return String(line.toByteArray(), Charsets.UTF_8) +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/Extensions_Syntax.kt b/app/src/main/java/com/futo/platformplayer/Extensions_Syntax.kt index 41d1822e..0b79de90 100644 --- a/app/src/main/java/com/futo/platformplayer/Extensions_Syntax.kt +++ b/app/src/main/java/com/futo/platformplayer/Extensions_Syntax.kt @@ -12,4 +12,8 @@ inline fun Any.assume(cb: (T) -> R): R? { if(result != null) return cb(result); return null; +} + +fun String?.yesNoToBoolean(): Boolean { + return this?.uppercase() == "YES" } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/http/server/HttpContext.kt b/app/src/main/java/com/futo/platformplayer/api/http/server/HttpContext.kt index 9302629d..f08610f5 100644 --- a/app/src/main/java/com/futo/platformplayer/api/http/server/HttpContext.kt +++ b/app/src/main/java/com/futo/platformplayer/api/http/server/HttpContext.kt @@ -219,8 +219,7 @@ class HttpContext : AutoCloseable { headersToRespond.put("keep-alive", "timeout=5, max=1000"); } - val responseHeader = HttpResponse(status, headers); - + val responseHeader = HttpResponse(status, headersToRespond); responseStream.write(responseHeader.getHttpHeaderBytes()); if(method != "HEAD") { diff --git a/app/src/main/java/com/futo/platformplayer/api/http/server/ManagedHttpServer.kt b/app/src/main/java/com/futo/platformplayer/api/http/server/ManagedHttpServer.kt index df695ed1..fa5eafac 100644 --- a/app/src/main/java/com/futo/platformplayer/api/http/server/ManagedHttpServer.kt +++ b/app/src/main/java/com/futo/platformplayer/api/http/server/ManagedHttpServer.kt @@ -17,6 +17,7 @@ import java.util.* import java.util.concurrent.ExecutorService import java.util.concurrent.Executors import java.util.stream.IntStream.range +import kotlin.collections.HashMap class ManagedHttpServer(private val _requestedPort: Int = 0) { private val _client : ManagedHttpClient = ManagedHttpClient(); @@ -28,7 +29,8 @@ class ManagedHttpServer(private val _requestedPort: Int = 0) { var port = 0 private set; - private val _handlers = mutableListOf(); + private val _handlers = hashMapOf>() + private val _headHandlers = hashMapOf() private var _workerPool: ExecutorService? = null; @Synchronized @@ -114,32 +116,61 @@ class ManagedHttpServer(private val _requestedPort: Int = 0) { fun getHandler(method: String, path: String) : HttpHandler? { synchronized(_handlers) { - //TODO: Support regex paths? - if(method == "HEAD") - return _handlers.firstOrNull { it.path == path && (it.allowHEAD || it.method == "HEAD") } - return _handlers.firstOrNull { it.method == method && it.path == path }; + if (method == "HEAD") { + return _headHandlers[path] + } + + val handlerMap = _handlers[method] ?: return null + return handlerMap[path] } } fun addHandler(handler: HttpHandler, withHEAD: Boolean = false) : HttpHandler { synchronized(_handlers) { - _handlers.add(handler); handler.allowHEAD = withHEAD; + + var handlerMap: HashMap? = _handlers[handler.method]; + if (handlerMap == null) { + handlerMap = hashMapOf() + _handlers[handler.method] = handlerMap + } + + handlerMap[handler.path] = handler; + if (handler.allowHEAD || handler.method == "HEAD") { + _headHandlers[handler.path] = handler + } } return handler; } fun removeHandler(method: String, path: String) { synchronized(_handlers) { - val handler = getHandler(method, path); - if(handler != null) - _handlers.remove(handler); + val handlerMap = _handlers[method] ?: return + val handler = handlerMap.remove(path) ?: return + if (method == "HEAD" || handler.allowHEAD) { + _headHandlers.remove(path) + } } } fun removeAllHandlers(tag: String? = null) { synchronized(_handlers) { if(tag == null) _handlers.clear(); - else - _handlers.removeIf { it.tag == tag }; + else { + for (pair in _handlers) { + val toRemove = ArrayList() + for (innerPair in pair.value) { + if (innerPair.value.tag == tag) { + toRemove.add(innerPair.key) + + if (pair.key == "HEAD" || innerPair.value.allowHEAD) { + _headHandlers.remove(innerPair.key) + } + } + } + + for (x in toRemove) + pair.value.remove(x) + } + } } } fun addBridgeHandlers(obj: Any, tag: String? = null) { diff --git a/app/src/main/java/com/futo/platformplayer/api/http/server/handlers/HttpHandler.kt b/app/src/main/java/com/futo/platformplayer/api/http/server/handlers/HttpHandler.kt index 509c1b40..5097ef42 100644 --- a/app/src/main/java/com/futo/platformplayer/api/http/server/handlers/HttpHandler.kt +++ b/app/src/main/java/com/futo/platformplayer/api/http/server/handlers/HttpHandler.kt @@ -15,6 +15,7 @@ abstract class HttpHandler(val method: String, val path: String) { headers.put(key, value); return this; } + fun withContentType(contentType: String) = withHeader("Content-Type", contentType); fun withTag(tag: String) : HttpHandler { diff --git a/app/src/main/java/com/futo/platformplayer/api/http/server/handlers/HttpProxyHandler.kt b/app/src/main/java/com/futo/platformplayer/api/http/server/handlers/HttpProxyHandler.kt index f6774c1e..afc2589e 100644 --- a/app/src/main/java/com/futo/platformplayer/api/http/server/handlers/HttpProxyHandler.kt +++ b/app/src/main/java/com/futo/platformplayer/api/http/server/handlers/HttpProxyHandler.kt @@ -1,12 +1,20 @@ package com.futo.platformplayer.api.http.server.handlers import android.net.Uri +import android.util.Log import com.futo.platformplayer.api.http.server.HttpContext import com.futo.platformplayer.api.http.server.HttpHeaders import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.parsers.HttpResponseParser +import com.futo.platformplayer.readLine +import java.io.InputStream +import java.io.OutputStream +import java.lang.Exception +import java.net.Socket +import javax.net.ssl.SSLSocketFactory -class HttpProxyHandler(method: String, path: String, val targetUrl: String): HttpHandler(method, path) { +class HttpProxyHandler(method: String, path: String, val targetUrl: String, private val useTcp: Boolean = false): HttpHandler(method, path) { var content: String? = null; var contentType: String? = null; @@ -18,10 +26,17 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String): Htt private var _injectHost = false; private var _injectReferer = false; - private val _client = ManagedHttpClient(); override fun handle(context: HttpContext) { + if (useTcp) { + handleWithTcp(context) + } else { + handleWithOkHttp(context) + } + } + + private fun handleWithOkHttp(context: HttpContext) { val proxyHeaders = HashMap(); for (header in context.headers.filter { !_ignoreRequestHeaders.contains(it.key.lowercase()) }) proxyHeaders[header.key] = header.value; @@ -35,8 +50,8 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String): Htt proxyHeaders.put("Referer", targetUrl); val useMethod = if (method == "inherit") context.method else method; - Logger.i(TAG, "Proxied Request ${useMethod}: ${targetUrl}"); - Logger.i(TAG, "Headers:" + proxyHeaders.map { "${it.key}: ${it.value}" }.joinToString("\n")); + Logger.i(TAG, "handleWithOkHttp Proxied Request ${useMethod}: ${targetUrl}"); + Logger.i(TAG, "handleWithOkHttp Headers:" + proxyHeaders.map { "${it.key}: ${it.value}" }.joinToString("\n")); val resp = when (useMethod) { "GET" -> _client.get(targetUrl, proxyHeaders); @@ -46,7 +61,7 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String): Htt }; Logger.i(TAG, "Proxied Response [${resp.code}]"); - val headersFiltered = HttpHeaders(resp.getHeadersFlat().filter { !_ignoreRequestHeaders.contains(it.key.lowercase()) }); + val headersFiltered = HttpHeaders(resp.getHeadersFlat().filter { !_ignoreResponseHeaders.contains(it.key.lowercase()) }); for(newHeader in headers) headersFiltered.put(newHeader.key, newHeader.value); @@ -66,6 +81,129 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String): Htt } } + private fun handleWithTcp(context: HttpContext) { + if (content != null) + throw NotImplementedError("Content body is not supported") + + val proxyHeaders = HashMap(); + for (header in context.headers.filter { !_ignoreRequestHeaders.contains(it.key.lowercase()) }) + proxyHeaders[header.key] = header.value; + for (injectHeader in _injectRequestHeader) + proxyHeaders[injectHeader.first] = injectHeader.second; + + val parsed = Uri.parse(targetUrl); + if(_injectHost) + proxyHeaders.put("Host", parsed.host!!); + if(_injectReferer) + proxyHeaders.put("Referer", targetUrl); + + val useMethod = if (method == "inherit") context.method else method; + Logger.i(TAG, "handleWithTcp Proxied Request ${useMethod}: ${targetUrl}"); + Logger.i(TAG, "handleWithTcp Headers:" + proxyHeaders.map { "${it.key}: ${it.value}" }.joinToString("\n")); + + val requestBuilder = StringBuilder() + requestBuilder.append("$useMethod $targetUrl HTTP/1.1\r\n") + proxyHeaders.forEach { (key, value) -> requestBuilder.append("$key: $value\r\n") } + requestBuilder.append("\r\n") + + val port = if (parsed.port == -1) { + when (parsed.scheme) { + "https" -> 443 + "http" -> 80 + else -> throw Exception("Unhandled scheme") + } + } else { + parsed.port + } + + val socket = if (parsed.scheme == "https") { + val sslSocketFactory = SSLSocketFactory.getDefault() as SSLSocketFactory + sslSocketFactory.createSocket(parsed.host, port) + } else { + Socket(parsed.host, port) + } + + socket.use { s -> + s.getOutputStream().write(requestBuilder.toString().encodeToByteArray()) + + val inputStream = s.getInputStream() + val resp = HttpResponseParser(inputStream) + val isChunked = resp.transferEncoding.equals("chunked", ignoreCase = true) + val contentLength = resp.contentLength.toInt() + + val headersFiltered = HttpHeaders(resp.headers.filter { !_ignoreResponseHeaders.contains(it.key.lowercase()) }); + for(newHeader in headers) + headersFiltered.put(newHeader.key, newHeader.value); + + context.respond(resp.statusCode, headersFiltered) { responseStream -> + if (isChunked) { + Logger.i(TAG, "handleWithTcp handleChunkedTransfer"); + handleChunkedTransfer(inputStream, responseStream) + } else if (contentLength != -1) { + Logger.i(TAG, "handleWithTcp transferFixedLengthContent $contentLength"); + transferFixedLengthContent(inputStream, responseStream, contentLength) + } else { + Logger.i(TAG, "handleWithTcp transferUntilEndOfStream"); + transferUntilEndOfStream(inputStream, responseStream) + } + } + } + } + + private fun handleChunkedTransfer(inputStream: InputStream, responseStream: OutputStream) { + var line: String? + val buffer = ByteArray(8192) + + while (inputStream.readLine().also { line = it } != null) { + val size = line!!.trim().toInt(16) + Logger.i(TAG, "handleWithTcp handleChunkedTransfer chunk size $size") + + responseStream.write(line!!.encodeToByteArray()) + responseStream.write("\r\n".encodeToByteArray()) + + if (size == 0) { + inputStream.skip(2) + responseStream.write("\r\n".encodeToByteArray()) + break + } + + var totalRead = 0 + while (totalRead < size) { + val read = inputStream.read(buffer, 0, minOf(buffer.size, size - totalRead)) + if (read == -1) break + responseStream.write(buffer, 0, read) + totalRead += read + } + + inputStream.skip(2) + responseStream.write("\r\n".encodeToByteArray()) + responseStream.flush() + } + } + + private fun transferFixedLengthContent(inputStream: InputStream, responseStream: OutputStream, contentLength: Int) { + val buffer = ByteArray(8192) + var totalRead = 0 + while (totalRead < contentLength) { + val read = inputStream.read(buffer, 0, minOf(buffer.size, contentLength - totalRead)) + if (read == -1) break + responseStream.write(buffer, 0, read) + totalRead += read + } + + responseStream.flush() + } + + private fun transferUntilEndOfStream(inputStream: InputStream, responseStream: OutputStream) { + val buffer = ByteArray(8192) + var read: Int + while (inputStream.read(buffer).also { read = it } >= 0) { + responseStream.write(buffer, 0, read) + } + + responseStream.flush() + } + fun withContent(body: String) : HttpProxyHandler { this.content = body; return this; diff --git a/app/src/main/java/com/futo/platformplayer/builders/HlsBuilder.kt b/app/src/main/java/com/futo/platformplayer/builders/HlsBuilder.kt deleted file mode 100644 index 2744e8a0..00000000 --- a/app/src/main/java/com/futo/platformplayer/builders/HlsBuilder.kt +++ /dev/null @@ -1,37 +0,0 @@ -package com.futo.platformplayer.builders - -import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource -import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource -import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource -import java.io.PrintWriter -import java.io.StringWriter - -class HlsBuilder { - companion object{ - fun generateOnDemandHLS(vidSource: IVideoSource, vidUrl: String, audioSource: IAudioSource?, audioUrl: String?, subtitleSource: ISubtitleSource?, subtitleUrl: String?): String { - val hlsBuilder = StringWriter() - PrintWriter(hlsBuilder).use { writer -> - writer.println("#EXTM3U") - - // Audio - if (audioSource != null && audioUrl != null) { - val audioFormat = audioSource.container.substringAfter("/") - writer.println("#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"audio\",LANGUAGE=\"en\",NAME=\"English\",AUTOSELECT=YES,DEFAULT=YES,URI=\"${audioUrl.replace("&", "&")}\",FORMAT=\"$audioFormat\"") - } - - // Subtitles - if (subtitleSource != null && subtitleUrl != null) { - val subtitleFormat = subtitleSource.format ?: "text/vtt" - writer.println("#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID=\"subs\",LANGUAGE=\"en\",NAME=\"English\",AUTOSELECT=YES,DEFAULT=YES,URI=\"${subtitleUrl.replace("&", "&")}\",FORMAT=\"$subtitleFormat\"") - } - - // Video - val videoFormat = vidSource.container.substringAfter("/") - writer.println("#EXT-X-STREAM-INF:BANDWIDTH=100000,CODECS=\"${vidSource.codec}\",RESOLUTION=${vidSource.width}x${vidSource.height}${if (audioSource != null) ",AUDIO=\"audio\"" else ""}${if (subtitleSource != null) ",SUBTITLES=\"subs\"" else ""},FORMAT=\"$videoFormat\"") - writer.println(vidUrl.replace("&", "&")) - } - - return hlsBuilder.toString() - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt index 6a3055e7..34b3da87 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt @@ -4,6 +4,7 @@ import android.content.ContentResolver import android.content.Context import android.os.Looper import com.futo.platformplayer.* +import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.http.server.ManagedHttpServer import com.futo.platformplayer.api.http.server.handlers.* import com.futo.platformplayer.api.media.models.streams.sources.* @@ -15,6 +16,7 @@ import com.futo.platformplayer.constructs.Event2 import com.futo.platformplayer.exceptions.UnsupportedCastException import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.models.CastingDeviceInfo +import com.futo.platformplayer.parsers.HLS import com.futo.platformplayer.states.StateApp import kotlinx.coroutines.* import java.net.InetAddress @@ -45,6 +47,7 @@ class StateCasting { val onActiveDevicePlayChanged = Event1(); val onActiveDeviceTimeChanged = Event1(); var activeDevice: CastingDevice? = null; + private val _client = ManagedHttpClient(); val isCasting: Boolean get() = activeDevice != null; @@ -354,14 +357,22 @@ class StateCasting { } } else { if (videoSource is IVideoUrlSource) - ad.loadVideo("BUFFERED", videoSource.container, videoSource.getVideoUrl(), resumePosition, video.duration.toDouble()); - else if(videoSource is IHLSManifestSource) - ad.loadVideo("BUFFERED", videoSource.container, videoSource.url, resumePosition, video.duration.toDouble()); + ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoSource.getVideoUrl(), resumePosition, video.duration.toDouble()); else if (audioSource is IAudioUrlSource) - ad.loadVideo("BUFFERED", audioSource.container, audioSource.getAudioUrl(), resumePosition, video.duration.toDouble()); - else if(audioSource is IHLSManifestAudioSource) - ad.loadVideo("BUFFERED", audioSource.container, audioSource.url, resumePosition, video.duration.toDouble()); - else if (videoSource is LocalVideoSource) + ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioSource.getAudioUrl(), resumePosition, video.duration.toDouble()); + else if(videoSource is IHLSManifestSource) { + if (ad is ChromecastCastingDevice) { + castHlsIndirect(video, videoSource.url, resumePosition); + } else { + ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoSource.url, resumePosition, video.duration.toDouble()); + } + } else if(audioSource is IHLSManifestAudioSource) { + if (ad is ChromecastCastingDevice) { + castHlsIndirect(video, audioSource.url, resumePosition); + } else { + ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioSource.url, resumePosition, video.duration.toDouble()); + } + } else if (videoSource is LocalVideoSource) castLocalVideo(video, videoSource, resumePosition); else if (audioSource is LocalAudioSource) castLocalAudio(video, audioSource, resumePosition); @@ -405,7 +416,7 @@ class StateCasting { private fun castLocalVideo(video: IPlatformVideoDetails, videoSource: LocalVideoSource, resumePosition: Double) : List { val ad = activeDevice ?: return listOf(); - val url = "http://${ad.localAddress}:${_castServer.port}"; + val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}"; val id = UUID.randomUUID(); val videoPath = "/video-${id}" val videoUrl = url + videoPath; @@ -424,7 +435,7 @@ class StateCasting { private fun castLocalAudio(video: IPlatformVideoDetails, audioSource: LocalAudioSource, resumePosition: Double) : List { val ad = activeDevice ?: return listOf(); - val url = "http://${ad.localAddress}:${_castServer.port}"; + val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}"; val id = UUID.randomUUID(); val audioPath = "/audio-${id}" val audioUrl = url + audioPath; @@ -444,7 +455,7 @@ class StateCasting { private fun castLocalDash(video: IPlatformVideoDetails, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?, resumePosition: Double) : List { val ad = activeDevice ?: return listOf(); - val url = "http://${ad.localAddress}:${_castServer.port}"; + val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}"; val id = UUID.randomUUID(); val dashPath = "/dash-${id}" @@ -505,7 +516,7 @@ class StateCasting { private suspend fun castDashDirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double) : List { val ad = activeDevice ?: return listOf(); - val url = "http://${ad.localAddress}:${_castServer.port}"; + val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}"; val id = UUID.randomUUID(); val subtitlePath = "/subtitle-${id}"; @@ -547,11 +558,126 @@ class StateCasting { return listOf(videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: ""); } + private fun castHlsIndirect(video: IPlatformVideoDetails, sourceUrl: String, resumePosition: Double): List { + _castServer.removeAllHandlers("castHlsIndirectMaster") + + val ad = activeDevice ?: return listOf(); + val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}"; + + val id = UUID.randomUUID(); + val hlsPath = "/hls-${id}" + val hlsUrl = url + hlsPath + Logger.i(TAG, "HLS url: $hlsUrl"); + + _castServer.addHandler(HttpFuntionHandler("GET", hlsPath) { masterContext -> + _castServer.removeAllHandlers("castHlsIndirectVariant") + + val headers = masterContext.headers.clone() + headers["Content-Type"] = "application/vnd.apple.mpegurl"; + + val masterPlaylist = HLS.downloadAndParseMasterPlaylist(_client, sourceUrl) + val newVariantPlaylistRefs = arrayListOf() + val newMediaRenditions = arrayListOf() + val newMasterPlaylist = HLS.MasterPlaylist(newVariantPlaylistRefs, newMediaRenditions, masterPlaylist.independentSegments) + + for (variantPlaylistRef in masterPlaylist.variantPlaylistsRefs) { + val playlistId = UUID.randomUUID(); + val newPlaylistPath = "/hls-playlist-${playlistId}" + val newPlaylistUrl = url + newPlaylistPath; + + _castServer.addHandler(HttpFuntionHandler("GET", newPlaylistPath) { vpContext -> + val vpHeaders = vpContext.headers.clone() + vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl"; + + val variantPlaylist = HLS.downloadAndParseVariantPlaylist(_client, variantPlaylistRef.url) + val proxiedVariantPlaylist = proxyVariantPlaylist(url, playlistId, variantPlaylist) + val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8() + vpContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8); + }.withHeader("Access-Control-Allow-Origin", "*"), true).withTag("castHlsIndirectVariant") + + newVariantPlaylistRefs.add(HLS.VariantPlaylistReference( + newPlaylistUrl, + variantPlaylistRef.streamInfo + )) + } + + for (mediaRendition in masterPlaylist.mediaRenditions) { + val playlistId = UUID.randomUUID(); + val newPlaylistPath = "/hls-playlist-${playlistId}" + val newPlaylistUrl = url + newPlaylistPath; + + _castServer.addHandler(HttpFuntionHandler("GET", newPlaylistPath) { vpContext -> + val vpHeaders = vpContext.headers.clone() + vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl"; + + val variantPlaylist = HLS.downloadAndParseVariantPlaylist(_client, mediaRendition.uri) + val proxiedVariantPlaylist = proxyVariantPlaylist(url, playlistId, variantPlaylist) + val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8() + vpContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8); + }.withHeader("Access-Control-Allow-Origin", "*"), true).withTag("castHlsIndirectVariant") + + newMediaRenditions.add(HLS.MediaRendition( + mediaRendition.type, + newPlaylistUrl, + mediaRendition.groupID, + mediaRendition.language, + mediaRendition.name, + mediaRendition.isDefault, + mediaRendition.isAutoSelect, + mediaRendition.isForced + )) + } + + masterContext.respondCode(200, headers, newMasterPlaylist.buildM3U8()); + }.withHeader("Access-Control-Allow-Origin", "*"), true).withTag("castHlsIndirectMaster") + + Logger.i(TAG, "added new castHlsIndirect handlers (hlsPath: $hlsPath)."); + ad.loadVideo("BUFFERED", "application/vnd.apple.mpegurl", hlsUrl, resumePosition, video.duration.toDouble()); + + return listOf(hlsUrl); + } + + private fun proxyVariantPlaylist(url: String, playlistId: UUID, variantPlaylist: HLS.VariantPlaylist): HLS.VariantPlaylist { + val newSegments = arrayListOf() + + variantPlaylist.segments.forEachIndexed { index, segment -> + val sequenceNumber = variantPlaylist.mediaSequence + index.toLong() + newSegments.add(proxySegment(url, playlistId, segment, sequenceNumber)) + } + + return HLS.VariantPlaylist( + variantPlaylist.version, + variantPlaylist.targetDuration, + variantPlaylist.mediaSequence, + variantPlaylist.discontinuitySequence, + variantPlaylist.programDateTime, + newSegments + ) + } + + private fun proxySegment(url: String, playlistId: UUID, segment: HLS.Segment, index: Long): HLS.Segment { + val newSegmentPath = "/hls-playlist-${playlistId}-segment-${index}" + val newSegmentUrl = url + newSegmentPath; + + if (_castServer.getHandler("GET", newSegmentPath) == null) { + _castServer.addHandler( + HttpProxyHandler("GET", newSegmentPath, segment.uri, true) + .withInjectedHost() + .withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("castHlsIndirectVariant") + } + + return HLS.Segment( + segment.duration, + newSegmentUrl + ) + } + private suspend fun castDashIndirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double) : List { val ad = activeDevice ?: return listOf(); val proxyStreams = ad !is FastCastCastingDevice; - val url = "http://${ad.localAddress}:${_castServer.port}"; + val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}"; Logger.i(TAG, "DASH url: $url"); val id = UUID.randomUUID(); diff --git a/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt b/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt new file mode 100644 index 00000000..c3fa6245 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt @@ -0,0 +1,266 @@ +package com.futo.platformplayer.parsers + +import com.futo.platformplayer.api.http.ManagedHttpClient +import com.futo.platformplayer.yesNoToBoolean +import java.net.URI +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter + +class HLS { + companion object { + fun downloadAndParseMasterPlaylist(client: ManagedHttpClient, sourceUrl: String): MasterPlaylist { + val masterPlaylistResponse = client.get(sourceUrl) + check(masterPlaylistResponse.isOk) { "Failed to get master playlist: ${masterPlaylistResponse.code}" } + + val masterPlaylistContent = masterPlaylistResponse.body?.string() + ?: throw Exception("Master playlist content is empty") + val baseUrl = URI(sourceUrl).resolve("./").toString() + + val variantPlaylists = mutableListOf() + val mediaRenditions = mutableListOf() + var independentSegments = false + + masterPlaylistContent.lines().forEachIndexed { index, line -> + when { + line.startsWith("#EXT-X-STREAM-INF") -> { + val nextLine = masterPlaylistContent.lines().getOrNull(index + 1) + ?: throw Exception("Expected URI following #EXT-X-STREAM-INF, found none") + val url = resolveUrl(baseUrl, nextLine) + + variantPlaylists.add(VariantPlaylistReference(url, parseStreamInfo(line))) + } + + line.startsWith("#EXT-X-MEDIA") -> { + mediaRenditions.add(parseMediaRendition(client, line, baseUrl)) + } + + line == "#EXT-X-INDEPENDENT-SEGMENTS" -> { + independentSegments = true + } + } + } + + return MasterPlaylist(variantPlaylists, mediaRenditions, independentSegments) + } + + fun downloadAndParseVariantPlaylist(client: ManagedHttpClient, sourceUrl: String): VariantPlaylist { + val response = client.get(sourceUrl) + check(response.isOk) { "Failed to get variant playlist: ${response.code}" } + + val content = response.body?.string() + ?: throw Exception("Variant playlist content is empty") + + val lines = content.lines() + val version = lines.find { it.startsWith("#EXT-X-VERSION:") }?.substringAfter(":")?.toIntOrNull() ?: 3 + val targetDuration = lines.find { it.startsWith("#EXT-X-TARGETDURATION:") }?.substringAfter(":")?.toIntOrNull() + ?: throw Exception("Target duration not found in variant playlist") + val mediaSequence = lines.find { it.startsWith("#EXT-X-MEDIA-SEQUENCE:") }?.substringAfter(":")?.toLongOrNull() ?: 0 + val discontinuitySequence = lines.find { it.startsWith("#EXT-X-DISCONTINUITY-SEQUENCE:") }?.substringAfter(":")?.toIntOrNull() ?: 0 + val programDateTime = lines.find { it.startsWith("#EXT-X-PROGRAM-DATE-TIME:") }?.substringAfter(":")?.let { + ZonedDateTime.parse(it, DateTimeFormatter.ISO_DATE_TIME) + } + + val segments = mutableListOf() + var currentSegment: Segment? = null + lines.forEach { line -> + when { + line.startsWith("#EXTINF:") -> { + val duration = line.substringAfter(":").substringBefore(",").toDoubleOrNull() + ?: throw Exception("Invalid segment duration format") + currentSegment = Segment(duration = duration) + } + line.startsWith("#") -> { + // Handle other tags if necessary + } + else -> { + currentSegment?.let { + it.uri = line + segments.add(it) + } + currentSegment = null + } + } + } + + return VariantPlaylist(version, targetDuration, mediaSequence, discontinuitySequence, programDateTime, segments) + } + + private fun resolveUrl(baseUrl: String, url: String): String { + return if (URI(url).isAbsolute) url else baseUrl + url + } + + + private fun parseStreamInfo(content: String): StreamInfo { + val attributes = parseAttributes(content) + return StreamInfo( + bandwidth = attributes["BANDWIDTH"]?.toIntOrNull(), + resolution = attributes["RESOLUTION"], + codecs = attributes["CODECS"], + frameRate = attributes["FRAME-RATE"], + videoRange = attributes["VIDEO-RANGE"], + audio = attributes["AUDIO"], + closedCaptions = attributes["CLOSED-CAPTIONS"] + ) + } + + private fun parseMediaRendition(client: ManagedHttpClient, line: String, baseUrl: String): MediaRendition { + val attributes = parseAttributes(line) + val uri = attributes["URI"]!! + val url = resolveUrl(baseUrl, uri) + return MediaRendition( + type = attributes["TYPE"], + uri = url, + groupID = attributes["GROUP-ID"], + language = attributes["LANGUAGE"], + name = attributes["NAME"], + isDefault = attributes["DEFAULT"]?.yesNoToBoolean(), + isAutoSelect = attributes["AUTOSELECT"]?.yesNoToBoolean(), + isForced = attributes["FORCED"]?.yesNoToBoolean() + ) + } + + private fun parseAttributes(content: String): Map { + val attributes = mutableMapOf() + val attributePairs = content.substringAfter(":").splitToSequence(',') + + var currentPair = StringBuilder() + for (pair in attributePairs) { + currentPair.append(pair) + if (currentPair.count { it == '\"' } % 2 == 0) { // Check if the number of quotes is even + val (key, value) = currentPair.toString().split('=') + attributes[key.trim()] = value.trim().removeSurrounding("\"") + currentPair = StringBuilder() // Reset for the next attribute + } else { + currentPair.append(',') // Continue building the current attribute pair + } + } + + return attributes + } + + private val _quoteList = listOf("GROUP-ID", "NAME", "URI", "CODECS", "AUDIO") + private fun shouldQuote(key: String, value: String?): Boolean { + if (value == null) + return false; + + if (value.contains(',')) + return true; + + return _quoteList.contains(key) + } + private fun appendAttributes(stringBuilder: StringBuilder, vararg attributes: Pair) { + attributes.filter { it.second != null } + .joinToString(",") { + val value = it.second + "${it.first}=${if (shouldQuote(it.first, it.second)) "\"$value\"" else value}" + } + .let { if (it.isNotEmpty()) stringBuilder.append(it) } + } + } + + data class StreamInfo( + val bandwidth: Int?, + val resolution: String?, + val codecs: String?, + val frameRate: String?, + val videoRange: String?, + val audio: String?, + val closedCaptions: String? + ) + + data class MediaRendition( + val type: String?, + val uri: String, + val groupID: String?, + val language: String?, + val name: String?, + val isDefault: Boolean?, + val isAutoSelect: Boolean?, + val isForced: Boolean? + ) { + fun toM3U8Line(): String = buildString { + append("#EXT-X-MEDIA:") + appendAttributes(this, + "TYPE" to type, + "URI" to uri, + "GROUP-ID" to groupID, + "LANGUAGE" to language, + "NAME" to name, + "DEFAULT" to isDefault?.toString()?.uppercase(), + "AUTOSELECT" to isAutoSelect?.toString()?.uppercase(), + "FORCED" to isForced?.toString()?.uppercase() + ) + append("\n") + } + } + + data class MasterPlaylist( + val variantPlaylistsRefs: List, + val mediaRenditions: List, + val independentSegments: Boolean + ) { + fun buildM3U8(): String { + val builder = StringBuilder() + builder.append("#EXTM3U\n") + if (independentSegments) { + builder.append("#EXT-X-INDEPENDENT-SEGMENTS\n") + } + + mediaRenditions.forEach { rendition -> + builder.append(rendition.toM3U8Line()) + } + + variantPlaylistsRefs.forEach { variant -> + builder.append(variant.toM3U8Line()) + } + + return builder.toString() + } + } + + data class VariantPlaylistReference(val url: String, val streamInfo: StreamInfo) { + fun toM3U8Line(): String = buildString { + append("#EXT-X-STREAM-INF:") + appendAttributes(this, + "BANDWIDTH" to streamInfo.bandwidth?.toString(), + "RESOLUTION" to streamInfo.resolution, + "CODECS" to streamInfo.codecs, + "FRAME-RATE" to streamInfo.frameRate, + "VIDEO-RANGE" to streamInfo.videoRange, + "AUDIO" to streamInfo.audio, + "CLOSED-CAPTIONS" to streamInfo.closedCaptions + ) + append("\n$url\n") + } + } + + data class VariantPlaylist( + val version: Int, + val targetDuration: Int, + val mediaSequence: Long, + val discontinuitySequence: Int, + val programDateTime: ZonedDateTime?, + val segments: List + ) { + fun buildM3U8(): String = buildString { + append("#EXTM3U\n") + append("#EXT-X-VERSION:$version\n") + append("#EXT-X-TARGETDURATION:$targetDuration\n") + append("#EXT-X-MEDIA-SEQUENCE:$mediaSequence\n") + append("#EXT-X-DISCONTINUITY-SEQUENCE:$discontinuitySequence\n") + programDateTime?.let { + append("#EXT-X-PROGRAM-DATE-TIME:${it.format(DateTimeFormatter.ISO_DATE_TIME)}\n") + } + + segments.forEach { segment -> + append("#EXTINF:${segment.duration},\n") + append(segment.uri + "\n") + } + } + } + + data class Segment( + val duration: Double, + var uri: String = "" + ) +} diff --git a/app/src/main/java/com/futo/platformplayer/parsers/HttpResponseParser.kt b/app/src/main/java/com/futo/platformplayer/parsers/HttpResponseParser.kt new file mode 100644 index 00000000..2209ba24 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/parsers/HttpResponseParser.kt @@ -0,0 +1,64 @@ +package com.futo.platformplayer.parsers + +import com.futo.platformplayer.api.http.server.HttpHeaders +import com.futo.platformplayer.api.http.server.exceptions.EmptyRequestException +import com.futo.platformplayer.readHttpHeaderBytes +import java.io.ByteArrayInputStream +import java.io.InputStream + +class HttpResponseParser : AutoCloseable { + private val _inputStream: InputStream; + + var head: String = ""; + var headers: HttpHeaders = HttpHeaders(); + + var contentType: String? = null; + var transferEncoding: String? = null; + var contentLength: Long = -1L; + + var statusCode: Int = -1; + + constructor(inputStream: InputStream) { + _inputStream = inputStream; + + val headerBytes = inputStream.readHttpHeaderBytes() + ByteArrayInputStream(headerBytes).use { + val reader = it.bufferedReader(Charsets.UTF_8) + head = reader.readLine() ?: throw EmptyRequestException("No head found"); + + val statusLineParts = head.split(" ") + if (statusLineParts.size < 3) { + throw IllegalStateException("Invalid status line") + } + + statusCode = statusLineParts[1].toInt() + + while (true) { + val line = reader.readLine(); + val headerEndIndex = line.indexOf(":"); + if (headerEndIndex == -1) + break; + + val headerKey = line.substring(0, headerEndIndex).lowercase() + val headerValue = line.substring(headerEndIndex + 1).trim(); + headers[headerKey] = headerValue; + + when(headerKey) { + "content-length" -> contentLength = headerValue.toLong(); + "content-type" -> contentType = headerValue; + "transfer-encoding" -> transferEncoding = headerValue; + } + if(line.isNullOrEmpty()) + break; + } + } + } + + override fun close() { + _inputStream.close(); + } + + companion object { + private val TAG = "HttpResponse"; + } +} \ No newline at end of file