Finished implementation of HLS proxying.

This commit is contained in:
Koen 2023-11-21 15:12:09 +00:00
commit 2246f8cee2
10 changed files with 706 additions and 67 deletions

View file

@ -1,11 +1,15 @@
package com.futo.platformplayer package com.futo.platformplayer
import com.google.common.base.CharMatcher 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.Inet4Address
import java.net.InetAddress import java.net.InetAddress
import java.net.InetSocketAddress import java.net.InetSocketAddress
import java.net.Socket import java.net.Socket
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.nio.charset.Charset
private const val IPV4_PART_COUNT = 4; private const val IPV4_PART_COUNT = 4;
@ -273,3 +277,46 @@ fun getConnectedSocket(addresses: List<InetAddress>, port: Int): Socket? {
return connectedSocket; 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)
}

View file

@ -13,3 +13,7 @@ inline fun <reified T, R> Any.assume(cb: (T) -> R): R? {
return cb(result); return cb(result);
return null; return null;
} }
fun String?.yesNoToBoolean(): Boolean {
return this?.uppercase() == "YES"
}

View file

@ -219,8 +219,7 @@ class HttpContext : AutoCloseable {
headersToRespond.put("keep-alive", "timeout=5, max=1000"); headersToRespond.put("keep-alive", "timeout=5, max=1000");
} }
val responseHeader = HttpResponse(status, headers); val responseHeader = HttpResponse(status, headersToRespond);
responseStream.write(responseHeader.getHttpHeaderBytes()); responseStream.write(responseHeader.getHttpHeaderBytes());
if(method != "HEAD") { if(method != "HEAD") {

View file

@ -17,6 +17,7 @@ import java.util.*
import java.util.concurrent.ExecutorService import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors import java.util.concurrent.Executors
import java.util.stream.IntStream.range import java.util.stream.IntStream.range
import kotlin.collections.HashMap
class ManagedHttpServer(private val _requestedPort: Int = 0) { class ManagedHttpServer(private val _requestedPort: Int = 0) {
private val _client : ManagedHttpClient = ManagedHttpClient(); private val _client : ManagedHttpClient = ManagedHttpClient();
@ -28,7 +29,8 @@ class ManagedHttpServer(private val _requestedPort: Int = 0) {
var port = 0 var port = 0
private set; private set;
private val _handlers = mutableListOf<HttpHandler>(); private val _handlers = hashMapOf<String, HashMap<String, HttpHandler>>()
private val _headHandlers = hashMapOf<String, HttpHandler>()
private var _workerPool: ExecutorService? = null; private var _workerPool: ExecutorService? = null;
@Synchronized @Synchronized
@ -114,32 +116,61 @@ class ManagedHttpServer(private val _requestedPort: Int = 0) {
fun getHandler(method: String, path: String) : HttpHandler? { fun getHandler(method: String, path: String) : HttpHandler? {
synchronized(_handlers) { synchronized(_handlers) {
//TODO: Support regex paths? if (method == "HEAD") {
if(method == "HEAD") return _headHandlers[path]
return _handlers.firstOrNull { it.path == path && (it.allowHEAD || it.method == "HEAD") } }
return _handlers.firstOrNull { it.method == method && it.path == path };
val handlerMap = _handlers[method] ?: return null
return handlerMap[path]
} }
} }
fun addHandler(handler: HttpHandler, withHEAD: Boolean = false) : HttpHandler { fun addHandler(handler: HttpHandler, withHEAD: Boolean = false) : HttpHandler {
synchronized(_handlers) { synchronized(_handlers) {
_handlers.add(handler);
handler.allowHEAD = withHEAD; handler.allowHEAD = withHEAD;
var handlerMap: HashMap<String, HttpHandler>? = _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; return handler;
} }
fun removeHandler(method: String, path: String) { fun removeHandler(method: String, path: String) {
synchronized(_handlers) { synchronized(_handlers) {
val handler = getHandler(method, path); val handlerMap = _handlers[method] ?: return
if(handler != null) val handler = handlerMap.remove(path) ?: return
_handlers.remove(handler); if (method == "HEAD" || handler.allowHEAD) {
_headHandlers.remove(path)
}
} }
} }
fun removeAllHandlers(tag: String? = null) { fun removeAllHandlers(tag: String? = null) {
synchronized(_handlers) { synchronized(_handlers) {
if(tag == null) if(tag == null)
_handlers.clear(); _handlers.clear();
else else {
_handlers.removeIf { it.tag == tag }; for (pair in _handlers) {
val toRemove = ArrayList<String>()
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) { fun addBridgeHandlers(obj: Any, tag: String? = null) {

View file

@ -15,6 +15,7 @@ abstract class HttpHandler(val method: String, val path: String) {
headers.put(key, value); headers.put(key, value);
return this; return this;
} }
fun withContentType(contentType: String) = withHeader("Content-Type", contentType); fun withContentType(contentType: String) = withHeader("Content-Type", contentType);
fun withTag(tag: String) : HttpHandler { fun withTag(tag: String) : HttpHandler {

View file

@ -1,12 +1,20 @@
package com.futo.platformplayer.api.http.server.handlers package com.futo.platformplayer.api.http.server.handlers
import android.net.Uri import android.net.Uri
import android.util.Log
import com.futo.platformplayer.api.http.server.HttpContext import com.futo.platformplayer.api.http.server.HttpContext
import com.futo.platformplayer.api.http.server.HttpHeaders import com.futo.platformplayer.api.http.server.HttpHeaders
import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.logging.Logger 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 content: String? = null;
var contentType: 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 _injectHost = false;
private var _injectReferer = false; private var _injectReferer = false;
private val _client = ManagedHttpClient(); private val _client = ManagedHttpClient();
override fun handle(context: HttpContext) { override fun handle(context: HttpContext) {
if (useTcp) {
handleWithTcp(context)
} else {
handleWithOkHttp(context)
}
}
private fun handleWithOkHttp(context: HttpContext) {
val proxyHeaders = HashMap<String, String>(); val proxyHeaders = HashMap<String, String>();
for (header in context.headers.filter { !_ignoreRequestHeaders.contains(it.key.lowercase()) }) for (header in context.headers.filter { !_ignoreRequestHeaders.contains(it.key.lowercase()) })
proxyHeaders[header.key] = header.value; proxyHeaders[header.key] = header.value;
@ -35,8 +50,8 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String): Htt
proxyHeaders.put("Referer", targetUrl); proxyHeaders.put("Referer", targetUrl);
val useMethod = if (method == "inherit") context.method else method; val useMethod = if (method == "inherit") context.method else method;
Logger.i(TAG, "Proxied Request ${useMethod}: ${targetUrl}"); Logger.i(TAG, "handleWithOkHttp Proxied Request ${useMethod}: ${targetUrl}");
Logger.i(TAG, "Headers:" + proxyHeaders.map { "${it.key}: ${it.value}" }.joinToString("\n")); Logger.i(TAG, "handleWithOkHttp Headers:" + proxyHeaders.map { "${it.key}: ${it.value}" }.joinToString("\n"));
val resp = when (useMethod) { val resp = when (useMethod) {
"GET" -> _client.get(targetUrl, proxyHeaders); "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}]"); 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) for(newHeader in headers)
headersFiltered.put(newHeader.key, newHeader.value); 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<String, String>();
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 { fun withContent(body: String) : HttpProxyHandler {
this.content = body; this.content = body;
return this; return this;

View file

@ -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("&", "&amp;")}\",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("&", "&amp;")}\",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("&", "&amp;"))
}
return hlsBuilder.toString()
}
}
}

View file

@ -4,6 +4,7 @@ import android.content.ContentResolver
import android.content.Context import android.content.Context
import android.os.Looper import android.os.Looper
import com.futo.platformplayer.* 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.ManagedHttpServer
import com.futo.platformplayer.api.http.server.handlers.* import com.futo.platformplayer.api.http.server.handlers.*
import com.futo.platformplayer.api.media.models.streams.sources.* 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.exceptions.UnsupportedCastException
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.CastingDeviceInfo import com.futo.platformplayer.models.CastingDeviceInfo
import com.futo.platformplayer.parsers.HLS
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import kotlinx.coroutines.* import kotlinx.coroutines.*
import java.net.InetAddress import java.net.InetAddress
@ -45,6 +47,7 @@ class StateCasting {
val onActiveDevicePlayChanged = Event1<Boolean>(); val onActiveDevicePlayChanged = Event1<Boolean>();
val onActiveDeviceTimeChanged = Event1<Double>(); val onActiveDeviceTimeChanged = Event1<Double>();
var activeDevice: CastingDevice? = null; var activeDevice: CastingDevice? = null;
private val _client = ManagedHttpClient();
val isCasting: Boolean get() = activeDevice != null; val isCasting: Boolean get() = activeDevice != null;
@ -354,14 +357,22 @@ class StateCasting {
} }
} else { } else {
if (videoSource is IVideoUrlSource) if (videoSource is IVideoUrlSource)
ad.loadVideo("BUFFERED", videoSource.container, videoSource.getVideoUrl(), resumePosition, video.duration.toDouble()); ad.loadVideo(if (video.isLive) "LIVE" else "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());
else if (audioSource is IAudioUrlSource) else if (audioSource is IAudioUrlSource)
ad.loadVideo("BUFFERED", audioSource.container, audioSource.getAudioUrl(), resumePosition, video.duration.toDouble()); ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioSource.getAudioUrl(), resumePosition, video.duration.toDouble());
else if(audioSource is IHLSManifestAudioSource) else if(videoSource is IHLSManifestSource) {
ad.loadVideo("BUFFERED", audioSource.container, audioSource.url, resumePosition, video.duration.toDouble()); if (ad is ChromecastCastingDevice) {
else if (videoSource is LocalVideoSource) 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); castLocalVideo(video, videoSource, resumePosition);
else if (audioSource is LocalAudioSource) else if (audioSource is LocalAudioSource)
castLocalAudio(video, audioSource, resumePosition); castLocalAudio(video, audioSource, resumePosition);
@ -405,7 +416,7 @@ class StateCasting {
private fun castLocalVideo(video: IPlatformVideoDetails, videoSource: LocalVideoSource, resumePosition: Double) : List<String> { private fun castLocalVideo(video: IPlatformVideoDetails, videoSource: LocalVideoSource, resumePosition: Double) : List<String> {
val ad = activeDevice ?: return listOf(); 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 id = UUID.randomUUID();
val videoPath = "/video-${id}" val videoPath = "/video-${id}"
val videoUrl = url + videoPath; val videoUrl = url + videoPath;
@ -424,7 +435,7 @@ class StateCasting {
private fun castLocalAudio(video: IPlatformVideoDetails, audioSource: LocalAudioSource, resumePosition: Double) : List<String> { private fun castLocalAudio(video: IPlatformVideoDetails, audioSource: LocalAudioSource, resumePosition: Double) : List<String> {
val ad = activeDevice ?: return listOf(); 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 id = UUID.randomUUID();
val audioPath = "/audio-${id}" val audioPath = "/audio-${id}"
val audioUrl = url + audioPath; val audioUrl = url + audioPath;
@ -444,7 +455,7 @@ class StateCasting {
private fun castLocalDash(video: IPlatformVideoDetails, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?, resumePosition: Double) : List<String> { private fun castLocalDash(video: IPlatformVideoDetails, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?, resumePosition: Double) : List<String> {
val ad = activeDevice ?: return listOf(); 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 id = UUID.randomUUID();
val dashPath = "/dash-${id}" 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<String> { private suspend fun castDashDirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double) : List<String> {
val ad = activeDevice ?: return listOf(); 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 id = UUID.randomUUID();
val subtitlePath = "/subtitle-${id}"; val subtitlePath = "/subtitle-${id}";
@ -547,11 +558,126 @@ class StateCasting {
return listOf(videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: ""); return listOf(videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: "");
} }
private fun castHlsIndirect(video: IPlatformVideoDetails, sourceUrl: String, resumePosition: Double): List<String> {
_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<HLS.VariantPlaylistReference>()
val newMediaRenditions = arrayListOf<HLS.MediaRendition>()
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<HLS.Segment>()
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<String> { private suspend fun castDashIndirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double) : List<String> {
val ad = activeDevice ?: return listOf(); val ad = activeDevice ?: return listOf();
val proxyStreams = ad !is FastCastCastingDevice; 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"); Logger.i(TAG, "DASH url: $url");
val id = UUID.randomUUID(); val id = UUID.randomUUID();

View file

@ -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<VariantPlaylistReference>()
val mediaRenditions = mutableListOf<MediaRendition>()
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<Segment>()
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<String, String> {
val attributes = mutableMapOf<String, String>()
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<String, String?>) {
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<VariantPlaylistReference>,
val mediaRenditions: List<MediaRendition>,
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<Segment>
) {
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 = ""
)
}

View file

@ -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";
}
}