diff --git a/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt b/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt index 582a74f7..0ee1a3f2 100644 --- a/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt +++ b/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt @@ -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(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 } diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/HLSVariantUrlSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/HLSVariantUrlSource.kt index 36df5fb2..1b8840a5 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/HLSVariantUrlSource.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/HLSVariantUrlSource.kt @@ -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 diff --git a/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt b/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt index 8ba2814f..ab184fb4 100644 --- a/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt +++ b/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt @@ -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() 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() 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, 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; } diff --git a/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt b/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt index 9d1a3faa..9dfa6269 100644 --- a/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt +++ b/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt @@ -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() + 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 { + fun parseAndGetVideoSources(source: JSSource, content: String, url: String): List { 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 { + fun parseAndGetAudioSources(source: JSSource, content: String, url: String): List { 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 { + fun getVideoSources(source: JSSource? = null): List { 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 { + fun getAudioSources(source: JSSource? = null): List { 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 + val segments: List, + val decryptionInfo: DecryptionInfo? = null ) { fun buildM3U8(): String = buildString { append("#EXTM3U\n") diff --git a/app/src/main/java/com/futo/platformplayer/states/StateDownloads.kt b/app/src/main/java/com/futo/platformplayer/states/StateDownloads.kt index 4bfeae7b..481c1a86 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateDownloads.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateDownloads.kt @@ -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) {