mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2025-04-19 19:14:51 +00:00
add support for hls sources with request modifiers
add support for encrypted hls streams Changelog: changed
This commit is contained in:
parent
e36047c890
commit
2697107f76
5 changed files with 195 additions and 54 deletions
|
@ -28,6 +28,9 @@ import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
|
|||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawAudioSource
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawSource
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSHLSManifestAudioSource
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSHLSManifestSource
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource
|
||||
import com.futo.platformplayer.downloads.VideoLocal
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.SubscriptionGroupFragment
|
||||
import com.futo.platformplayer.helpers.VideoHelper
|
||||
|
@ -269,12 +272,17 @@ class UISlideOverlays {
|
|||
|
||||
}
|
||||
|
||||
fun showHlsPicker(video: IPlatformVideoDetails, source: Any, sourceUrl: String, container: ViewGroup): SlideUpMenuOverlay {
|
||||
fun showHlsPicker(video: IPlatformVideoDetails, source: JSSource, sourceUrl: String, container: ViewGroup): SlideUpMenuOverlay {
|
||||
val items = arrayListOf<View>(LoaderView(container.context))
|
||||
val slideUpMenuOverlay = SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.download_video), null, true, items)
|
||||
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
val masterPlaylistResponse = ManagedHttpClient().get(sourceUrl)
|
||||
val masterPlaylistResponse = if (source.hasRequestModifier) {
|
||||
val request = source.getRequestModifier()!!.modifyRequest(sourceUrl, mapOf())
|
||||
ManagedHttpClient().get(request.url!!, request.headers.toMutableMap())
|
||||
} else {
|
||||
ManagedHttpClient().get(sourceUrl)
|
||||
}
|
||||
check(masterPlaylistResponse.isOk) { "Failed to get master playlist: ${masterPlaylistResponse.code}" }
|
||||
|
||||
val masterPlaylistContent = masterPlaylistResponse.body?.string()
|
||||
|
@ -355,7 +363,7 @@ class UISlideOverlays {
|
|||
|
||||
slideUpMenuOverlay.onOK.subscribe {
|
||||
//TODO: Fix SubtitleRawSource issue
|
||||
StateDownloads.instance.download(video, selectedVideoVariant, selectedAudioVariant, null);
|
||||
StateDownloads.instance.download(video, selectedVideoVariant, selectedAudioVariant, null, if (source is JSSource) source.hasRequestModifier else false);
|
||||
slideUpMenuOverlay.hide()
|
||||
}
|
||||
|
||||
|
@ -475,7 +483,7 @@ class UISlideOverlays {
|
|||
)
|
||||
}
|
||||
|
||||
is IHLSManifestSource -> {
|
||||
is JSHLSManifestSource -> {
|
||||
SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_movie,
|
||||
|
@ -549,7 +557,7 @@ class UISlideOverlays {
|
|||
);
|
||||
}
|
||||
|
||||
is IHLSManifestAudioSource -> {
|
||||
is JSHLSManifestAudioSource -> {
|
||||
SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_movie,
|
||||
|
@ -614,13 +622,13 @@ class UISlideOverlays {
|
|||
|
||||
menu.onOK.subscribe {
|
||||
val sv = selectedVideo
|
||||
if (sv is IHLSManifestSource) {
|
||||
if (sv is JSHLSManifestSource) {
|
||||
showHlsPicker(video, sv, sv.url, container)
|
||||
return@subscribe
|
||||
}
|
||||
|
||||
val sa = selectedAudio
|
||||
if (sa is IHLSManifestAudioSource) {
|
||||
if (sa is JSHLSManifestAudioSource) {
|
||||
showHlsPicker(video, sa, sa.url, container)
|
||||
return@subscribe
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ package com.futo.platformplayer.api.media.models.streams.sources
|
|||
|
||||
import android.net.Uri
|
||||
import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource
|
||||
|
||||
class HLSVariantVideoUrlSource(
|
||||
override val name: String,
|
||||
|
@ -12,7 +13,8 @@ class HLSVariantVideoUrlSource(
|
|||
override val bitrate: Int?,
|
||||
override val duration: Long,
|
||||
override val priority: Boolean,
|
||||
val url: String
|
||||
val url: String,
|
||||
val jsSource: JSSource? = null,
|
||||
) : IVideoUrlSource {
|
||||
override fun getVideoUrl(): String {
|
||||
return url
|
||||
|
@ -27,7 +29,8 @@ class HLSVariantAudioUrlSource(
|
|||
override val language: String,
|
||||
override val duration: Long?,
|
||||
override val priority: Boolean,
|
||||
val url: String
|
||||
val url: String,
|
||||
val jsSource: JSSource? = null,
|
||||
) : IAudioUrlSource {
|
||||
override fun getAudioUrl(): String {
|
||||
return url
|
||||
|
|
|
@ -10,11 +10,10 @@ import com.futo.platformplayer.api.http.ManagedHttpClient
|
|||
import com.futo.platformplayer.api.media.PlatformID
|
||||
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.AudioUrlSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantAudioUrlSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantVideoUrlSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestAudioSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.LocalAudioSource
|
||||
|
@ -28,12 +27,11 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
|
|||
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
|
||||
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideoDetails
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSRequestExecutor
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSVideo
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.IJSDashManifestRawSource
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawAudioSource
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawSource
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSHLSManifestAudioSource
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSHLSManifestSource
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.exceptions.DownloadException
|
||||
|
@ -44,9 +42,9 @@ import com.futo.platformplayer.parsers.HLS
|
|||
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
|
||||
import com.futo.platformplayer.states.StateDownloads
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.platformplayer.states.StatePlugins
|
||||
import com.futo.platformplayer.toHumanBitrate
|
||||
import com.futo.platformplayer.toHumanBytesSpeed
|
||||
import com.futo.polycentric.core.hexStringToByteArray
|
||||
import hasAnySource
|
||||
import isDownloadable
|
||||
import kotlinx.coroutines.CancellationException
|
||||
|
@ -59,6 +57,7 @@ import kotlinx.coroutines.suspendCancellableCoroutine
|
|||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Contextual
|
||||
import kotlinx.serialization.Transient
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
|
@ -69,8 +68,10 @@ import java.util.concurrent.Executors
|
|||
import java.util.concurrent.ForkJoinPool
|
||||
import java.util.concurrent.ForkJoinTask
|
||||
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.time.times
|
||||
|
||||
@kotlinx.serialization.Serializable
|
||||
class VideoDownload {
|
||||
|
@ -93,10 +94,10 @@ class VideoDownload {
|
|||
var audioSource: AudioUrlSource?;
|
||||
@Contextual
|
||||
@Transient
|
||||
val videoSourceToUse: IVideoSource? get () = if(requiresLiveVideoSource) videoSourceLive as IVideoSource? else videoSource as IVideoSource?;
|
||||
val videoSourceToUse: IVideoSource? get () = if (videoSource?.container == "application/vnd.apple.mpegurl") videoSource else if(requiresLiveVideoSource) videoSourceLive as IVideoSource? else videoSource as IVideoSource?;
|
||||
@Contextual
|
||||
@Transient
|
||||
val audioSourceToUse: IAudioSource? get () = if(requiresLiveAudioSource) audioSourceLive as IAudioSource? else audioSource as IAudioSource?;
|
||||
val audioSourceToUse: IAudioSource? get () = if (audioSource?.container == "application/vnd.apple.mpegurl") audioSource else if(requiresLiveAudioSource) audioSourceLive as IAudioSource? else audioSource as IAudioSource?;
|
||||
|
||||
var requireVideoSource: Boolean = false;
|
||||
var requireAudioSource: Boolean = false;
|
||||
|
@ -131,6 +132,9 @@ class VideoDownload {
|
|||
var hasVideoRequestExecutor: Boolean = false;
|
||||
var hasAudioRequestExecutor: Boolean = false;
|
||||
|
||||
var hasVideoRequestModifier: Boolean = false;
|
||||
var hasAudioRequestModifier: Boolean = false;
|
||||
|
||||
var progress: Double = 0.0;
|
||||
var isCancelled = false;
|
||||
|
||||
|
@ -180,7 +184,7 @@ class VideoDownload {
|
|||
this.requireAudioSource = targetBitrate != null; //TODO: May not be a valid check.. can only be determined after live fetch?
|
||||
this.requiredCheck = optionalSources;
|
||||
}
|
||||
constructor(video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: SubtitleRawSource?) {
|
||||
constructor(video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: SubtitleRawSource?, hasHLSRequestModifier: Boolean = false) {
|
||||
this.video = SerializedPlatformVideo.fromVideo(video);
|
||||
this.videoDetails = SerializedPlatformVideoDetails.fromVideo(video, if (subtitleSource != null) listOf(subtitleSource) else listOf());
|
||||
this.videoSource = if(videoSource is IVideoUrlSource) VideoUrlSource.fromUrlSource(videoSource) else null;
|
||||
|
@ -191,8 +195,10 @@ class VideoDownload {
|
|||
this.prepareTime = OffsetDateTime.now();
|
||||
this.hasVideoRequestExecutor = videoSource is JSSource && videoSource.hasRequestExecutor;
|
||||
this.hasAudioRequestExecutor = audioSource is JSSource && audioSource.hasRequestExecutor;
|
||||
this.requiresLiveVideoSource = this.hasVideoRequestExecutor || (videoSource is JSDashManifestRawSource && videoSource.hasGenerate);
|
||||
this.requiresLiveAudioSource = this.hasAudioRequestExecutor || (audioSource is JSDashManifestRawAudioSource && audioSource.hasGenerate);
|
||||
this.hasVideoRequestModifier = videoSource is JSSource && videoSource.hasRequestModifier || hasHLSRequestModifier
|
||||
this.hasAudioRequestModifier = audioSource is JSSource && audioSource.hasRequestModifier || hasHLSRequestModifier
|
||||
this.requiresLiveVideoSource = this.hasVideoRequestExecutor || this.hasVideoRequestModifier || (videoSource is JSDashManifestRawSource && videoSource.hasGenerate);
|
||||
this.requiresLiveAudioSource = this.hasAudioRequestExecutor || this.hasAudioRequestModifier || (audioSource is JSDashManifestRawAudioSource && audioSource.hasGenerate);
|
||||
this.targetVideoName = videoSource?.name;
|
||||
this.targetAudioName = audioSource?.name;
|
||||
this.targetPixelCount = if(videoSource != null) (videoSource.width * videoSource.height).toLong() else null;
|
||||
|
@ -285,9 +291,14 @@ class VideoDownload {
|
|||
if(videoSource == null && targetPixelCount != null) {
|
||||
val videoSources = arrayListOf<IVideoSource>()
|
||||
for (source in original.video.videoSources) {
|
||||
if (source is IHLSManifestSource) {
|
||||
if (source is JSHLSManifestSource) {
|
||||
try {
|
||||
val playlistResponse = client.get(source.url)
|
||||
val playlistResponse = if (source.hasRequestModifier) {
|
||||
val request = source.getRequestModifier()!!.modifyRequest(source.url, mapOf())
|
||||
client.get(request.url!!, request.headers.toMutableMap())
|
||||
} else {
|
||||
client.get(source.url)
|
||||
}
|
||||
if (playlistResponse.isOk) {
|
||||
val playlistContent = playlistResponse.body?.string()
|
||||
if (playlistContent != null) {
|
||||
|
@ -320,6 +331,10 @@ class VideoDownload {
|
|||
if(original.video.videoSources.size == 0)
|
||||
requireVideoSource = false;
|
||||
}
|
||||
else if (vsource is HLSVariantVideoUrlSource && vsource.container == "application/vnd.apple.mpegurl") {
|
||||
videoSource = VideoUrlSource.fromUrlSource(vsource)
|
||||
videoSourceLive = vsource.jsSource!!
|
||||
}
|
||||
else if(vsource is IVideoUrlSource)
|
||||
videoSource = VideoUrlSource.fromUrlSource(vsource)
|
||||
else if(vsource is JSSource && requiresLiveVideoSource)
|
||||
|
@ -333,9 +348,14 @@ class VideoDownload {
|
|||
val video = original.video
|
||||
if (video is VideoUnMuxedSourceDescriptor) {
|
||||
for (source in video.audioSources) {
|
||||
if (source is IHLSManifestAudioSource) {
|
||||
if (source is JSHLSManifestAudioSource) {
|
||||
try {
|
||||
val playlistResponse = client.get(source.url)
|
||||
val playlistResponse = if (source.hasRequestModifier) {
|
||||
val request = source.getRequestModifier()!!.modifyRequest(source.url, mapOf())
|
||||
client.get(request.url!!, request.headers.toMutableMap())
|
||||
} else {
|
||||
client.get(source.url)
|
||||
}
|
||||
if (playlistResponse.isOk) {
|
||||
val playlistContent = playlistResponse.body?.string()
|
||||
if (playlistContent != null) {
|
||||
|
@ -350,6 +370,26 @@ class VideoDownload {
|
|||
}
|
||||
}
|
||||
}
|
||||
for (source in video.videoSources) {
|
||||
if (source is JSHLSManifestSource) {
|
||||
try {
|
||||
val playlistResponse = if (source.hasRequestModifier) {
|
||||
val request = source.getRequestModifier()!!.modifyRequest(source.url, mapOf())
|
||||
client.get(request.url!!, request.headers.toMutableMap())
|
||||
} else {
|
||||
client.get(source.url)
|
||||
}
|
||||
if (playlistResponse.isOk) {
|
||||
val playlistContent = playlistResponse.body?.string()
|
||||
if (playlistContent != null) {
|
||||
audioSources.addAll(HLS.parseAndGetAudioSources(source, playlistContent, source.url))
|
||||
}
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Log.i(TAG, "Failed to get HLS audio sources", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var asource: IAudioSource? = null;
|
||||
if(targetAudioName != null) {
|
||||
|
@ -376,6 +416,10 @@ class VideoDownload {
|
|||
if(!original.video.isUnMuxed || original.video.videoSources.size == 0)
|
||||
requireVideoSource = false;
|
||||
}
|
||||
else if (asource is HLSVariantAudioUrlSource && asource.container == "application/vnd.apple.mpegurl") {
|
||||
audioSource = AudioUrlSource.fromUrlSource(asource)
|
||||
audioSourceLive = asource.jsSource!!
|
||||
}
|
||||
else if(asource is IAudioUrlSource)
|
||||
audioSource = AudioUrlSource.fromUrlSource(asource)
|
||||
else if(asource is JSSource && requiresLiveAudioSource)
|
||||
|
@ -458,9 +502,9 @@ class VideoDownload {
|
|||
}
|
||||
}
|
||||
|
||||
if(actualVideoSource is IVideoUrlSource)
|
||||
if(videoSource is IVideoUrlSource)
|
||||
videoFileSize = when (videoSource!!.container) {
|
||||
"application/vnd.apple.mpegurl" -> downloadHlsSource(context, "Video", client, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
|
||||
"application/vnd.apple.mpegurl" -> downloadHlsSource(context, "Video", client, if (actualVideoSource is JSSource) actualVideoSource else null, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
|
||||
else -> downloadFileSource("Video", client, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
|
||||
}
|
||||
else if(actualVideoSource is JSDashManifestRawSource) {
|
||||
|
@ -498,9 +542,9 @@ class VideoDownload {
|
|||
}
|
||||
}
|
||||
|
||||
if(actualAudioSource is IAudioUrlSource)
|
||||
if(audioSource is IAudioUrlSource)
|
||||
audioFileSize = when (audioSource!!.container) {
|
||||
"application/vnd.apple.mpegurl" -> downloadHlsSource(context, "Audio", client, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
|
||||
"application/vnd.apple.mpegurl" -> downloadHlsSource(context, "Audio", client, if (audioSourceLive is JSSource) audioSourceLive else null, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
|
||||
else -> downloadFileSource("Audio", client, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
|
||||
}
|
||||
else if(actualAudioSource is JSDashManifestRawAudioSource) {
|
||||
|
@ -554,7 +598,15 @@ class VideoDownload {
|
|||
}
|
||||
}
|
||||
|
||||
private suspend fun downloadHlsSource(context: Context, name: String, client: ManagedHttpClient, hlsUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
|
||||
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, source: JSSource?, hlsUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
|
||||
if(targetFile.exists())
|
||||
targetFile.delete();
|
||||
|
||||
|
@ -562,13 +614,33 @@ class VideoDownload {
|
|||
|
||||
val segmentFiles = arrayListOf<File>()
|
||||
try {
|
||||
val response = client.get(hlsUrl)
|
||||
val response = if (source is JSSource && source.hasRequestModifier) {
|
||||
val request = source.getRequestModifier()!!.modifyRequest(hlsUrl, mapOf())
|
||||
client.get(request.url!!, request.headers.toMutableMap())
|
||||
} else {
|
||||
client.get(hlsUrl)
|
||||
}
|
||||
check(response.isOk) { "Failed to get variant playlist: ${response.code}" }
|
||||
|
||||
val vpContent = response.body?.string()
|
||||
?: throw Exception("Variant playlist content is empty")
|
||||
|
||||
val variantPlaylist = HLS.parseVariantPlaylist(vpContent, hlsUrl)
|
||||
|
||||
val decryptionInfo: DecryptionInfo? = if (variantPlaylist.decryptionInfo != null) {
|
||||
val keyResponse = if (source is JSSource && source.hasRequestModifier) {
|
||||
val request = source.getRequestModifier()!!.modifyRequest(variantPlaylist.decryptionInfo.keyUrl, mapOf())
|
||||
client.get(request.url!!, request.headers.toMutableMap())
|
||||
} else {
|
||||
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 ->
|
||||
if (segment !is HLS.MediaSegment) {
|
||||
return@forEachIndexed
|
||||
|
@ -580,7 +652,7 @@ class VideoDownload {
|
|||
try {
|
||||
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 expectedTotalLength = averageSegmentLength * (variantPlaylist.segments.size - 1) + segmentLength
|
||||
onProgress(expectedTotalLength, downloadedTotalLength + totalRead, lastSpeed)
|
||||
|
@ -620,10 +692,8 @@ class VideoDownload {
|
|||
|
||||
private suspend fun combineSegments(context: Context, segmentFiles: List<File>, targetFile: File) = withContext(Dispatchers.IO) {
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
val fileList = File(context.cacheDir, "fileList-${UUID.randomUUID()}.txt")
|
||||
fileList.writeText(segmentFiles.joinToString("\n") { "file '${it.absolutePath}'" })
|
||||
|
||||
val cmd = "-f concat -safe 0 -i \"${fileList.absolutePath}\" -c copy \"${targetFile.absolutePath}\""
|
||||
val cmd =
|
||||
"-i \"concat:${segmentFiles.joinToString("|")}\" -c copy \"${targetFile.absolutePath}\""
|
||||
|
||||
val statisticsCallback = StatisticsCallback { _ ->
|
||||
//TODO: Show progress?
|
||||
|
@ -633,7 +703,6 @@ class VideoDownload {
|
|||
val session = FFmpegKit.executeAsync(cmd,
|
||||
{ session ->
|
||||
if (ReturnCode.isSuccess(session.returnCode)) {
|
||||
fileList.delete()
|
||||
continuation.resumeWith(Result.success(Unit))
|
||||
} else {
|
||||
val errorMessage = if (ReturnCode.isCancel(session.returnCode)) {
|
||||
|
@ -641,7 +710,6 @@ class VideoDownload {
|
|||
} else {
|
||||
"Command failed with state '${session.state}' and return code ${session.returnCode}, stack trace ${session.failStackTrace}"
|
||||
}
|
||||
fileList.delete()
|
||||
continuation.resumeWithException(RuntimeException(errorMessage))
|
||||
}
|
||||
},
|
||||
|
@ -761,7 +829,7 @@ class VideoDownload {
|
|||
else {
|
||||
Logger.i(TAG, "Download $name Sequential");
|
||||
try {
|
||||
sourceLength = downloadSource_Sequential(client, fileStream, videoUrl, onProgress);
|
||||
sourceLength = downloadSource_Sequential(client, fileStream, videoUrl, null, onProgress);
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to download sequentially (url = $videoUrl)")
|
||||
throw e
|
||||
|
@ -788,7 +856,31 @@ class VideoDownload {
|
|||
}
|
||||
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;
|
||||
var lastProgressCount: Int = 0;
|
||||
val speedRate: Int = 4096 * 5;
|
||||
|
@ -808,6 +900,8 @@ class VideoDownload {
|
|||
val sourceLength = result.body.contentLength();
|
||||
val sourceStream = result.body.byteStream();
|
||||
|
||||
val segmentBuffer = ByteArrayOutputStream()
|
||||
|
||||
var totalRead: Long = 0;
|
||||
try {
|
||||
var read: Int;
|
||||
|
@ -818,7 +912,7 @@ class VideoDownload {
|
|||
if (read < 0)
|
||||
break;
|
||||
|
||||
fileStream.write(buffer, 0, read);
|
||||
segmentBuffer.write(buffer, 0, read);
|
||||
|
||||
totalRead += read;
|
||||
|
||||
|
@ -844,6 +938,14 @@ class VideoDownload {
|
|||
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);
|
||||
return sourceLength;
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantSubtit
|
|||
import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantVideoUrlSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestAudioSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource
|
||||
import com.futo.platformplayer.toYesNo
|
||||
import com.futo.platformplayer.yesNoToBoolean
|
||||
import java.net.URI
|
||||
|
@ -61,7 +62,28 @@ class HLS {
|
|||
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 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>()
|
||||
if (initSegment != null) {
|
||||
segments.add(MediaSegment(0.0, resolveUrl(sourceUrl, initSegment)))
|
||||
}
|
||||
|
||||
var currentSegment: MediaSegment? = null
|
||||
lines.forEach { line ->
|
||||
when {
|
||||
|
@ -86,14 +108,14 @@ 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: JSSource, content: String, url: String): List<HLSVariantVideoUrlSource> {
|
||||
val masterPlaylist: MasterPlaylist
|
||||
try {
|
||||
masterPlaylist = parseMasterPlaylist(content, url)
|
||||
return masterPlaylist.getVideoSources()
|
||||
return masterPlaylist.getVideoSources(source)
|
||||
} catch (e: Throwable) {
|
||||
if (content.lines().any { it.startsWith("#EXTINF:") }) {
|
||||
return if (source is IHLSManifestSource) {
|
||||
|
@ -109,11 +131,11 @@ class HLS {
|
|||
}
|
||||
}
|
||||
|
||||
fun parseAndGetAudioSources(source: Any, content: String, url: String): List<HLSVariantAudioUrlSource> {
|
||||
fun parseAndGetAudioSources(source: JSSource, content: String, url: String): List<HLSVariantAudioUrlSource> {
|
||||
val masterPlaylist: MasterPlaylist
|
||||
try {
|
||||
masterPlaylist = parseMasterPlaylist(content, url)
|
||||
return masterPlaylist.getAudioSources()
|
||||
return masterPlaylist.getAudioSources(source)
|
||||
} catch (e: Throwable) {
|
||||
if (content.lines().any { it.startsWith("#EXTINF:") }) {
|
||||
return if (source is IHLSManifestSource) {
|
||||
|
@ -317,7 +339,7 @@ class HLS {
|
|||
return builder.toString()
|
||||
}
|
||||
|
||||
fun getVideoSources(): List<HLSVariantVideoUrlSource> {
|
||||
fun getVideoSources(source: JSSource? = null): List<HLSVariantVideoUrlSource> {
|
||||
return variantPlaylistsRefs.map {
|
||||
var width: Int? = null
|
||||
var height: Int? = null
|
||||
|
@ -328,11 +350,11 @@ class HLS {
|
|||
}
|
||||
|
||||
val suffix = listOf(it.streamInfo.video, it.streamInfo.codecs).mapNotNull { x -> x?.ifEmpty { null } }.joinToString(", ")
|
||||
HLSVariantVideoUrlSource(suffix, width ?: 0, height ?: 0, "application/vnd.apple.mpegurl", it.streamInfo.codecs ?: "", it.streamInfo.bandwidth, 0, false, it.url)
|
||||
HLSVariantVideoUrlSource(suffix, width ?: 0, height ?: 0, "application/vnd.apple.mpegurl", it.streamInfo.codecs ?: "", it.streamInfo.bandwidth, 0, false, it.url, source)
|
||||
}
|
||||
}
|
||||
|
||||
fun getAudioSources(): List<HLSVariantAudioUrlSource> {
|
||||
fun getAudioSources(source: JSSource? = null): List<HLSVariantAudioUrlSource> {
|
||||
return mediaRenditions.mapNotNull {
|
||||
if (it.uri == null) {
|
||||
return@mapNotNull null
|
||||
|
@ -340,7 +362,7 @@ class HLS {
|
|||
|
||||
val suffix = listOf(it.language, it.groupID).mapNotNull { x -> x?.ifEmpty { null } }.joinToString(", ")
|
||||
return@mapNotNull when (it.type) {
|
||||
"AUDIO" -> HLSVariantAudioUrlSource(it.name?.ifEmpty { "Audio (${suffix})" } ?: "Audio (${suffix})", 0, "application/vnd.apple.mpegurl", "", it.language ?: "", null, false, it.uri)
|
||||
"AUDIO" -> HLSVariantAudioUrlSource(it.name?.ifEmpty { "Audio (${suffix})" } ?: "Audio (${suffix})", 0, "application/vnd.apple.mpegurl", "", it.language ?: "", null, false, it.uri, source)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
@ -368,6 +390,11 @@ class HLS {
|
|||
}
|
||||
}
|
||||
|
||||
data class DecryptionInfo(
|
||||
val keyUrl: String,
|
||||
val iv: String
|
||||
)
|
||||
|
||||
data class VariantPlaylist(
|
||||
val version: Int?,
|
||||
val targetDuration: Int?,
|
||||
|
@ -376,7 +403,8 @@ class HLS {
|
|||
val programDateTime: ZonedDateTime?,
|
||||
val playlistType: String?,
|
||||
val streamInfo: StreamInfo?,
|
||||
val segments: List<Segment>
|
||||
val segments: List<Segment>,
|
||||
val decryptionInfo: DecryptionInfo? = null
|
||||
) {
|
||||
fun buildM3U8(): String = buildString {
|
||||
append("#EXTM3U\n")
|
||||
|
|
|
@ -336,8 +336,8 @@ class StateDownloads {
|
|||
fun download(video: IPlatformVideo, targetPixelcount: Long?, targetBitrate: Long?) {
|
||||
download(VideoDownload(video, targetPixelcount, targetBitrate));
|
||||
}
|
||||
fun download(video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: SubtitleRawSource?) {
|
||||
download(VideoDownload(video, videoSource, audioSource, subtitleSource));
|
||||
fun download(video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: SubtitleRawSource?, hasRequestModifier: Boolean = false) {
|
||||
download(VideoDownload(video, videoSource, audioSource, subtitleSource, hasRequestModifier));
|
||||
}
|
||||
|
||||
private fun download(videoState: VideoDownload, notify: Boolean = true) {
|
||||
|
|
Loading…
Add table
Reference in a new issue