mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2025-05-05 17:14:37 +02:00
add support for downloading encrypted HLS streams
Changelog: changed
This commit is contained in:
parent
78f5169880
commit
9014fb581d
@ -47,6 +47,7 @@ import com.futo.platformplayer.states.StatePlatform
|
|||||||
import com.futo.platformplayer.states.StatePlugins
|
import com.futo.platformplayer.states.StatePlugins
|
||||||
import com.futo.platformplayer.toHumanBitrate
|
import com.futo.platformplayer.toHumanBitrate
|
||||||
import com.futo.platformplayer.toHumanBytesSpeed
|
import com.futo.platformplayer.toHumanBytesSpeed
|
||||||
|
import com.futo.polycentric.core.hexStringToByteArray
|
||||||
import hasAnySource
|
import hasAnySource
|
||||||
import isDownloadable
|
import isDownloadable
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
@ -59,6 +60,7 @@ import kotlinx.coroutines.suspendCancellableCoroutine
|
|||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.Contextual
|
import kotlinx.serialization.Contextual
|
||||||
import kotlinx.serialization.Transient
|
import kotlinx.serialization.Transient
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileOutputStream
|
import java.io.FileOutputStream
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
@ -69,6 +71,9 @@ import java.util.concurrent.Executors
|
|||||||
import java.util.concurrent.ForkJoinPool
|
import java.util.concurrent.ForkJoinPool
|
||||||
import java.util.concurrent.ForkJoinTask
|
import java.util.concurrent.ForkJoinTask
|
||||||
import java.util.concurrent.ThreadLocalRandom
|
import java.util.concurrent.ThreadLocalRandom
|
||||||
|
import javax.crypto.Cipher
|
||||||
|
import javax.crypto.spec.IvParameterSpec
|
||||||
|
import javax.crypto.spec.SecretKeySpec
|
||||||
import kotlin.coroutines.resumeWithException
|
import kotlin.coroutines.resumeWithException
|
||||||
import kotlin.time.times
|
import kotlin.time.times
|
||||||
|
|
||||||
@ -564,6 +569,14 @@ class VideoDownload {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun decryptSegment(encryptedSegment: ByteArray, key: ByteArray, iv: ByteArray): ByteArray {
|
||||||
|
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
||||||
|
val secretKey = SecretKeySpec(key, "AES")
|
||||||
|
val ivSpec = IvParameterSpec(iv)
|
||||||
|
cipher.init(Cipher.DECRYPT_MODE, secretKey, ivSpec)
|
||||||
|
return cipher.doFinal(encryptedSegment)
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun downloadHlsSource(context: Context, name: String, client: ManagedHttpClient, hlsUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
|
private suspend fun downloadHlsSource(context: Context, name: String, client: ManagedHttpClient, hlsUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
|
||||||
if(targetFile.exists())
|
if(targetFile.exists())
|
||||||
targetFile.delete();
|
targetFile.delete();
|
||||||
@ -579,6 +592,14 @@ class VideoDownload {
|
|||||||
?: throw Exception("Variant playlist content is empty")
|
?: throw Exception("Variant playlist content is empty")
|
||||||
|
|
||||||
val variantPlaylist = HLS.parseVariantPlaylist(vpContent, hlsUrl)
|
val variantPlaylist = HLS.parseVariantPlaylist(vpContent, hlsUrl)
|
||||||
|
val decryptionInfo: DecryptionInfo? = if (variantPlaylist.decryptionInfo != null) {
|
||||||
|
val keyResponse = client.get(variantPlaylist.decryptionInfo.keyUrl)
|
||||||
|
check(keyResponse.isOk) { "HLS request failed for decryption key: ${keyResponse.code}" }
|
||||||
|
DecryptionInfo(keyResponse.body!!.bytes(), variantPlaylist.decryptionInfo.iv.hexStringToByteArray())
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
variantPlaylist.segments.forEachIndexed { index, segment ->
|
variantPlaylist.segments.forEachIndexed { index, segment ->
|
||||||
if (segment !is HLS.MediaSegment) {
|
if (segment !is HLS.MediaSegment) {
|
||||||
return@forEachIndexed
|
return@forEachIndexed
|
||||||
@ -590,7 +611,7 @@ class VideoDownload {
|
|||||||
try {
|
try {
|
||||||
segmentFiles.add(segmentFile)
|
segmentFiles.add(segmentFile)
|
||||||
|
|
||||||
val segmentLength = downloadSource_Sequential(client, outputStream, segment.uri) { segmentLength, totalRead, lastSpeed ->
|
val segmentLength = downloadSource_Sequential(client, outputStream, segment.uri, if (index == 0) null else decryptionInfo) { segmentLength, totalRead, lastSpeed ->
|
||||||
val averageSegmentLength = if (index == 0) segmentLength else downloadedTotalLength / index
|
val averageSegmentLength = if (index == 0) segmentLength else downloadedTotalLength / index
|
||||||
val expectedTotalLength = averageSegmentLength * (variantPlaylist.segments.size - 1) + segmentLength
|
val expectedTotalLength = averageSegmentLength * (variantPlaylist.segments.size - 1) + segmentLength
|
||||||
onProgress(expectedTotalLength, downloadedTotalLength + totalRead, lastSpeed)
|
onProgress(expectedTotalLength, downloadedTotalLength + totalRead, lastSpeed)
|
||||||
@ -771,7 +792,7 @@ class VideoDownload {
|
|||||||
else {
|
else {
|
||||||
Logger.i(TAG, "Download $name Sequential");
|
Logger.i(TAG, "Download $name Sequential");
|
||||||
try {
|
try {
|
||||||
sourceLength = downloadSource_Sequential(client, fileStream, videoUrl, onProgress);
|
sourceLength = downloadSource_Sequential(client, fileStream, videoUrl, null, onProgress);
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
Logger.w(TAG, "Failed to download sequentially (url = $videoUrl)")
|
Logger.w(TAG, "Failed to download sequentially (url = $videoUrl)")
|
||||||
throw e
|
throw e
|
||||||
@ -798,7 +819,31 @@ class VideoDownload {
|
|||||||
}
|
}
|
||||||
return sourceLength!!;
|
return sourceLength!!;
|
||||||
}
|
}
|
||||||
private fun downloadSource_Sequential(client: ManagedHttpClient, fileStream: FileOutputStream, url: String, onProgress: (Long, Long, Long) -> Unit): Long {
|
|
||||||
|
data class DecryptionInfo(
|
||||||
|
val key: ByteArray,
|
||||||
|
val iv: ByteArray
|
||||||
|
) {
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (this === other) return true
|
||||||
|
if (javaClass != other?.javaClass) return false
|
||||||
|
|
||||||
|
other as DecryptionInfo
|
||||||
|
|
||||||
|
if (!key.contentEquals(other.key)) return false
|
||||||
|
if (!iv.contentEquals(other.iv)) return false
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode(): Int {
|
||||||
|
var result = key.contentHashCode()
|
||||||
|
result = 31 * result + iv.contentHashCode()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun downloadSource_Sequential(client: ManagedHttpClient, fileStream: FileOutputStream, url: String, decryptionInfo: DecryptionInfo?, onProgress: (Long, Long, Long) -> Unit): Long {
|
||||||
val progressRate: Int = 4096 * 5;
|
val progressRate: Int = 4096 * 5;
|
||||||
var lastProgressCount: Int = 0;
|
var lastProgressCount: Int = 0;
|
||||||
val speedRate: Int = 4096 * 5;
|
val speedRate: Int = 4096 * 5;
|
||||||
@ -818,6 +863,8 @@ class VideoDownload {
|
|||||||
val sourceLength = result.body.contentLength();
|
val sourceLength = result.body.contentLength();
|
||||||
val sourceStream = result.body.byteStream();
|
val sourceStream = result.body.byteStream();
|
||||||
|
|
||||||
|
val segmentBuffer = ByteArrayOutputStream()
|
||||||
|
|
||||||
var totalRead: Long = 0;
|
var totalRead: Long = 0;
|
||||||
try {
|
try {
|
||||||
var read: Int;
|
var read: Int;
|
||||||
@ -828,7 +875,7 @@ class VideoDownload {
|
|||||||
if (read < 0)
|
if (read < 0)
|
||||||
break;
|
break;
|
||||||
|
|
||||||
fileStream.write(buffer, 0, read);
|
segmentBuffer.write(buffer, 0, read);
|
||||||
|
|
||||||
totalRead += read;
|
totalRead += read;
|
||||||
|
|
||||||
@ -854,6 +901,14 @@ class VideoDownload {
|
|||||||
result.body.close()
|
result.body.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (decryptionInfo != null) {
|
||||||
|
val decryptedData =
|
||||||
|
decryptSegment(segmentBuffer.toByteArray(), decryptionInfo.key, decryptionInfo.iv)
|
||||||
|
fileStream.write(decryptedData)
|
||||||
|
} else {
|
||||||
|
fileStream.write(segmentBuffer.toByteArray())
|
||||||
|
}
|
||||||
|
|
||||||
onProgress(sourceLength, totalRead, 0);
|
onProgress(sourceLength, totalRead, 0);
|
||||||
return sourceLength;
|
return sourceLength;
|
||||||
}
|
}
|
||||||
|
@ -61,7 +61,27 @@ class HLS {
|
|||||||
val playlistType = lines.find { it.startsWith("#EXT-X-PLAYLIST-TYPE:") }?.substringAfter(":")
|
val playlistType = lines.find { it.startsWith("#EXT-X-PLAYLIST-TYPE:") }?.substringAfter(":")
|
||||||
val streamInfo = lines.find { it.startsWith("#EXT-X-STREAM-INF:") }?.let { parseStreamInfo(it) }
|
val streamInfo = lines.find { it.startsWith("#EXT-X-STREAM-INF:") }?.let { parseStreamInfo(it) }
|
||||||
|
|
||||||
|
val keyInfo =
|
||||||
|
lines.find { it.startsWith("#EXT-X-KEY:") }?.substringAfter(":")?.split(",")
|
||||||
|
|
||||||
|
val key = keyInfo?.find { it.startsWith("URI=") }?.substringAfter("=")?.trim('"')
|
||||||
|
val iv =
|
||||||
|
keyInfo?.find { it.startsWith("IV=") }?.substringAfter("=")?.substringAfter("x")
|
||||||
|
|
||||||
|
val decryptionInfo: DecryptionInfo? = key?.let { k ->
|
||||||
|
iv?.let { i ->
|
||||||
|
DecryptionInfo(k, i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val initSegment =
|
||||||
|
lines.find { it.startsWith("#EXT-X-MAP:") }?.substringAfter(":")?.split(",")?.get(0)
|
||||||
|
?.substringAfter("=")?.trim('"')
|
||||||
val segments = mutableListOf<Segment>()
|
val segments = mutableListOf<Segment>()
|
||||||
|
if (initSegment != null) {
|
||||||
|
segments.add(MediaSegment(0.0, resolveUrl(sourceUrl, initSegment)))
|
||||||
|
}
|
||||||
|
|
||||||
var currentSegment: MediaSegment? = null
|
var currentSegment: MediaSegment? = null
|
||||||
lines.forEach { line ->
|
lines.forEach { line ->
|
||||||
when {
|
when {
|
||||||
@ -86,7 +106,7 @@ class HLS {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return VariantPlaylist(version, targetDuration, mediaSequence, discontinuitySequence, programDateTime, playlistType, streamInfo, segments)
|
return VariantPlaylist(version, targetDuration, mediaSequence, discontinuitySequence, programDateTime, playlistType, streamInfo, segments, decryptionInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun parseAndGetVideoSources(source: Any, content: String, url: String): List<HLSVariantVideoUrlSource> {
|
fun parseAndGetVideoSources(source: Any, content: String, url: String): List<HLSVariantVideoUrlSource> {
|
||||||
@ -368,6 +388,11 @@ class HLS {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class DecryptionInfo(
|
||||||
|
val keyUrl: String,
|
||||||
|
val iv: String
|
||||||
|
)
|
||||||
|
|
||||||
data class VariantPlaylist(
|
data class VariantPlaylist(
|
||||||
val version: Int?,
|
val version: Int?,
|
||||||
val targetDuration: Int?,
|
val targetDuration: Int?,
|
||||||
@ -376,7 +401,8 @@ class HLS {
|
|||||||
val programDateTime: ZonedDateTime?,
|
val programDateTime: ZonedDateTime?,
|
||||||
val playlistType: String?,
|
val playlistType: String?,
|
||||||
val streamInfo: StreamInfo?,
|
val streamInfo: StreamInfo?,
|
||||||
val segments: List<Segment>
|
val segments: List<Segment>,
|
||||||
|
val decryptionInfo: DecryptionInfo? = null
|
||||||
) {
|
) {
|
||||||
fun buildM3U8(): String = buildString {
|
fun buildM3U8(): String = buildString {
|
||||||
append("#EXTM3U\n")
|
append("#EXTM3U\n")
|
||||||
|
Loading…
x
Reference in New Issue
Block a user