mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2025-04-20 11:35:46 +00:00
improve support for HLS and DASH downloads
Changelog: added
This commit is contained in:
parent
a410e2962a
commit
0190bbffdd
12 changed files with 731 additions and 148 deletions
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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) };
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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";
|
||||
}
|
||||
|
|
|
@ -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.");
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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")
|
||||
|
|
Loading…
Add table
Reference in a new issue