improve support for HLS and DASH downloads

Changelog: added
This commit is contained in:
Kai 2025-01-22 16:05:44 -06:00
parent a410e2962a
commit 0190bbffdd
No known key found for this signature in database
12 changed files with 731 additions and 148 deletions

View file

@ -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,114 @@ 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)
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 +399,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 +420,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,7 +459,12 @@ 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))
},
@ -366,11 +497,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 +548,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,31 +561,11 @@ class UISlideOverlays {
menu?.setOk(container.context.getString(R.string.download));
},
invokeParent = false
)) +
)) else listOf()) +
videoSources
.filter { it.isDownloadable() }
.map {
when (it) {
is IVideoUrlSource -> {
val estSize = VideoHelper.estimateSourceSize(it);
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
SlideUpMenuItem(
container.context,
R.drawable.ic_movie,
it.name,
"${it.width}x${it.height}",
(prefix + it.codec).trim(),
tag = it,
call = {
selectedVideo = it
menu?.selectOption(videoSources, it);
if(selectedAudio != null || !requiresAudio)
menu?.setOk(container.context.getString(R.string.download));
},
invokeParent = false
)
}
is JSDashManifestRawSource -> {
val estSize = VideoHelper.estimateSourceSize(it);
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
@ -475,7 +586,15 @@ class UISlideOverlays {
)
}
is IHLSManifestSource -> {
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,
@ -489,6 +608,26 @@ class UISlideOverlays {
)
}
is IVideoUrlSource -> {
val estSize = VideoHelper.estimateSourceSize(it);
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
SlideUpMenuItem(
container.context,
R.drawable.ic_movie,
it.name,
"${it.width}x${it.height}",
(prefix + it.codec).trim(),
tag = it,
call = {
selectedVideo = it
menu?.selectOption(videoSources, it);
if(selectedAudio != null || !requiresAudio)
menu?.setOk(container.context.getString(R.string.download));
},
invokeParent = false
)
}
else -> {
Logger.w(TAG, "Unhandled source type for UISlideOverlay download items");
null;//throw Exception("Unhandled source type")
@ -549,7 +688,7 @@ class UISlideOverlays {
);
}
is IHLSManifestAudioSource -> {
is JSHLSManifestAudioSource -> {
SlideUpMenuItem(
container.context,
R.drawable.ic_movie,
@ -614,13 +753,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
}

View file

@ -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;
}
}

View file

@ -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()
}
}

View file

@ -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
}

View file

@ -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

View file

@ -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) };

View file

@ -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;
}
}

View file

@ -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;
}

View file

@ -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)
}
else if(actualVideoSource is JSDashManifestRawSource) {
videoFileSize = downloadDashFileSource("Video", client, actualVideoSource, File(downloadDir, videoFileName!!), progressCallback);
is JSHLSManifestSource -> {
downloadHlsSource(context, "Video", client, actualVideoSource, false, actualVideoSource.url, 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";
}

View file

@ -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.");

View file

@ -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);

View file

@ -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)
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)))
}
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,25 @@ 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:URI=") }?.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 +128,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 +151,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 +245,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 +312,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 +383,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 +411,11 @@ class HLS {
}
}
data class DecryptionInfo(
val keyUrl: String,
val iv: String
)
data class VariantPlaylist(
val version: Int?,
val targetDuration: Int?,
@ -376,7 +424,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")