mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2025-08-06 08:10:17 +00:00
Finished implementation of HLS proxying.
This commit is contained in:
parent
8661ff88c0
commit
2246f8cee2
10 changed files with 706 additions and 67 deletions
|
@ -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)
|
||||||
|
}
|
|
@ -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"
|
||||||
|
}
|
|
@ -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") {
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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();
|
||||||
|
|
266
app/src/main/java/com/futo/platformplayer/parsers/HLS.kt
Normal file
266
app/src/main/java/com/futo/platformplayer/parsers/HLS.kt
Normal 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 = ""
|
||||||
|
)
|
||||||
|
}
|
|
@ -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";
|
||||||
|
}
|
||||||
|
}
|
Loading…
Add table
Add a link
Reference in a new issue