diff --git a/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt b/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt index 2a930734e743b63f35a546473986185271c5a19a..ce68b36119a00b76795f26eb4a8124280a3d2a84 100644 --- a/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt +++ b/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt @@ -4,8 +4,13 @@ import android.app.NotificationManager import android.content.ContentResolver import android.content.Context import android.content.Intent +import android.net.Uri import android.view.View import android.view.ViewGroup +import androidx.annotation.OptIn +import androidx.media3.common.C +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.dash.manifest.DashManifestParser import androidx.recyclerview.widget.RecyclerView import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.activities.SettingsActivity @@ -13,10 +18,13 @@ import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.media.models.ResultCapabilities import com.futo.platformplayer.api.media.models.channels.IPlatformChannel import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor +import com.futo.platformplayer.api.media.models.streams.sources.DashManifestAudioSourceDelegate +import com.futo.platformplayer.api.media.models.streams.sources.DashManifestSourceDelegate 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 @@ -28,6 +36,10 @@ 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.JSDashManifestSource +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 @@ -36,6 +48,7 @@ import com.futo.platformplayer.models.ImageVariable import com.futo.platformplayer.models.Playlist import com.futo.platformplayer.models.Subscription import com.futo.platformplayer.models.SubscriptionGroup +import com.futo.platformplayer.others.Language import com.futo.platformplayer.parsers.HLS import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateDownloads @@ -63,6 +76,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import java.io.ByteArrayInputStream class UISlideOverlays { companion object { @@ -269,14 +283,116 @@ class UISlideOverlays { } - fun showHlsPicker(video: IPlatformVideoDetails, source: Any, sourceUrl: String, container: ViewGroup): SlideUpMenuOverlay { + @OptIn(UnstableApi::class) + fun showDashPicker(video: IPlatformVideoDetails, source: JSDashManifestSource, 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 manifestResponse = ManagedHttpClient().get(sourceUrl) + check(manifestResponse.isOk) { "Failed to get DASH manifest: ${manifestResponse.code}" } + + val manifestContent = manifestResponse.body?.string() + ?: throw Exception("Manifest content is empty") + + val videoButtons = arrayListOf<SlideUpMenuItem>() + val audioButtons = arrayListOf<SlideUpMenuItem>() + //TODO: Implement subtitles + //val subtitleButtons = arrayListOf<SlideUpMenuItem>() + + var selectedVideoVariant: IDashManifestSource? = null + var selectedAudioVariant: IAudioSource? = null + //TODO: Implement subtitles + //var selectedSubtitleVariant: HLSVariantSubtitleUrlSource? = null + + val manifestStream = ByteArrayInputStream(manifestContent.toByteArray()) + val playlist = DashManifestParser().parse(Uri.parse(sourceUrl), manifestStream) + + playlist.getPeriod(0).adaptationSets.filter { it.type == C.TRACK_TYPE_AUDIO } + .flatMap { it.representations }.forEach { + audioButtons.add(SlideUpMenuItem(container.context, R.drawable.ic_music, it.format.containerMimeType + ?: "", listOf(it.format.language, it.format.codecs).mapNotNull { x -> x?.ifEmpty { null } } + .joinToString(", "), it.format.codecs, tag = it, call = { + selectedAudioVariant = DashManifestAudioSourceDelegate( + source, it.format.language + ?: Language.UNKNOWN, it.format.bitrate, it.format.containerMimeType!! + ) + + slideUpMenuOverlay.selectOption(audioButtons, it) + slideUpMenuOverlay.setOk(container.context.getString(R.string.download)) + }, invokeParent = false + ) + ) + } + + /*masterPlaylist.getSubtitleSources().forEach { it -> + subtitleButtons.add(SlideUpMenuItem(container.context, R.drawable.ic_music, it.name, listOf(it.format).mapNotNull { x -> x.ifEmpty { null } }.joinToString(", "), it, { + selectedSubtitleVariant = it + slideUpMenuOverlay.selectOption(subtitleButtons, it) + slideUpMenuOverlay.setOk(container.context.getString(R.string.download)) + }, false)) + }*/ + + playlist.getPeriod(0).adaptationSets.filter { it.type == C.TRACK_TYPE_VIDEO } + .flatMap { it.representations }.forEach { + videoButtons.add( + SlideUpMenuItem( + container.context, R.drawable.ic_movie, it.format.containerMimeType + ?: "", "${it.format.width}x${it.format.height}", it.format.codecs, tag = it, call = { + selectedVideoVariant = + DashManifestSourceDelegate(source, it.format.width, it.format.height, it.format.containerMimeType!!) + slideUpMenuOverlay.selectOption(videoButtons, it) + if (audioButtons.isEmpty()){ + slideUpMenuOverlay.setOk(container.context.getString(R.string.download)) + } + }, invokeParent = false + ) + ) + } + + val newItems = arrayListOf<View>() + if (videoButtons.isNotEmpty()) { + newItems.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.video), videoButtons, videoButtons)) + } + if (audioButtons.isNotEmpty()) { + newItems.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.audio), audioButtons, audioButtons)) + } + //TODO: Implement subtitles + /*if (subtitleButtons.isNotEmpty()) { + newItems.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.subtitles), subtitleButtons, subtitleButtons)) + }*/ + + slideUpMenuOverlay.onOK.subscribe { + //TODO: Fix SubtitleRawSource issue + StateDownloads.instance.download(video, selectedVideoVariant, selectedAudioVariant, null) + slideUpMenuOverlay.hide() + } + + withContext(Dispatchers.Main) { + slideUpMenuOverlay.setItems(newItems) + } + } + + return slideUpMenuOverlay.apply { show() } + } + + 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 resolvedSourceUrl = masterPlaylistResponse.url + val masterPlaylistContent = masterPlaylistResponse.body?.string() ?: throw Exception("Master playlist content is empty") @@ -285,14 +401,14 @@ class UISlideOverlays { //TODO: Implement subtitles //val subtitleButtons = arrayListOf<SlideUpMenuItem>() - var selectedVideoVariant: HLSVariantVideoUrlSource? = null - var selectedAudioVariant: HLSVariantAudioUrlSource? = null + var selectedVideoVariant: IHLSManifestSource? = null + var selectedAudioVariant: IAudioSource? = null //TODO: Implement subtitles //var selectedSubtitleVariant: HLSVariantSubtitleUrlSource? = null val masterPlaylist: HLS.MasterPlaylist try { - masterPlaylist = HLS.parseMasterPlaylist(masterPlaylistContent, sourceUrl) + masterPlaylist = HLS.parseMasterPlaylist(masterPlaylistContent, masterPlaylistResponse.url, source is IHLSManifestAudioSource) masterPlaylist.getAudioSources().forEach { it -> @@ -306,7 +422,19 @@ class UISlideOverlays { (prefix + it.codec).trim(), tag = it, call = { - selectedAudioVariant = it + if (source is JSHLSManifestAudioSource) { + source.setPreferredBitrate(it.bitrate) + source.setPreferredLanguage(it.language) + source.setPreferredContainer(it.container) + selectedAudioVariant = source + } else if (source is JSHLSManifestSource) { + source.setPreferredBitrate(it.bitrate) + source.setPreferredLanguage(it.language) + selectedAudioVariant = source + } else { + throw Exception("Expected HLS Source") + } + slideUpMenuOverlay.selectOption(audioButtons, it) slideUpMenuOverlay.setOk(container.context.getString(R.string.download)) }, @@ -333,9 +461,16 @@ class UISlideOverlays { (prefix + it.codec).trim(), tag = it, call = { - selectedVideoVariant = it + if (source !is JSHLSManifestSource){ + throw Exception("Expected HLS Source") + } + source.setPreferredWidth(it.width) + source.setPreferredHeight(it.height) + selectedVideoVariant = source slideUpMenuOverlay.selectOption(videoButtons, it) - slideUpMenuOverlay.setOk(container.context.getString(R.string.download)) + if (audioButtons.isEmpty()){ + slideUpMenuOverlay.setOk(container.context.getString(R.string.download)) + } }, invokeParent = false )) @@ -366,11 +501,11 @@ class UISlideOverlays { if (masterPlaylistContent.lines().any { it.startsWith("#EXTINF:") }) { withContext(Dispatchers.Main) { if (source is IHLSManifestSource) { - StateDownloads.instance.download(video, HLSVariantVideoUrlSource("variant", 0, 0, "application/vnd.apple.mpegurl", "", null, 0, false, sourceUrl), null, null) + StateDownloads.instance.download(video, HLSVariantVideoUrlSource("variant", 0, 0, "application/vnd.apple.mpegurl", "", null, 0, false, resolvedSourceUrl), null, null) UIDialogs.toast(container.context, "Variant video HLS playlist download started") slideUpMenuOverlay.hide() } else if (source is IHLSManifestAudioSource) { - StateDownloads.instance.download(video, null, HLSVariantAudioUrlSource("variant", 0, "application/vnd.apple.mpegurl", "", "", null, false, sourceUrl), null) + StateDownloads.instance.download(video, null, HLSVariantAudioUrlSource("variant", 0, "application/vnd.apple.mpegurl", "", "", null, false, resolvedSourceUrl), null) UIDialogs.toast(container.context, "Variant audio HLS playlist download started") slideUpMenuOverlay.hide() } else { @@ -417,7 +552,7 @@ class UISlideOverlays { } items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.video), videoSources, - listOf(listOf(SlideUpMenuItem( + listOf((if (audioSources != null) listOf(SlideUpMenuItem( container.context, R.drawable.ic_movie, container.context.getString(R.string.none), @@ -430,12 +565,12 @@ class UISlideOverlays { menu?.setOk(container.context.getString(R.string.download)); }, invokeParent = false - )) + + )) else listOf()) + videoSources .filter { it.isDownloadable() } .map { when (it) { - is IVideoUrlSource -> { + is JSDashManifestRawSource -> { val estSize = VideoHelper.estimateSourceSize(it); val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else ""; SlideUpMenuItem( @@ -455,35 +590,43 @@ class UISlideOverlays { ) } - is JSDashManifestRawSource -> { - val estSize = VideoHelper.estimateSourceSize(it); - val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else ""; + is JSDashManifestSource -> { + SlideUpMenuItem( + container.context, R.drawable.ic_movie, it.name, "DASH", tag = it, call = { + showDashPicker(video, it, it.url, container) + }, invokeParent = false + ) + } + + is JSHLSManifestSource -> { SlideUpMenuItem( container.context, R.drawable.ic_movie, it.name, - "${it.width}x${it.height}", - (prefix + it.codec).trim(), + "HLS", tag = it, call = { - selectedVideo = it - menu?.selectOption(videoSources, it); - if(selectedAudio != null || !requiresAudio) - menu?.setOk(container.context.getString(R.string.download)); + showHlsPicker(video, it, it.url, container) }, invokeParent = false ) } - is IHLSManifestSource -> { + is IVideoUrlSource -> { + val estSize = VideoHelper.estimateSourceSize(it); + val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else ""; SlideUpMenuItem( container.context, R.drawable.ic_movie, it.name, - "HLS", + "${it.width}x${it.height}", + (prefix + it.codec).trim(), tag = it, call = { - showHlsPicker(video, it, it.url, container) + selectedVideo = it + menu?.selectOption(videoSources, it); + if(selectedAudio != null || !requiresAudio) + menu?.setOk(container.context.getString(R.string.download)); }, invokeParent = false ) @@ -549,7 +692,7 @@ class UISlideOverlays { ); } - is IHLSManifestAudioSource -> { + is JSHLSManifestAudioSource -> { SlideUpMenuItem( container.context, R.drawable.ic_movie, @@ -614,13 +757,18 @@ class UISlideOverlays { menu.onOK.subscribe { val sv = selectedVideo - if (sv is IHLSManifestSource) { + if (sv is JSHLSManifestSource) { showHlsPicker(video, sv, sv.url, container) return@subscribe } + if (sv is JSDashManifestSource) { + showDashPicker(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/HLSManifestSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/HLSManifestSource.kt index 5230447339a0afc5bfe4c74e43bd4ffd473bcae8..440e9e12d743b296744b5c1a2a42ebb95516e932 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/HLSManifestSource.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/HLSManifestSource.kt @@ -1,18 +1,21 @@ package com.futo.platformplayer.api.media.models.streams.sources +import com.futo.platformplayer.others.Language + class HLSManifestSource : IVideoSource, IHLSManifestSource { - override val width : Int = 0; - override val height : Int = 0; - override val container : String = "HLS"; + override val width: Int = 0; + override val height: Int = 0; + override val container: String = "HLS"; override val codec: String = "HLS"; - override val name : String = "HLS"; - override val bitrate : Int? = null; - override val url : String; + override val name: String = "HLS"; + override val bitrate: Int = 0; + override val url: String; override val duration: Long = 0; + override val language: String = Language.UNKNOWN; override var priority: Boolean = false; - constructor(url : String) { + constructor(url: String) { this.url = url; } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/IDashManifestSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/IDashManifestSource.kt index c6086d7e11b51ab76ee3be43676fda7eb0cb98de..c3d43fdecc7fe0c8ac3d3aae522459427d28b9e5 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/IDashManifestSource.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/IDashManifestSource.kt @@ -1,5 +1,29 @@ package com.futo.platformplayer.api.media.models.streams.sources +import com.caoccao.javet.values.reference.V8ValueObject +import com.futo.platformplayer.api.media.platforms.js.models.sources.IUnderlyingObject +import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestSource + interface IDashManifestSource : IVideoSource { - val url : String; + val url: String +} + +interface DashWrapper { + val source: IDashManifestSource +} + +class DashManifestAudioSourceDelegate( + override val source: JSDashManifestSource, override val language: String, override val bitrate: Int, override val container: String +) : IDashManifestSource by source, IAudioSource, DashWrapper, IUnderlyingObject { + override fun getUnderlyingObject(): V8ValueObject? { + return source.getUnderlyingObject() + } +} + +class DashManifestSourceDelegate( + override val source: JSDashManifestSource, override val width: Int, override val height: Int, override val container: String +) : IDashManifestSource by source, DashWrapper, IUnderlyingObject { + override fun getUnderlyingObject(): V8ValueObject? { + return source.getUnderlyingObject() + } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/IHLSManifestSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/IHLSManifestSource.kt index 8993d044b976e1511c1af3c98f1b3a28a51f713e..d280cd15275ffdb5432a45aed2d23f05cd812ca4 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/IHLSManifestSource.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/IHLSManifestSource.kt @@ -1,8 +1,8 @@ package com.futo.platformplayer.api.media.models.streams.sources -interface IHLSManifestSource : IVideoSource { - val url : String; +interface IHLSManifestSource : IVideoSource, IAudioSource { + val url : String } interface IHLSManifestAudioSource : IAudioSource { - val url : String; + val url : String } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSDashManifestSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSDashManifestSource.kt index 3070a2d4e26ed1df1a006f9b531225497352abc3..2edb01b2ebd16d009a3c17fbfab987ed8617e973 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSDashManifestSource.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSDashManifestSource.kt @@ -4,8 +4,6 @@ import com.caoccao.javet.values.reference.V8ValueObject import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource import com.futo.platformplayer.api.media.platforms.js.JSClient -import com.futo.platformplayer.engine.IV8PluginConfig -import com.futo.platformplayer.engine.V8Plugin import com.futo.platformplayer.getOrNull import com.futo.platformplayer.getOrThrow diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSHLSManifestAudioSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSHLSManifestAudioSource.kt index 4194880220a3b11c2c903b6328ed8e16172b85d5..a63f6b42aea989382791654dcf63026c666bdf52 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSHLSManifestAudioSource.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSHLSManifestAudioSource.kt @@ -2,23 +2,20 @@ package com.futo.platformplayer.api.media.platforms.js.models.sources import com.caoccao.javet.values.V8Value import com.caoccao.javet.values.reference.V8ValueObject -import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlSource import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestAudioSource import com.futo.platformplayer.api.media.platforms.js.JSClient -import com.futo.platformplayer.engine.IV8PluginConfig -import com.futo.platformplayer.engine.V8Plugin import com.futo.platformplayer.getOrNull import com.futo.platformplayer.getOrThrow import com.futo.platformplayer.orNull class JSHLSManifestAudioSource : IHLSManifestAudioSource, JSSource { - override val container : String get() = "application/vnd.apple.mpegurl"; + override var container : String = "application/vnd.apple.mpegurl"; override val codec: String = "HLS"; override val name : String; - override val bitrate : Int = 0; + override var bitrate : Int = 0; override val url : String; override val duration: Long; - override val language: String; + override var language: String; override var priority: Boolean = false; @@ -34,6 +31,17 @@ class JSHLSManifestAudioSource : IHLSManifestAudioSource, JSSource { priority = obj.getOrNull(config, "priority", contextName) ?: false; } + fun setPreferredBitrate(bitrate: Int) { + this@JSHLSManifestAudioSource.bitrate = bitrate; + } + + fun setPreferredLanguage(language: String) { + this@JSHLSManifestAudioSource.language = language; + } + + fun setPreferredContainer(container: String) { + this@JSHLSManifestAudioSource.container = container; + } companion object { fun fromV8HLSNullable(plugin: JSClient, obj: V8Value?) : JSHLSManifestAudioSource? = obj.orNull { fromV8HLS(plugin, it as V8ValueObject) }; diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSHLSManifestSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSHLSManifestSource.kt index 606d107c3811211528ad083c4dad91a99f165025..1a35fc646a7c55d8b7f5dff550886f5e6e9f83e6 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSHLSManifestSource.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSHLSManifestSource.kt @@ -2,22 +2,21 @@ package com.futo.platformplayer.api.media.platforms.js.models.sources import com.caoccao.javet.values.reference.V8ValueObject import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource -import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource import com.futo.platformplayer.api.media.platforms.js.JSClient -import com.futo.platformplayer.engine.IV8PluginConfig -import com.futo.platformplayer.engine.V8Plugin import com.futo.platformplayer.getOrNull import com.futo.platformplayer.getOrThrow +import com.futo.platformplayer.others.Language class JSHLSManifestSource : IHLSManifestSource, JSSource { - override val width : Int = 0; - override val height : Int = 0; + override var width : Int = 0; + override var height : Int = 0; override val container : String get() = "application/vnd.apple.mpegurl"; override val codec: String = "HLS"; override val name : String; - override val bitrate : Int? = null; + override var bitrate : Int = 0; override val url : String; override val duration: Long; + override var language: String = Language.UNKNOWN override var priority: Boolean = false; @@ -31,4 +30,20 @@ class JSHLSManifestSource : IHLSManifestSource, JSSource { priority = obj.getOrNull(config, "priority", contextName) ?: false; } + + fun setPreferredWidth(width: Int) { + this@JSHLSManifestSource.width = width + } + + fun setPreferredHeight(height: Int) { + this@JSHLSManifestSource.height = height + } + + fun setPreferredBitrate(bitrate: Int) { + this@JSHLSManifestSource.bitrate = bitrate; + } + + fun setPreferredLanguage(language: String) { + this@JSHLSManifestSource.language = language; + } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSSource.kt index 3c76e23dd6155ca533a7d33af45b9b25c67f6434..941c0b1131651dd0a70eda3e429e9a49f3a4df59 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSSource.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSSource.kt @@ -1,7 +1,5 @@ package com.futo.platformplayer.api.media.platforms.js.models.sources -import androidx.media3.datasource.DefaultHttpDataSource -import androidx.media3.datasource.HttpDataSource import com.caoccao.javet.values.V8Value import com.caoccao.javet.values.reference.V8ValueObject import com.futo.platformplayer.api.media.models.modifier.AdhocRequestModifier @@ -17,9 +15,12 @@ import com.futo.platformplayer.engine.V8Plugin import com.futo.platformplayer.getOrDefault import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.orNull -import com.futo.platformplayer.views.video.datasources.JSHttpDataSource -abstract class JSSource { +interface IUnderlyingObject { + fun getUnderlyingObject(): V8ValueObject? +} + +abstract class JSSource : IUnderlyingObject { protected val _plugin: JSClient; protected val _config: IV8PluginConfig; protected val _obj: V8ValueObject; @@ -88,7 +89,7 @@ abstract class JSSource { fun getUnderlyingPlugin(): JSClient? { return _plugin; } - fun getUnderlyingObject(): V8ValueObject? { + override fun getUnderlyingObject(): V8ValueObject? { return _obj; } 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 84c019c8ce73893f365422ac2b7f6b64beef5c93..7a8808b1a045ceb27aa7af69c1a854d4f27e4937 100644 --- a/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt +++ b/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt @@ -1,7 +1,12 @@ package com.futo.platformplayer.downloads import android.content.Context +import android.net.Uri import android.util.Log +import androidx.annotation.OptIn +import androidx.media3.common.C +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.dash.manifest.DashManifestParser import com.arthenica.ffmpegkit.FFmpegKit import com.arthenica.ffmpegkit.ReturnCode import com.arthenica.ffmpegkit.StatisticsCallback @@ -10,6 +15,8 @@ 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.DashManifestAudioSourceDelegate +import com.futo.platformplayer.api.media.models.streams.sources.DashManifestSourceDelegate 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 @@ -28,25 +35,27 @@ 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.IUnderlyingObject 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.JSDashManifestSource +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 import com.futo.platformplayer.helpers.FileHelper.Companion.sanitizeFileName import com.futo.platformplayer.helpers.VideoHelper import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.others.Language 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 +68,8 @@ import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import kotlinx.serialization.Contextual import kotlinx.serialization.Transient +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream import java.io.File import java.io.FileOutputStream import java.io.IOException @@ -69,8 +80,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 { @@ -119,18 +132,21 @@ class VideoDownload { var requiresLiveVideoSource: Boolean = false; @Contextual @kotlinx.serialization.Transient - var videoSourceLive: JSSource? = null; + var videoSourceLive: IUnderlyingObject? = null; val isLiveVideoSourceValid get() = videoSourceLive?.getUnderlyingObject()?.isClosed?.let { !it } ?: false; var requiresLiveAudioSource: Boolean = false; @Contextual @kotlinx.serialization.Transient - var audioSourceLive: JSSource? = null; + var audioSourceLive: IUnderlyingObject? = null; val isLiveAudioSourceValid get() = audioSourceLive?.getUnderlyingObject()?.isClosed?.let { !it } ?: false; var hasVideoRequestExecutor: Boolean = false; var hasAudioRequestExecutor: Boolean = false; + private var hasVideoRequestModifier: Boolean = false + private var hasAudioRequestModifier: Boolean = false + var progress: Double = 0.0; var isCancelled = false; @@ -191,8 +207,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 + this.hasAudioRequestModifier = audioSource is JSSource && audioSource.hasRequestModifier + this.requiresLiveVideoSource = this.hasVideoRequestExecutor || this.hasVideoRequestModifier || videoSource !is IVideoUrlSource + this.requiresLiveAudioSource = this.hasAudioRequestExecutor || this.hasAudioRequestModifier || audioSource !is IAudioUrlSource this.targetVideoName = videoSource?.name; this.targetAudioName = audioSource?.name; this.targetPixelCount = if(videoSource != null) (videoSource.width * videoSource.height).toLong() else null; @@ -227,6 +245,7 @@ class VideoDownload { return items.joinToString(" • "); } + @OptIn(UnstableApi::class) suspend fun prepare(client: ManagedHttpClient) { Logger.i(TAG, "VideoDownload Prepare [${name}]"); @@ -282,21 +301,57 @@ class VideoDownload { } videoDetails = SerializedPlatformVideoDetails.fromVideo(original, if (subtitleSource != null) listOf(subtitleSource!!) else listOf()); - if(videoSource == null && targetPixelCount != null) { + if (videoSource == null && targetPixelCount != null) { val videoSources = arrayListOf<IVideoSource>() for (source in original.video.videoSources) { if (source is IHLSManifestSource) { try { - val playlistResponse = client.get(source.url) + val playlistResponse = if ((source as JSSource).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) { - videoSources.addAll(HLS.parseAndGetVideoSources(source, playlistContent, source.url)) + val variantSources = + HLS.parseAndGetVideoSources(source, playlistContent, source.url) + val target = + VideoHelper.selectBestVideoSource(variantSources, targetPixelCount!!.toInt(), arrayOf()) + if (target != null) { + (source as JSHLSManifestSource).setPreferredWidth(target.width) + source.setPreferredHeight(target.height) + videoSources.add(source) + } } } } catch (e: Throwable) { Log.i(TAG, "Failed to get HLS video sources", e) } + } else if (source is JSDashManifestSource) { + val masterPlaylistResponse = ManagedHttpClient().get(source.url) + check(masterPlaylistResponse.isOk) { "Failed to get master playlist: ${masterPlaylistResponse.code}" } + + val hlsManifestUrl = masterPlaylistResponse.url + + val masterPlaylistContent = masterPlaylistResponse.body?.string() + ?: throw Exception("Master playlist content is empty") + + val inputStream = ByteArrayInputStream(masterPlaylistContent.toByteArray()) + + val playlist = + DashManifestParser().parse(Uri.parse(hlsManifestUrl), inputStream) + + val period = playlist.getPeriod(0) + + val representation = + period.adaptationSets.filter { it.type == C.TRACK_TYPE_VIDEO } + .flatMap { it.representations }.filter { + (it.format.width * it.format.height).toLong() == targetPixelCount + }[0] + videoSources.add(DashManifestSourceDelegate(source, representation.format.width, representation.format.height, representation.format.containerMimeType!!)) } else { videoSources.add(source) } @@ -320,22 +375,40 @@ class VideoDownload { videoSource = VideoUrlSource.fromUrlSource(vsource) else if(vsource is JSSource && requiresLiveVideoSource) videoSourceLive = vsource; + else if (vsource is DashManifestSourceDelegate) + videoSourceLive = vsource else throw DownloadException("Video source is not supported for downloading (yet) [" + vsource?.javaClass?.name + "]", false); } - if(audioSource == null && targetBitrate != null) { + if (audioSource == null && targetBitrate != null) { var audioSources = mutableListOf<IAudioSource>() val video = original.video if (video is VideoUnMuxedSourceDescriptor) { for (source in video.audioSources) { if (source is IHLSManifestAudioSource) { try { - val playlistResponse = client.get(source.url) + val playlistResponse = + if ((source as JSSource).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)) + val variantSources = + HLS.parseAndGetAudioSources(source, playlistContent, source.url, true) + val target = + VideoHelper.selectBestAudioSource(variantSources, arrayOf(), null, targetBitrate) + if (target != null) { + (source as JSHLSManifestAudioSource).setPreferredBitrate(target.bitrate) + source.setPreferredLanguage(target.language) + source.setPreferredContainer(target.container) + audioSources.add(source) + } } } } catch (e: Throwable) { @@ -346,6 +419,62 @@ class VideoDownload { } } } + for (source in video.videoSources) { + if (source is IHLSManifestSource) { + try { + val playlistResponse = if ((source as JSSource).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) { + val variantSources = + HLS.parseAndGetAudioSources(source, playlistContent, source.url, true) + val target = + VideoHelper.selectBestAudioSource(variantSources, arrayOf(), null, targetBitrate) + if (target != null) { + (source as JSHLSManifestSource).setPreferredBitrate(target.bitrate) + source.setPreferredLanguage(target.language) + audioSources.add(source) + } + } + } + } catch (e: Throwable) { + Log.i(TAG, "Failed to get HLS audio sources", e) + } + } else if (source is JSDashManifestSource) { + val masterPlaylistResponse = ManagedHttpClient().get(source.url) + check(masterPlaylistResponse.isOk) { "Failed to get master playlist: ${masterPlaylistResponse.code}" } + + val hlsManifestUrl = masterPlaylistResponse.url + + val masterPlaylistContent = masterPlaylistResponse.body?.string() + ?: throw Exception("Master playlist content is empty") + + val inputStream = ByteArrayInputStream(masterPlaylistContent.toByteArray()) + + val playlist = + DashManifestParser().parse(Uri.parse(hlsManifestUrl), inputStream) + + val period = playlist.getPeriod(0) + + val representation = + period.adaptationSets.filter { it.type == C.TRACK_TYPE_AUDIO } + .flatMap { it.representations }.filter { + it.format.bitrate.toLong() == targetBitrate + }[0] + audioSources.add( + DashManifestAudioSourceDelegate( + source, representation.format.language + ?: Language.UNKNOWN, representation.format.bitrate, representation.format.containerMimeType!! + ) + ) + } + } var asource: IAudioSource? = null; if(targetAudioName != null) { @@ -370,6 +499,8 @@ class VideoDownload { audioSource = AudioUrlSource.fromUrlSource(asource) else if(asource is JSSource && requiresLiveAudioSource) audioSourceLive = asource; + else if (asource is DashManifestAudioSourceDelegate) + audioSourceLive = asource else throw DownloadException("Audio source is not supported for downloading (yet) [" + asource?.javaClass?.name + "]", false); } @@ -448,16 +579,23 @@ class VideoDownload { } } - if(actualVideoSource is IVideoUrlSource) - videoFileSize = when (videoSource!!.container) { - "application/vnd.apple.mpegurl" -> downloadHlsSource(context, "Video", client, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback) - else -> downloadFileSource("Video", client, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback) + videoFileSize = when (actualVideoSource) { + is IVideoUrlSource -> downloadFileSource("Video", client, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback) + is JSDashManifestRawSource -> { + downloadDashFileSource("Video", client, actualVideoSource, File(downloadDir, videoFileName!!), progressCallback) + } + + is JSHLSManifestSource -> { + downloadHlsSource(context, "Video", client, actualVideoSource, false, actualVideoSource.url, File(downloadDir, videoFileName!!), progressCallback) } - else if(actualVideoSource is JSDashManifestRawSource) { - videoFileSize = downloadDashFileSource("Video", client, actualVideoSource, File(downloadDir, videoFileName!!), progressCallback); + + is DashManifestSourceDelegate -> { + downloadDashSource(context, "Video", client, actualVideoSource.source, actualVideoSource.url, File(downloadDir, videoFileName!!), progressCallback) + } + + else -> throw NotImplementedError("NotImplemented video download: " + actualVideoSource.javaClass.name) } - else throw NotImplementedError("NotImplemented video download: " + actualVideoSource.javaClass.name); - }); + }) } if(actualAudioSource != null) { sourcesToDownload.add(async { @@ -488,16 +626,27 @@ class VideoDownload { } } - if(actualAudioSource is IAudioUrlSource) - audioFileSize = when (audioSource!!.container) { - "application/vnd.apple.mpegurl" -> downloadHlsSource(context, "Audio", client, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback) - else -> downloadFileSource("Audio", client, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback) + audioFileSize = when (actualAudioSource) { + is IVideoUrlSource -> downloadFileSource("Audio", client, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback) + is JSDashManifestRawAudioSource -> { + downloadDashFileSource("Audio", client, actualAudioSource, File(downloadDir, audioFileName!!), progressCallback) } - else if(actualAudioSource is JSDashManifestRawAudioSource) { - audioFileSize = downloadDashFileSource("Audio", client, actualAudioSource, File(downloadDir, audioFileName!!), progressCallback); + + is JSHLSManifestAudioSource -> { + downloadHlsSource(context, "Audio", client, actualAudioSource, false, actualAudioSource.url, File(downloadDir, audioFileName!!), progressCallback) + } + + is JSHLSManifestSource -> { + downloadHlsSource(context, "Audio", client, actualAudioSource, true, actualAudioSource.url, File(downloadDir, audioFileName!!), progressCallback) + } + + is DashManifestAudioSourceDelegate -> { + downloadDashSource(context, "Audio", client, actualAudioSource.source, actualAudioSource.url, File(downloadDir, audioFileName!!), progressCallback) + } + + else -> throw NotImplementedError("NotImplemented audio download: " + actualAudioSource.javaClass.name) } - else throw NotImplementedError("NotImplemented audio download: " + actualAudioSource.javaClass.name); - }); + }) } if (subtitleSource != null) { sourcesToDownload.add(async { @@ -544,7 +693,108 @@ class VideoDownload { } } - private suspend fun downloadHlsSource(context: Context, name: String, client: ManagedHttpClient, hlsUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long { + @OptIn(UnstableApi::class) + private suspend fun downloadDashSource(context: Context, name: String, client: ManagedHttpClient, source: JSSource?, hlsManifestUrl2: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long { + if (targetFile.exists()) targetFile.delete() + + var downloadedTotalLength = 0L + + val segmentFiles = arrayListOf<File>() + try { + val manifestResponse = ManagedHttpClient().get(hlsManifestUrl2) + check(manifestResponse.isOk) { "Failed to get DASH manifest: ${manifestResponse.code}" } + + val resolvedUrl = manifestResponse.url + + val manifestContent = manifestResponse.body?.string() + ?: throw Exception("Manifest content is empty") + + val inputStream = ByteArrayInputStream(manifestContent.toByteArray()) + val playlist = DashManifestParser().parse(Uri.parse(resolvedUrl), inputStream) + + val period = playlist.getPeriod(0) + + val representation = when (name) { + "Audio" -> { + period.adaptationSets.filter { it.type == C.TRACK_TYPE_AUDIO } + .flatMap { it.representations }.filter { + it.format.bitrate.toLong() == targetBitrate + }[0] + } + "Video" -> { + period.adaptationSets.filter { it.type == C.TRACK_TYPE_VIDEO } + .flatMap { it.representations }.filter { + (it.format.width * it.format.height).toLong() == targetPixelCount + }[0] + } + else -> { + throw Exception("Unknown type") + } + } + + val segmentIndex = representation.index + + if (segmentIndex != null) { + val baseUrl = representation.baseUrls[0] + val count = segmentIndex.getSegmentCount(C.TIME_UNSET) + for (index in 0 until count) { + val segmentUrl = if (index != 0L) segmentIndex.getSegmentUrl(index) + .resolveUriString(baseUrl.url) + else { + val init = representation.initializationUri ?: continue + init.resolveUriString(baseUrl.url) + } + Logger.i(TAG, "Download '$name' segment $index Sequential") + val segmentFile = File(context.cacheDir, "segment-${UUID.randomUUID()}") + val outputStream = segmentFile.outputStream() + outputStream.use { os -> + segmentFiles.add(segmentFile) + + val segmentLength = + downloadSource_Sequential(client, os, segmentUrl, null) { segmentLength, totalRead, lastSpeed -> + val averageSegmentLength = + if (index == 0L) segmentLength else downloadedTotalLength / index + val expectedTotalLength = + averageSegmentLength * (count - 1) + segmentLength + onProgress(expectedTotalLength, downloadedTotalLength + totalRead, lastSpeed) + } + + downloadedTotalLength += segmentLength + } + } + } else { + println("No segment index available for representation: ${representation.format.id}") + } + + Logger.i(TAG, "Combining segments into $targetFile") + combineSegments(context, segmentFiles, targetFile) + + Logger.i(TAG, "$name downloadSource Finished") + } catch (ioex: IOException) { + if (targetFile.exists()) targetFile.delete() + if (ioex.message?.contains("ENOSPC") == true + ) throw Exception("Not enough space on device", ioex) + else throw ioex + } catch (ex: Throwable) { + if (targetFile.exists()) targetFile.delete() + throw ex + } finally { + for (segmentFile in segmentFiles) { + segmentFile.delete() + } + } + return downloadedTotalLength + } + + 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?, audio: Boolean, hlsManifestUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long { if(targetFile.exists()) targetFile.delete(); @@ -552,13 +802,68 @@ class VideoDownload { val segmentFiles = arrayListOf<File>() try { - val response = client.get(hlsUrl) + val masterPlaylistResponse = ManagedHttpClient().get(hlsManifestUrl) + check(masterPlaylistResponse.isOk) { "Failed to get master playlist: ${masterPlaylistResponse.code}" } + + val resolvedSourceUrl = masterPlaylistResponse.url + + val masterPlaylistContent = masterPlaylistResponse.body?.string() + ?: throw Exception("Master playlist content is empty") + + val variantUrl = if (source is JSHLSManifestAudioSource){ + val audioTracks = HLS.parseAndGetAudioSources(source, masterPlaylistContent, resolvedSourceUrl, true) + + val variant = VideoHelper.selectBestAudioSource(audioTracks, arrayOf(), source.language, targetBitrate) + if (variant !is IAudioUrlSource){ + throw Exception("Variant is not an audio source") + } + variant.getAudioUrl() + }else if (audio && source is JSHLSManifestSource){ + val audioTracks = HLS.parseAndGetAudioSources(source, masterPlaylistContent, resolvedSourceUrl, true) + + val variant = VideoHelper.selectBestAudioSource(audioTracks, arrayOf(), source.language, targetBitrate) + if (variant !is IAudioUrlSource){ + throw Exception("Variant is not an audio source") + } + variant.getAudioUrl() + }else if (source is JSHLSManifestSource) { + val variants = HLS.parseAndGetVideoSources(source, masterPlaylistContent, resolvedSourceUrl) + + val variant = VideoHelper.selectBestVideoSource(variants, targetPixelCount!!.toInt(), arrayOf()) + if (variant !is IVideoUrlSource){ + throw Exception("Variant is not a video source") + } + variant.getVideoUrl() + } else { + throw Exception("Source is not a HLS manifest") + } + + val response = if (source.hasRequestModifier) { + val request = source.getRequestModifier()!!.modifyRequest(variantUrl, mapOf()) + client.get(request.url!!, request.headers.toMutableMap()) + } else { + client.get(variantUrl) + } 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 variantPlaylist = HLS.parseVariantPlaylist(vpContent, variantUrl) + val decryptionInfo: DecryptionInfo? = if (variantPlaylist.decryptionInfo != null) { + val keyResponse = if (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 @@ -570,7 +875,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) @@ -608,12 +913,11 @@ class VideoDownload { return downloadedTotalLength; } - 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}\"" + private suspend fun combineSegments(context: Context, segmentFiles: List<File>, targetFile: File) = + withContext(Dispatchers.IO) { + suspendCancellableCoroutine { continuation -> + val cmd = + "-i \"concat:${segmentFiles.joinToString("|")}\" -c copy \"${targetFile.absolutePath}\"" val statisticsCallback = StatisticsCallback { _ -> //TODO: Show progress? @@ -623,7 +927,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)) { @@ -631,7 +934,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)) } }, @@ -751,7 +1053,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 @@ -778,7 +1080,31 @@ class VideoDownload { } return sourceLength!!; } - private fun downloadSource_Sequential(client: ManagedHttpClient, fileStream: FileOutputStream, url: String, onProgress: (Long, Long, Long) -> Unit): Long { + // methods are auto generated + 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; @@ -798,6 +1124,8 @@ class VideoDownload { val sourceLength = result.body.contentLength(); val sourceStream = result.body.byteStream(); + val segmentBuffer = ByteArrayOutputStream() + var totalRead: Long = 0; try { var read: Int; @@ -808,7 +1136,7 @@ class VideoDownload { if (read < 0) break; - fileStream.write(buffer, 0, read); + segmentBuffer.write(buffer, 0, read); totalRead += read; @@ -834,6 +1162,13 @@ 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; } @@ -1025,7 +1360,7 @@ class VideoDownload { val expectedFile = File(videoFilePath!!); if(!expectedFile.exists()) throw IllegalStateException("Video file missing after download"); - if (videoSource?.container != "application/vnd.apple.mpegurl") { + if (videoSourceLive !is IHLSManifestSource && videoSourceLive !is IDashManifestSource) { if (expectedFile.length() != videoFileSize) throw IllegalStateException("Expected size [${videoFileSize}], but found ${expectedFile.length()}"); } @@ -1036,7 +1371,7 @@ class VideoDownload { val expectedFile = File(audioFilePath!!); if(!expectedFile.exists()) throw IllegalStateException("Audio file missing after download"); - if (audioSource?.container != "application/vnd.apple.mpegurl") { + if (audioSourceLive !is IHLSManifestAudioSource && audioSourceLive !is IHLSManifestSource && audioSourceLive !is IDashManifestSource) { if (expectedFile.length() != audioFileSize) throw IllegalStateException("Expected size [${audioFileSize}], but found ${expectedFile.length()}"); } @@ -1121,7 +1456,7 @@ class VideoDownload { val REGEX_DASH_CUE = Regex("<S .*?t=\"([0-9]*?)\".*?d=\"([0-9]*?)\".*?\\/>", RegexOption.DOT_MATCHES_ALL); fun videoContainerToExtension(container: String): String? { - if (container.contains("video/mp4") || container == "application/vnd.apple.mpegurl") + if (container.contains("video/mp4")) return "mp4"; else if (container.contains("application/x-mpegURL")) return "m3u8"; @@ -1133,21 +1468,26 @@ class VideoDownload { return "webm"; else if (container.contains("video/x-matroska")) return "mkv"; + else if (container.contains("video/mp2t")) + return "m2ts" + else if (container == "application/vnd.apple.mpegurl") + return "mp4" else return "video"; } fun audioContainerToExtension(container: String): String { if (container.contains("audio/mp4")) - return "mp4a"; + return "m4a"; else if (container.contains("audio/mpeg")) - return "mpga"; + return "mp3"; +// return "mpga"; else if (container.contains("audio/mp3")) return "mp3"; + else if (container == "application/vnd.apple.mpegurl") + return "m4a" else if (container.contains("audio/webm")) return "webma"; - else if (container == "application/vnd.apple.mpegurl") - return "mp4"; else return "audio"; } diff --git a/app/src/main/java/com/futo/platformplayer/downloads/VideoExport.kt b/app/src/main/java/com/futo/platformplayer/downloads/VideoExport.kt index 7c1c4e0988b93be9b204aee5fa3b5c2ef63d6ef6..b9899946a51c365bd88b5ff8597260190d28e432 100644 --- a/app/src/main/java/com/futo/platformplayer/downloads/VideoExport.kt +++ b/app/src/main/java/com/futo/platformplayer/downloads/VideoExport.kt @@ -69,7 +69,7 @@ class VideoExport { outputFile = f; } else if (v != null) { val outputFileName = videoLocal.name.sanitizeFileName(true) + "." + VideoDownload.videoContainerToExtension(v.container); - val f = downloadRoot.createFile(v.container, outputFileName) + val f = downloadRoot.createFile(if (v.container == "application/vnd.apple.mpegurl") "video/mp2t" else v.container, outputFileName) ?: throw Exception("Failed to create file in external directory."); Logger.i(TAG, "Copying video."); @@ -81,7 +81,7 @@ class VideoExport { outputFile = f; } else if (a != null) { val outputFileName = videoLocal.name.sanitizeFileName(true) + "." + VideoDownload.audioContainerToExtension(a.container); - val f = downloadRoot.createFile(a.container, outputFileName) + val f = downloadRoot.createFile(if (a.container == "application/vnd.apple.mpegurl") "audio/mp3" else a.container, outputFileName) ?: throw Exception("Failed to create file in external directory."); Logger.i(TAG, "Copying audio."); diff --git a/app/src/main/java/com/futo/platformplayer/helpers/VideoHelper.kt b/app/src/main/java/com/futo/platformplayer/helpers/VideoHelper.kt index 87e8f0510b8205113e24bd3687b8d4b58a73f261..11db26c2347cc2ca5576a56e510494941195f28a 100644 --- a/app/src/main/java/com/futo/platformplayer/helpers/VideoHelper.kt +++ b/app/src/main/java/com/futo/platformplayer/helpers/VideoHelper.kt @@ -13,6 +13,7 @@ import com.futo.platformplayer.api.media.models.streams.IVideoSourceDescriptor import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor 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 @@ -46,7 +47,7 @@ class VideoHelper { return false } - fun isDownloadable(source: IVideoSource) = (source is IVideoUrlSource || source is IHLSManifestSource || source is JSDashManifestRawSource) && source !is IWidevineSource + fun isDownloadable(source: IVideoSource) = (source is IVideoUrlSource || source is IHLSManifestSource || source is JSDashManifestRawSource || source is IDashManifestSource) && source !is IWidevineSource fun isDownloadable(source: IAudioSource) = (source is IAudioUrlSource || source is IHLSManifestAudioSource || source is JSDashManifestRawAudioSource) && source !is IWidevineSource fun selectBestVideoSource(desc: IVideoSourceDescriptor, desiredPixelCount : Int, prefContainers : Array<String>) : IVideoSource? = selectBestVideoSource(desc.videoSources.toList(), desiredPixelCount, prefContainers); 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 9d1a3faa1b3665e76bf71cfdf854a97f01373534..e7a853cdcf2b778c23c3497b98f4ccbf68a354b4 100644 --- a/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt +++ b/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt @@ -1,5 +1,11 @@ package com.futo.platformplayer.parsers +import android.net.Uri +import androidx.annotation.OptIn +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.hls.playlist.DefaultHlsPlaylistParserFactory +import androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist +import androidx.media3.exoplayer.hls.playlist.HlsMultivariantPlaylist import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantAudioUrlSource import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantSubtitleUrlSource import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantVideoUrlSource @@ -7,13 +13,20 @@ import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestAudi import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource import com.futo.platformplayer.toYesNo import com.futo.platformplayer.yesNoToBoolean +import java.io.ByteArrayInputStream import java.net.URI +import java.net.URLConnection import java.time.ZonedDateTime import java.time.format.DateTimeFormatter class HLS { companion object { - fun parseMasterPlaylist(masterPlaylistContent: String, sourceUrl: String): MasterPlaylist { + @OptIn(UnstableApi::class) + fun parseMasterPlaylist(masterPlaylistContent: String, sourceUrl: String, isAudioSource: Boolean? = null): MasterPlaylist { + val inputStream = ByteArrayInputStream(masterPlaylistContent.toByteArray()) + val playlist = DefaultHlsPlaylistParserFactory().createPlaylistParser() + .parse(Uri.parse(sourceUrl), inputStream) + val baseUrl = URI(sourceUrl).resolve("./").toString() val variantPlaylists = mutableListOf<VariantPlaylistReference>() @@ -21,27 +34,38 @@ class HLS { val sessionDataList = mutableListOf<SessionData>() 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))) - } + if (playlist is HlsMediaPlaylist) { + independentSegments = playlist.hasIndependentSegments + if (isAudioSource == true) { + val firstSegmentUrlFile = + Uri.parse(playlist.segments[0].url).buildUpon().clearQuery().fragment(null) + .build().toString() + mediaRenditions.add(MediaRendition("AUDIO", playlist.baseUri, "Single Playlist", null, null, null, null, null, URLConnection.guessContentTypeFromName(firstSegmentUrlFile))) + } else { + variantPlaylists.add(VariantPlaylistReference(playlist.baseUri, StreamInfo(null, null, null, null, null, null, null, null, null))) + } + } else if (playlist is HlsMultivariantPlaylist) { + 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(line, baseUrl)) - } + line.startsWith("#EXT-X-MEDIA") -> { + mediaRenditions.add(parseMediaRendition(line, baseUrl)) + } - line == "#EXT-X-INDEPENDENT-SEGMENTS" -> { - independentSegments = true - } + line == "#EXT-X-INDEPENDENT-SEGMENTS" -> { + independentSegments = true + } - line.startsWith("#EXT-X-SESSION-DATA") -> { - val sessionData = parseSessionData(line) - sessionDataList.add(sessionData) + line.startsWith("#EXT-X-SESSION-DATA") -> { + val sessionData = parseSessionData(line) + sessionDataList.add(sessionData) + } } } } @@ -61,7 +85,26 @@ 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,7 +129,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> { @@ -109,10 +152,10 @@ class HLS { } } - fun parseAndGetAudioSources(source: Any, content: String, url: String): List<HLSVariantAudioUrlSource> { + fun parseAndGetAudioSources(source: Any, content: String, url: String, isAudioSource: Boolean? = null): List<HLSVariantAudioUrlSource> { val masterPlaylist: MasterPlaylist try { - masterPlaylist = parseMasterPlaylist(content, url) + masterPlaylist = parseMasterPlaylist(content, url, isAudioSource) return masterPlaylist.getAudioSources() } catch (e: Throwable) { if (content.lines().any { it.startsWith("#EXTINF:") }) { @@ -203,10 +246,10 @@ class HLS { private val _quoteList = listOf("GROUP-ID", "NAME", "URI", "CODECS", "AUDIO", "VIDEO") private fun shouldQuote(key: String, value: String?): Boolean { if (value == null) - return false; + return false if (value.contains(',')) - return true; + return true return _quoteList.contains(key) } @@ -270,7 +313,8 @@ class HLS { val name: String?, val isDefault: Boolean?, val isAutoSelect: Boolean?, - val isForced: Boolean? + val isForced: Boolean?, + val container: String? = null ) { fun toM3U8Line(): String = buildString { append("#EXT-X-MEDIA:") @@ -340,7 +384,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, it.container?: "application/vnd.apple.mpegurl", "", it.language ?: "", null, false, it.uri) else -> null } } @@ -368,6 +412,11 @@ class HLS { } } + data class DecryptionInfo( + val keyUrl: String, + val iv: String + ) + data class VariantPlaylist( val version: Int?, val targetDuration: Int?, @@ -376,7 +425,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")