mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2025-04-20 11:35:46 +00:00
Merge branch 'hls-live-stream-proxy' into 'master'
Finished implementation of HLS proxying. See merge request videostreaming/grayjay!5
This commit is contained in:
commit
dfafac7d99
10 changed files with 706 additions and 67 deletions
|
@ -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<InetAddress>, 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)
|
||||
}
|
|
@ -12,4 +12,8 @@ inline fun <reified T, R> Any.assume(cb: (T) -> R): R? {
|
|||
if(result != null)
|
||||
return cb(result);
|
||||
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");
|
||||
}
|
||||
|
||||
val responseHeader = HttpResponse(status, headers);
|
||||
|
||||
val responseHeader = HttpResponse(status, headersToRespond);
|
||||
responseStream.write(responseHeader.getHttpHeaderBytes());
|
||||
|
||||
if(method != "HEAD") {
|
||||
|
|
|
@ -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<HttpHandler>();
|
||||
private val _handlers = hashMapOf<String, HashMap<String, HttpHandler>>()
|
||||
private val _headHandlers = hashMapOf<String, HttpHandler>()
|
||||
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<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;
|
||||
}
|
||||
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<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) {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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<String, String>();
|
||||
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<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 {
|
||||
this.content = body;
|
||||
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.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<Boolean>();
|
||||
val onActiveDeviceTimeChanged = Event1<Double>();
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<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> {
|
||||
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();
|
||||
|
|
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
Reference in a new issue