mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2025-08-04 15:19:48 +00:00
Merge branch 'download-encrypted-hls' into 'master'
Encrypted HLS Download See merge request videostreaming/grayjay!89
This commit is contained in:
commit
408ce029f8
2 changed files with 96 additions and 13 deletions
|
@ -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,16 +60,21 @@ 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
|
||||||
import java.lang.Thread.sleep
|
import java.lang.Thread.sleep
|
||||||
|
import java.nio.ByteBuffer
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import java.util.concurrent.Executors
|
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 +570,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 +593,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, mutableMapOf("Cookie" to "sails.sid=s%3AeSKom53v4W3_0CliWJFFMj9k3hcAuyhx.Nf9lF1sUSQ0GUvCKBOM64bsV%2BZMOkiKke43eHO6gTZI;"))
|
||||||
|
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 +612,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, index) { 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)
|
||||||
|
@ -630,10 +652,8 @@ class VideoDownload {
|
||||||
|
|
||||||
private suspend fun combineSegments(context: Context, segmentFiles: List<File>, targetFile: File) = withContext(Dispatchers.IO) {
|
private suspend fun combineSegments(context: Context, segmentFiles: List<File>, targetFile: File) = withContext(Dispatchers.IO) {
|
||||||
suspendCancellableCoroutine { continuation ->
|
suspendCancellableCoroutine { continuation ->
|
||||||
val fileList = File(context.cacheDir, "fileList-${UUID.randomUUID()}.txt")
|
val cmd =
|
||||||
fileList.writeText(segmentFiles.joinToString("\n") { "file '${it.absolutePath}'" })
|
"-i \"concat:${segmentFiles.joinToString("|")}\" -c copy \"${targetFile.absolutePath}\""
|
||||||
|
|
||||||
val cmd = "-f concat -safe 0 -i \"${fileList.absolutePath}\" -c copy \"${targetFile.absolutePath}\""
|
|
||||||
|
|
||||||
val statisticsCallback = StatisticsCallback { _ ->
|
val statisticsCallback = StatisticsCallback { _ ->
|
||||||
//TODO: Show progress?
|
//TODO: Show progress?
|
||||||
|
@ -643,7 +663,6 @@ class VideoDownload {
|
||||||
val session = FFmpegKit.executeAsync(cmd,
|
val session = FFmpegKit.executeAsync(cmd,
|
||||||
{ session ->
|
{ session ->
|
||||||
if (ReturnCode.isSuccess(session.returnCode)) {
|
if (ReturnCode.isSuccess(session.returnCode)) {
|
||||||
fileList.delete()
|
|
||||||
continuation.resumeWith(Result.success(Unit))
|
continuation.resumeWith(Result.success(Unit))
|
||||||
} else {
|
} else {
|
||||||
val errorMessage = if (ReturnCode.isCancel(session.returnCode)) {
|
val errorMessage = if (ReturnCode.isCancel(session.returnCode)) {
|
||||||
|
@ -651,7 +670,6 @@ class VideoDownload {
|
||||||
} else {
|
} else {
|
||||||
"Command failed with state '${session.state}' and return code ${session.returnCode}, stack trace ${session.failStackTrace}"
|
"Command failed with state '${session.state}' and return code ${session.returnCode}, stack trace ${session.failStackTrace}"
|
||||||
}
|
}
|
||||||
fileList.delete()
|
|
||||||
continuation.resumeWithException(RuntimeException(errorMessage))
|
continuation.resumeWithException(RuntimeException(errorMessage))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -771,7 +789,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, 0, 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 +816,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?, index: Int, 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 +860,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 +872,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 +898,21 @@ class VideoDownload {
|
||||||
result.body.close()
|
result.body.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (decryptionInfo != null) {
|
||||||
|
var iv = decryptionInfo.iv
|
||||||
|
if (iv == null) {
|
||||||
|
iv = ByteBuffer.allocate(16)
|
||||||
|
.putLong(0L)
|
||||||
|
.putLong(index.toLong())
|
||||||
|
.array()
|
||||||
|
}
|
||||||
|
|
||||||
|
val decryptedData = decryptSegment(segmentBuffer.toByteArray(), decryptionInfo.key, iv!!)
|
||||||
|
fileStream.write(decryptedData)
|
||||||
|
} else {
|
||||||
|
fileStream.write(segmentBuffer.toByteArray())
|
||||||
|
}
|
||||||
|
|
||||||
onProgress(sourceLength, totalRead, 0);
|
onProgress(sourceLength, totalRead, 0);
|
||||||
return sourceLength;
|
return sourceLength;
|
||||||
}
|
}
|
||||||
|
@ -1167,7 +1226,7 @@ class VideoDownload {
|
||||||
else if (container.contains("audio/webm"))
|
else if (container.contains("audio/webm"))
|
||||||
return "webm";
|
return "webm";
|
||||||
else if (container == "application/vnd.apple.mpegurl")
|
else if (container == "application/vnd.apple.mpegurl")
|
||||||
return "mp4a";
|
return "mp4";
|
||||||
else
|
else
|
||||||
return "audio";// throw IllegalStateException("Unknown container: " + container)
|
return "audio";// throw IllegalStateException("Unknown container: " + container)
|
||||||
}
|
}
|
||||||
|
|
|
@ -61,7 +61,25 @@ 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 ->
|
||||||
|
DecryptionInfo(k, iv)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 +104,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 +386,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 +399,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…
Add table
Add a link
Reference in a new issue