improve support for HLS and DASH downloads

Changelog: added
This commit is contained in:
Kai 2025-01-22 16:05:44 -06:00
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.ContentResolver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri
import android.view.View import android.view.View
import android.view.ViewGroup 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 androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.activities.SettingsActivity 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.ResultCapabilities
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel 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.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.HLSVariantAudioUrlSource
import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantVideoUrlSource 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.IAudioSource
import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlSource 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.IHLSManifestAudioSource
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource import com.futo.platformplayer.api.media.models.streams.sources.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.JSClient
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawAudioSource 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.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.downloads.VideoLocal
import com.futo.platformplayer.fragment.mainactivity.main.SubscriptionGroupFragment import com.futo.platformplayer.fragment.mainactivity.main.SubscriptionGroupFragment
import com.futo.platformplayer.helpers.VideoHelper 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.Playlist
import com.futo.platformplayer.models.Subscription import com.futo.platformplayer.models.Subscription
import com.futo.platformplayer.models.SubscriptionGroup import com.futo.platformplayer.models.SubscriptionGroup
import com.futo.platformplayer.others.Language
import com.futo.platformplayer.parsers.HLS import com.futo.platformplayer.parsers.HLS
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateDownloads import com.futo.platformplayer.states.StateDownloads
@ -63,6 +76,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.ByteArrayInputStream
class UISlideOverlays { class UISlideOverlays {
companion object { 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 items = arrayListOf<View>(LoaderView(container.context))
val slideUpMenuOverlay = SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.download_video), null, true, items) val slideUpMenuOverlay = SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.download_video), null, true, items)
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { 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}" } check(masterPlaylistResponse.isOk) { "Failed to get master playlist: ${masterPlaylistResponse.code}" }
val resolvedSourceUrl = masterPlaylistResponse.url
val masterPlaylistContent = masterPlaylistResponse.body?.string() val masterPlaylistContent = masterPlaylistResponse.body?.string()
?: throw Exception("Master playlist content is empty") ?: throw Exception("Master playlist content is empty")
@ -285,14 +399,14 @@ class UISlideOverlays {
//TODO: Implement subtitles //TODO: Implement subtitles
//val subtitleButtons = arrayListOf<SlideUpMenuItem>() //val subtitleButtons = arrayListOf<SlideUpMenuItem>()
var selectedVideoVariant: HLSVariantVideoUrlSource? = null var selectedVideoVariant: IHLSManifestSource? = null
var selectedAudioVariant: HLSVariantAudioUrlSource? = null var selectedAudioVariant: IAudioSource? = null
//TODO: Implement subtitles //TODO: Implement subtitles
//var selectedSubtitleVariant: HLSVariantSubtitleUrlSource? = null //var selectedSubtitleVariant: HLSVariantSubtitleUrlSource? = null
val masterPlaylist: HLS.MasterPlaylist val masterPlaylist: HLS.MasterPlaylist
try { try {
masterPlaylist = HLS.parseMasterPlaylist(masterPlaylistContent, sourceUrl) masterPlaylist = HLS.parseMasterPlaylist(masterPlaylistContent, masterPlaylistResponse.url, source is IHLSManifestAudioSource)
masterPlaylist.getAudioSources().forEach { it -> masterPlaylist.getAudioSources().forEach { it ->
@ -306,7 +420,19 @@ class UISlideOverlays {
(prefix + it.codec).trim(), (prefix + it.codec).trim(),
tag = it, tag = it,
call = { 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.selectOption(audioButtons, it)
slideUpMenuOverlay.setOk(container.context.getString(R.string.download)) slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
}, },
@ -333,7 +459,12 @@ class UISlideOverlays {
(prefix + it.codec).trim(), (prefix + it.codec).trim(),
tag = it, tag = it,
call = { 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.selectOption(videoButtons, it)
slideUpMenuOverlay.setOk(container.context.getString(R.string.download)) slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
}, },
@ -366,11 +497,11 @@ class UISlideOverlays {
if (masterPlaylistContent.lines().any { it.startsWith("#EXTINF:") }) { if (masterPlaylistContent.lines().any { it.startsWith("#EXTINF:") }) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
if (source is IHLSManifestSource) { 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") UIDialogs.toast(container.context, "Variant video HLS playlist download started")
slideUpMenuOverlay.hide() slideUpMenuOverlay.hide()
} else if (source is IHLSManifestAudioSource) { } 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") UIDialogs.toast(container.context, "Variant audio HLS playlist download started")
slideUpMenuOverlay.hide() slideUpMenuOverlay.hide()
} else { } else {
@ -417,7 +548,7 @@ class UISlideOverlays {
} }
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.video), videoSources, items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.video), videoSources,
listOf(listOf(SlideUpMenuItem( listOf((if (audioSources != null) listOf(SlideUpMenuItem(
container.context, container.context,
R.drawable.ic_movie, R.drawable.ic_movie,
container.context.getString(R.string.none), container.context.getString(R.string.none),
@ -430,31 +561,11 @@ class UISlideOverlays {
menu?.setOk(container.context.getString(R.string.download)); menu?.setOk(container.context.getString(R.string.download));
}, },
invokeParent = false invokeParent = false
)) + )) else listOf()) +
videoSources videoSources
.filter { it.isDownloadable() } .filter { it.isDownloadable() }
.map { .map {
when (it) { 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 -> { is JSDashManifestRawSource -> {
val estSize = VideoHelper.estimateSourceSize(it); val estSize = VideoHelper.estimateSourceSize(it);
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else ""; 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( SlideUpMenuItem(
container.context, container.context,
R.drawable.ic_movie, 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 -> { else -> {
Logger.w(TAG, "Unhandled source type for UISlideOverlay download items"); Logger.w(TAG, "Unhandled source type for UISlideOverlay download items");
null;//throw Exception("Unhandled source type") null;//throw Exception("Unhandled source type")
@ -549,7 +688,7 @@ class UISlideOverlays {
); );
} }
is IHLSManifestAudioSource -> { is JSHLSManifestAudioSource -> {
SlideUpMenuItem( SlideUpMenuItem(
container.context, container.context,
R.drawable.ic_movie, R.drawable.ic_movie,
@ -614,13 +753,18 @@ class UISlideOverlays {
menu.onOK.subscribe { menu.onOK.subscribe {
val sv = selectedVideo val sv = selectedVideo
if (sv is IHLSManifestSource) { if (sv is JSHLSManifestSource) {
showHlsPicker(video, sv, sv.url, container) showHlsPicker(video, sv, sv.url, container)
return@subscribe return@subscribe
} }
if (sv is JSDashManifestSource) {
showDashPicker(video, sv, sv.url, container)
return@subscribe
}
val sa = selectedAudio val sa = selectedAudio
if (sa is IHLSManifestAudioSource) { if (sa is JSHLSManifestAudioSource) {
showHlsPicker(video, sa, sa.url, container) showHlsPicker(video, sa, sa.url, container)
return@subscribe return@subscribe
} }

View file

@ -1,18 +1,21 @@
package com.futo.platformplayer.api.media.models.streams.sources package com.futo.platformplayer.api.media.models.streams.sources
import com.futo.platformplayer.others.Language
class HLSManifestSource : IVideoSource, IHLSManifestSource { class HLSManifestSource : IVideoSource, IHLSManifestSource {
override val width : Int = 0; override val width: Int = 0;
override val height : Int = 0; override val height: Int = 0;
override val container : String = "HLS"; override val container: String = "HLS";
override val codec: String = "HLS"; override val codec: String = "HLS";
override val name : String = "HLS"; override val name: String = "HLS";
override val bitrate : Int? = null; override val bitrate: Int = 0;
override val url : String; override val url: String;
override val duration: Long = 0; override val duration: Long = 0;
override val language: String = Language.UNKNOWN;
override var priority: Boolean = false; override var priority: Boolean = false;
constructor(url : String) { constructor(url: String) {
this.url = url; this.url = url;
} }
} }

View file

@ -1,5 +1,29 @@
package com.futo.platformplayer.api.media.models.streams.sources 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 { 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 package com.futo.platformplayer.api.media.models.streams.sources
interface IHLSManifestSource : IVideoSource { interface IHLSManifestSource : IVideoSource, IAudioSource {
val url : String; val url : String
} }
interface IHLSManifestAudioSource : IAudioSource { 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.IDashManifestSource
import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource
import com.futo.platformplayer.api.media.platforms.js.JSClient 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.getOrNull
import com.futo.platformplayer.getOrThrow 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.V8Value
import com.caoccao.javet.values.reference.V8ValueObject 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.models.streams.sources.IHLSManifestAudioSource
import com.futo.platformplayer.api.media.platforms.js.JSClient 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.getOrNull
import com.futo.platformplayer.getOrThrow import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.orNull import com.futo.platformplayer.orNull
class JSHLSManifestAudioSource : IHLSManifestAudioSource, JSSource { 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 codec: String = "HLS";
override val name : String; override val name : String;
override val bitrate : Int = 0; override var bitrate : Int = 0;
override val url : String; override val url : String;
override val duration: Long; override val duration: Long;
override val language: String; override var language: String;
override var priority: Boolean = false; override var priority: Boolean = false;
@ -34,6 +31,17 @@ class JSHLSManifestAudioSource : IHLSManifestAudioSource, JSSource {
priority = obj.getOrNull(config, "priority", contextName) ?: false; 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 { companion object {
fun fromV8HLSNullable(plugin: JSClient, obj: V8Value?) : JSHLSManifestAudioSource? = obj.orNull { fromV8HLS(plugin, it as V8ValueObject) }; 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.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource 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.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.getOrNull
import com.futo.platformplayer.getOrThrow import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.others.Language
class JSHLSManifestSource : IHLSManifestSource, JSSource { class JSHLSManifestSource : IHLSManifestSource, JSSource {
override val width : Int = 0; override var width : Int = 0;
override val height : Int = 0; override var height : Int = 0;
override val container : String get() = "application/vnd.apple.mpegurl"; override val container : String get() = "application/vnd.apple.mpegurl";
override val codec: String = "HLS"; override val codec: String = "HLS";
override val name : String; override val name : String;
override val bitrate : Int? = null; override var bitrate : Int = 0;
override val url : String; override val url : String;
override val duration: Long; override val duration: Long;
override var language: String = Language.UNKNOWN
override var priority: Boolean = false; override var priority: Boolean = false;
@ -31,4 +30,20 @@ class JSHLSManifestSource : IHLSManifestSource, JSSource {
priority = obj.getOrNull(config, "priority", contextName) ?: false; 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 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.V8Value
import com.caoccao.javet.values.reference.V8ValueObject import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.api.media.models.modifier.AdhocRequestModifier 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.getOrDefault
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.orNull 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 _plugin: JSClient;
protected val _config: IV8PluginConfig; protected val _config: IV8PluginConfig;
protected val _obj: V8ValueObject; protected val _obj: V8ValueObject;
@ -88,7 +89,7 @@ abstract class JSSource {
fun getUnderlyingPlugin(): JSClient? { fun getUnderlyingPlugin(): JSClient? {
return _plugin; return _plugin;
} }
fun getUnderlyingObject(): V8ValueObject? { override fun getUnderlyingObject(): V8ValueObject? {
return _obj; return _obj;
} }

View file

@ -1,7 +1,12 @@
package com.futo.platformplayer.downloads package com.futo.platformplayer.downloads
import android.content.Context import android.content.Context
import android.net.Uri
import android.util.Log 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.FFmpegKit
import com.arthenica.ffmpegkit.ReturnCode import com.arthenica.ffmpegkit.ReturnCode
import com.arthenica.ffmpegkit.StatisticsCallback 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.PlatformID
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor 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.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.IAudioSource
import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlSource 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.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.SerializedPlatformVideo
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideoDetails 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.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.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.JSDashManifestRawAudioSource
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawSource 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.api.media.platforms.js.models.sources.JSSource
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.exceptions.DownloadException import com.futo.platformplayer.exceptions.DownloadException
import com.futo.platformplayer.helpers.FileHelper.Companion.sanitizeFileName import com.futo.platformplayer.helpers.FileHelper.Companion.sanitizeFileName
import com.futo.platformplayer.helpers.VideoHelper import com.futo.platformplayer.helpers.VideoHelper
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.others.Language
import com.futo.platformplayer.parsers.HLS import com.futo.platformplayer.parsers.HLS
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
import com.futo.platformplayer.states.StateDownloads import com.futo.platformplayer.states.StateDownloads
import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StatePlugins
import com.futo.platformplayer.toHumanBitrate import com.futo.platformplayer.toHumanBitrate
import com.futo.platformplayer.toHumanBytesSpeed import com.futo.platformplayer.toHumanBytesSpeed
import com.futo.polycentric.core.hexStringToByteArray
import hasAnySource import hasAnySource
import isDownloadable import isDownloadable
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
@ -59,6 +68,8 @@ import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.Contextual import kotlinx.serialization.Contextual
import kotlinx.serialization.Transient import kotlinx.serialization.Transient
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import java.io.IOException import java.io.IOException
@ -69,8 +80,10 @@ import java.util.concurrent.Executors
import java.util.concurrent.ForkJoinPool import java.util.concurrent.ForkJoinPool
import java.util.concurrent.ForkJoinTask import java.util.concurrent.ForkJoinTask
import java.util.concurrent.ThreadLocalRandom import java.util.concurrent.ThreadLocalRandom
import javax.crypto.Cipher
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
import kotlin.coroutines.resumeWithException import kotlin.coroutines.resumeWithException
import kotlin.time.times
@kotlinx.serialization.Serializable @kotlinx.serialization.Serializable
class VideoDownload { class VideoDownload {
@ -119,18 +132,21 @@ class VideoDownload {
var requiresLiveVideoSource: Boolean = false; var requiresLiveVideoSource: Boolean = false;
@Contextual @Contextual
@kotlinx.serialization.Transient @kotlinx.serialization.Transient
var videoSourceLive: JSSource? = null; var videoSourceLive: IUnderlyingObject? = null;
val isLiveVideoSourceValid get() = videoSourceLive?.getUnderlyingObject()?.isClosed?.let { !it } ?: false; val isLiveVideoSourceValid get() = videoSourceLive?.getUnderlyingObject()?.isClosed?.let { !it } ?: false;
var requiresLiveAudioSource: Boolean = false; var requiresLiveAudioSource: Boolean = false;
@Contextual @Contextual
@kotlinx.serialization.Transient @kotlinx.serialization.Transient
var audioSourceLive: JSSource? = null; var audioSourceLive: IUnderlyingObject? = null;
val isLiveAudioSourceValid get() = audioSourceLive?.getUnderlyingObject()?.isClosed?.let { !it } ?: false; val isLiveAudioSourceValid get() = audioSourceLive?.getUnderlyingObject()?.isClosed?.let { !it } ?: false;
var hasVideoRequestExecutor: Boolean = false; var hasVideoRequestExecutor: Boolean = false;
var hasAudioRequestExecutor: Boolean = false; var hasAudioRequestExecutor: Boolean = false;
private var hasVideoRequestModifier: Boolean = false
private var hasAudioRequestModifier: Boolean = false
var progress: Double = 0.0; var progress: Double = 0.0;
var isCancelled = false; var isCancelled = false;
@ -191,8 +207,10 @@ class VideoDownload {
this.prepareTime = OffsetDateTime.now(); this.prepareTime = OffsetDateTime.now();
this.hasVideoRequestExecutor = videoSource is JSSource && videoSource.hasRequestExecutor; this.hasVideoRequestExecutor = videoSource is JSSource && videoSource.hasRequestExecutor;
this.hasAudioRequestExecutor = audioSource is JSSource && audioSource.hasRequestExecutor; this.hasAudioRequestExecutor = audioSource is JSSource && audioSource.hasRequestExecutor;
this.requiresLiveVideoSource = this.hasVideoRequestExecutor || (videoSource is JSDashManifestRawSource && videoSource.hasGenerate); this.hasVideoRequestModifier = videoSource is JSSource && videoSource.hasRequestModifier
this.requiresLiveAudioSource = this.hasAudioRequestExecutor || (audioSource is JSDashManifestRawAudioSource && audioSource.hasGenerate); 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.targetVideoName = videoSource?.name;
this.targetAudioName = audioSource?.name; this.targetAudioName = audioSource?.name;
this.targetPixelCount = if(videoSource != null) (videoSource.width * videoSource.height).toLong() else null; this.targetPixelCount = if(videoSource != null) (videoSource.width * videoSource.height).toLong() else null;
@ -227,6 +245,7 @@ class VideoDownload {
return items.joinToString(""); return items.joinToString("");
} }
@OptIn(UnstableApi::class)
suspend fun prepare(client: ManagedHttpClient) { suspend fun prepare(client: ManagedHttpClient) {
Logger.i(TAG, "VideoDownload Prepare [${name}]"); Logger.i(TAG, "VideoDownload Prepare [${name}]");
@ -282,21 +301,57 @@ class VideoDownload {
} }
videoDetails = SerializedPlatformVideoDetails.fromVideo(original, if (subtitleSource != null) listOf(subtitleSource!!) else listOf()); 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>() val videoSources = arrayListOf<IVideoSource>()
for (source in original.video.videoSources) { for (source in original.video.videoSources) {
if (source is IHLSManifestSource) { if (source is IHLSManifestSource) {
try { 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) { if (playlistResponse.isOk) {
val playlistContent = playlistResponse.body?.string() val playlistContent = playlistResponse.body?.string()
if (playlistContent != null) { 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) { } catch (e: Throwable) {
Log.i(TAG, "Failed to get HLS video sources", e) 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 { } else {
videoSources.add(source) videoSources.add(source)
} }
@ -320,22 +375,40 @@ class VideoDownload {
videoSource = VideoUrlSource.fromUrlSource(vsource) videoSource = VideoUrlSource.fromUrlSource(vsource)
else if(vsource is JSSource && requiresLiveVideoSource) else if(vsource is JSSource && requiresLiveVideoSource)
videoSourceLive = vsource; videoSourceLive = vsource;
else if (vsource is DashManifestSourceDelegate)
videoSourceLive = vsource
else else
throw DownloadException("Video source is not supported for downloading (yet) [" + vsource?.javaClass?.name + "]", false); 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>() var audioSources = mutableListOf<IAudioSource>()
val video = original.video val video = original.video
if (video is VideoUnMuxedSourceDescriptor) { if (video is VideoUnMuxedSourceDescriptor) {
for (source in video.audioSources) { for (source in video.audioSources) {
if (source is IHLSManifestAudioSource) { if (source is IHLSManifestAudioSource) {
try { 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) { if (playlistResponse.isOk) {
val playlistContent = playlistResponse.body?.string() val playlistContent = playlistResponse.body?.string()
if (playlistContent != null) { 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) { } 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; var asource: IAudioSource? = null;
if(targetAudioName != null) { if(targetAudioName != null) {
@ -370,6 +499,8 @@ class VideoDownload {
audioSource = AudioUrlSource.fromUrlSource(asource) audioSource = AudioUrlSource.fromUrlSource(asource)
else if(asource is JSSource && requiresLiveAudioSource) else if(asource is JSSource && requiresLiveAudioSource)
audioSourceLive = asource; audioSourceLive = asource;
else if (asource is DashManifestAudioSourceDelegate)
audioSourceLive = asource
else else
throw DownloadException("Audio source is not supported for downloading (yet) [" + asource?.javaClass?.name + "]", false); 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 (actualVideoSource) {
videoFileSize = when (videoSource!!.container) { is IVideoUrlSource -> downloadFileSource("Video", client, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
"application/vnd.apple.mpegurl" -> downloadHlsSource(context, "Video", client, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback) is JSDashManifestRawSource -> {
else -> downloadFileSource("Video", client, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback) 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) { if(actualAudioSource != null) {
sourcesToDownload.add(async { sourcesToDownload.add(async {
@ -488,16 +626,27 @@ class VideoDownload {
} }
} }
if(actualAudioSource is IAudioUrlSource) audioFileSize = when (actualAudioSource) {
audioFileSize = when (audioSource!!.container) { is IVideoUrlSource -> downloadFileSource("Audio", client, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
"application/vnd.apple.mpegurl" -> downloadHlsSource(context, "Audio", client, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback) is JSDashManifestRawAudioSource -> {
else -> downloadFileSource("Audio", client, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback) 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) { if (subtitleSource != null) {
sourcesToDownload.add(async { 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()) if(targetFile.exists())
targetFile.delete(); targetFile.delete();
@ -552,13 +802,68 @@ class VideoDownload {
val segmentFiles = arrayListOf<File>() val segmentFiles = arrayListOf<File>()
try { 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}" } check(response.isOk) { "Failed to get variant playlist: ${response.code}" }
val vpContent = response.body?.string() val vpContent = response.body?.string()
?: throw Exception("Variant playlist content is empty") ?: throw Exception("Variant playlist content is empty")
val variantPlaylist = HLS.parseVariantPlaylist(vpContent, hlsUrl) val variantPlaylist = HLS.parseVariantPlaylist(vpContent, 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 -> variantPlaylist.segments.forEachIndexed { index, segment ->
if (segment !is HLS.MediaSegment) { if (segment !is HLS.MediaSegment) {
return@forEachIndexed return@forEachIndexed
@ -570,7 +875,7 @@ class VideoDownload {
try { try {
segmentFiles.add(segmentFile) segmentFiles.add(segmentFile)
val segmentLength = downloadSource_Sequential(client, outputStream, segment.uri) { segmentLength, totalRead, lastSpeed -> val segmentLength = downloadSource_Sequential(client, outputStream, segment.uri, if (index == 0) null else decryptionInfo) { segmentLength, totalRead, lastSpeed ->
val averageSegmentLength = if (index == 0) segmentLength else downloadedTotalLength / index val averageSegmentLength = if (index == 0) segmentLength else downloadedTotalLength / index
val expectedTotalLength = averageSegmentLength * (variantPlaylist.segments.size - 1) + segmentLength val expectedTotalLength = averageSegmentLength * (variantPlaylist.segments.size - 1) + segmentLength
onProgress(expectedTotalLength, downloadedTotalLength + totalRead, lastSpeed) onProgress(expectedTotalLength, downloadedTotalLength + totalRead, lastSpeed)
@ -608,12 +913,11 @@ class VideoDownload {
return downloadedTotalLength; return downloadedTotalLength;
} }
private suspend fun combineSegments(context: Context, segmentFiles: List<File>, targetFile: File) = withContext(Dispatchers.IO) { private suspend fun combineSegments(context: Context, segmentFiles: List<File>, targetFile: File) =
suspendCancellableCoroutine { continuation -> withContext(Dispatchers.IO) {
val fileList = File(context.cacheDir, "fileList-${UUID.randomUUID()}.txt") suspendCancellableCoroutine { continuation ->
fileList.writeText(segmentFiles.joinToString("\n") { "file '${it.absolutePath}'" }) val cmd =
"-i \"concat:${segmentFiles.joinToString("|")}\" -c copy \"${targetFile.absolutePath}\""
val cmd = "-f concat -safe 0 -i \"${fileList.absolutePath}\" -c copy \"${targetFile.absolutePath}\""
val statisticsCallback = StatisticsCallback { _ -> val statisticsCallback = StatisticsCallback { _ ->
//TODO: Show progress? //TODO: Show progress?
@ -623,7 +927,6 @@ class VideoDownload {
val session = FFmpegKit.executeAsync(cmd, val session = FFmpegKit.executeAsync(cmd,
{ session -> { session ->
if (ReturnCode.isSuccess(session.returnCode)) { if (ReturnCode.isSuccess(session.returnCode)) {
fileList.delete()
continuation.resumeWith(Result.success(Unit)) continuation.resumeWith(Result.success(Unit))
} else { } else {
val errorMessage = if (ReturnCode.isCancel(session.returnCode)) { val errorMessage = if (ReturnCode.isCancel(session.returnCode)) {
@ -631,7 +934,6 @@ class VideoDownload {
} else { } else {
"Command failed with state '${session.state}' and return code ${session.returnCode}, stack trace ${session.failStackTrace}" "Command failed with state '${session.state}' and return code ${session.returnCode}, stack trace ${session.failStackTrace}"
} }
fileList.delete()
continuation.resumeWithException(RuntimeException(errorMessage)) continuation.resumeWithException(RuntimeException(errorMessage))
} }
}, },
@ -751,7 +1053,7 @@ class VideoDownload {
else { else {
Logger.i(TAG, "Download $name Sequential"); Logger.i(TAG, "Download $name Sequential");
try { try {
sourceLength = downloadSource_Sequential(client, fileStream, videoUrl, onProgress); sourceLength = downloadSource_Sequential(client, fileStream, videoUrl, null, onProgress);
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.w(TAG, "Failed to download sequentially (url = $videoUrl)") Logger.w(TAG, "Failed to download sequentially (url = $videoUrl)")
throw e throw e
@ -778,7 +1080,31 @@ class VideoDownload {
} }
return sourceLength!!; 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; val progressRate: Int = 4096 * 5;
var lastProgressCount: Int = 0; var lastProgressCount: Int = 0;
val speedRate: Int = 4096 * 5; val speedRate: Int = 4096 * 5;
@ -798,6 +1124,8 @@ class VideoDownload {
val sourceLength = result.body.contentLength(); val sourceLength = result.body.contentLength();
val sourceStream = result.body.byteStream(); val sourceStream = result.body.byteStream();
val segmentBuffer = ByteArrayOutputStream()
var totalRead: Long = 0; var totalRead: Long = 0;
try { try {
var read: Int; var read: Int;
@ -808,7 +1136,7 @@ class VideoDownload {
if (read < 0) if (read < 0)
break; break;
fileStream.write(buffer, 0, read); segmentBuffer.write(buffer, 0, read);
totalRead += read; totalRead += read;
@ -834,6 +1162,13 @@ class VideoDownload {
result.body.close() 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); onProgress(sourceLength, totalRead, 0);
return sourceLength; return sourceLength;
} }
@ -1025,7 +1360,7 @@ class VideoDownload {
val expectedFile = File(videoFilePath!!); val expectedFile = File(videoFilePath!!);
if(!expectedFile.exists()) if(!expectedFile.exists())
throw IllegalStateException("Video file missing after download"); 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) if (expectedFile.length() != videoFileSize)
throw IllegalStateException("Expected size [${videoFileSize}], but found ${expectedFile.length()}"); throw IllegalStateException("Expected size [${videoFileSize}], but found ${expectedFile.length()}");
} }
@ -1036,7 +1371,7 @@ class VideoDownload {
val expectedFile = File(audioFilePath!!); val expectedFile = File(audioFilePath!!);
if(!expectedFile.exists()) if(!expectedFile.exists())
throw IllegalStateException("Audio file missing after download"); 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) if (expectedFile.length() != audioFileSize)
throw IllegalStateException("Expected size [${audioFileSize}], but found ${expectedFile.length()}"); 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); val REGEX_DASH_CUE = Regex("<S .*?t=\"([0-9]*?)\".*?d=\"([0-9]*?)\".*?\\/>", RegexOption.DOT_MATCHES_ALL);
fun videoContainerToExtension(container: String): String? { fun videoContainerToExtension(container: String): String? {
if (container.contains("video/mp4") || container == "application/vnd.apple.mpegurl") if (container.contains("video/mp4"))
return "mp4"; return "mp4";
else if (container.contains("application/x-mpegURL")) else if (container.contains("application/x-mpegURL"))
return "m3u8"; return "m3u8";
@ -1133,21 +1468,26 @@ class VideoDownload {
return "webm"; return "webm";
else if (container.contains("video/x-matroska")) else if (container.contains("video/x-matroska"))
return "mkv"; return "mkv";
else if (container.contains("video/mp2t"))
return "m2ts"
else if (container == "application/vnd.apple.mpegurl")
return "mp4"
else else
return "video"; return "video";
} }
fun audioContainerToExtension(container: String): String { fun audioContainerToExtension(container: String): String {
if (container.contains("audio/mp4")) if (container.contains("audio/mp4"))
return "mp4a"; return "m4a";
else if (container.contains("audio/mpeg")) else if (container.contains("audio/mpeg"))
return "mpga"; return "mp3";
// return "mpga";
else if (container.contains("audio/mp3")) else if (container.contains("audio/mp3"))
return "mp3"; return "mp3";
else if (container == "application/vnd.apple.mpegurl")
return "m4a"
else if (container.contains("audio/webm")) else if (container.contains("audio/webm"))
return "webma"; return "webma";
else if (container == "application/vnd.apple.mpegurl")
return "mp4";
else else
return "audio"; return "audio";
} }

View file

@ -69,7 +69,7 @@ class VideoExport {
outputFile = f; outputFile = f;
} else if (v != null) { } else if (v != null) {
val outputFileName = videoLocal.name.sanitizeFileName(true) + "." + VideoDownload.videoContainerToExtension(v.container); 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."); ?: throw Exception("Failed to create file in external directory.");
Logger.i(TAG, "Copying video."); Logger.i(TAG, "Copying video.");
@ -81,7 +81,7 @@ class VideoExport {
outputFile = f; outputFile = f;
} else if (a != null) { } else if (a != null) {
val outputFileName = videoLocal.name.sanitizeFileName(true) + "." + VideoDownload.audioContainerToExtension(a.container); 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."); ?: throw Exception("Failed to create file in external directory.");
Logger.i(TAG, "Copying audio."); 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.VideoUnMuxedSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource 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.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.IHLSManifestAudioSource
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
@ -46,7 +47,7 @@ class VideoHelper {
return false 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 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); 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 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.HLSVariantAudioUrlSource
import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantSubtitleUrlSource import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantSubtitleUrlSource
import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantVideoUrlSource 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.api.media.models.streams.sources.IHLSManifestSource
import com.futo.platformplayer.toYesNo import com.futo.platformplayer.toYesNo
import com.futo.platformplayer.yesNoToBoolean import com.futo.platformplayer.yesNoToBoolean
import java.io.ByteArrayInputStream
import java.net.URI import java.net.URI
import java.net.URLConnection
import java.time.ZonedDateTime import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
class HLS { class HLS {
companion object { 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 baseUrl = URI(sourceUrl).resolve("./").toString()
val variantPlaylists = mutableListOf<VariantPlaylistReference>() val variantPlaylists = mutableListOf<VariantPlaylistReference>()
@ -21,27 +34,38 @@ class HLS {
val sessionDataList = mutableListOf<SessionData>() val sessionDataList = mutableListOf<SessionData>()
var independentSegments = false var independentSegments = false
masterPlaylistContent.lines().forEachIndexed { index, line -> if (playlist is HlsMediaPlaylist) {
when { independentSegments = playlist.hasIndependentSegments
line.startsWith("#EXT-X-STREAM-INF") -> { if (isAudioSource == true) {
val nextLine = masterPlaylistContent.lines().getOrNull(index + 1) val firstSegmentUrlFile =
?: throw Exception("Expected URI following #EXT-X-STREAM-INF, found none") Uri.parse(playlist.segments[0].url).buildUpon().clearQuery().fragment(null)
val url = resolveUrl(baseUrl, nextLine) .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") -> { line == "#EXT-X-INDEPENDENT-SEGMENTS" -> {
mediaRenditions.add(parseMediaRendition(line, baseUrl)) independentSegments = true
} }
line == "#EXT-X-INDEPENDENT-SEGMENTS" -> { line.startsWith("#EXT-X-SESSION-DATA") -> {
independentSegments = true 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 playlistType = lines.find { it.startsWith("#EXT-X-PLAYLIST-TYPE:") }?.substringAfter(":")
val streamInfo = lines.find { it.startsWith("#EXT-X-STREAM-INF:") }?.let { parseStreamInfo(it) } val streamInfo = lines.find { it.startsWith("#EXT-X-STREAM-INF:") }?.let { parseStreamInfo(it) }
val keyInfo =
lines.find { it.startsWith("#EXT-X-KEY:") }?.substringAfter(":")?.split(",")
val key = keyInfo?.find { it.startsWith("URI=") }?.substringAfter("=")?.trim('"')
val iv =
keyInfo?.find { it.startsWith("IV=") }?.substringAfter("=")?.substringAfter("x")
val decryptionInfo: DecryptionInfo? = key?.let { k ->
iv?.let { i ->
DecryptionInfo(k, i)
}
}
val initSegment =
lines.find { it.startsWith("#EXT-X-MAP:URI=") }?.substringAfter("=")?.trim('"')
val segments = mutableListOf<Segment>() val segments = mutableListOf<Segment>()
if (initSegment != null) {
segments.add(MediaSegment(0.0, resolveUrl(sourceUrl, initSegment)))
}
var currentSegment: MediaSegment? = null var currentSegment: MediaSegment? = null
lines.forEach { line -> lines.forEach { line ->
when { when {
@ -86,7 +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> { 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 val masterPlaylist: MasterPlaylist
try { try {
masterPlaylist = parseMasterPlaylist(content, url) masterPlaylist = parseMasterPlaylist(content, url, isAudioSource)
return masterPlaylist.getAudioSources() return masterPlaylist.getAudioSources()
} catch (e: Throwable) { } catch (e: Throwable) {
if (content.lines().any { it.startsWith("#EXTINF:") }) { 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 val _quoteList = listOf("GROUP-ID", "NAME", "URI", "CODECS", "AUDIO", "VIDEO")
private fun shouldQuote(key: String, value: String?): Boolean { private fun shouldQuote(key: String, value: String?): Boolean {
if (value == null) if (value == null)
return false; return false
if (value.contains(',')) if (value.contains(','))
return true; return true
return _quoteList.contains(key) return _quoteList.contains(key)
} }
@ -270,7 +312,8 @@ class HLS {
val name: String?, val name: String?,
val isDefault: Boolean?, val isDefault: Boolean?,
val isAutoSelect: Boolean?, val isAutoSelect: Boolean?,
val isForced: Boolean? val isForced: Boolean?,
val container: String? = null
) { ) {
fun toM3U8Line(): String = buildString { fun toM3U8Line(): String = buildString {
append("#EXT-X-MEDIA:") append("#EXT-X-MEDIA:")
@ -340,7 +383,7 @@ class HLS {
val suffix = listOf(it.language, it.groupID).mapNotNull { x -> x?.ifEmpty { null } }.joinToString(", ") val suffix = listOf(it.language, it.groupID).mapNotNull { x -> x?.ifEmpty { null } }.joinToString(", ")
return@mapNotNull when (it.type) { 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 else -> null
} }
} }
@ -368,6 +411,11 @@ class HLS {
} }
} }
data class DecryptionInfo(
val keyUrl: String,
val iv: String
)
data class VariantPlaylist( data class VariantPlaylist(
val version: Int?, val version: Int?,
val targetDuration: Int?, val targetDuration: Int?,
@ -376,7 +424,8 @@ class HLS {
val programDateTime: ZonedDateTime?, val programDateTime: ZonedDateTime?,
val playlistType: String?, val playlistType: String?,
val streamInfo: StreamInfo?, val streamInfo: StreamInfo?,
val segments: List<Segment> val segments: List<Segment>,
val decryptionInfo: DecryptionInfo? = null
) { ) {
fun buildM3U8(): String = buildString { fun buildM3U8(): String = buildString {
append("#EXTM3U\n") append("#EXTM3U\n")