mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2025-08-03 14:50:49 +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.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
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,14 +1,17 @@
|
||||||
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;
|
||||||
|
|
||||||
|
|
|
@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -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
|
||||||
}
|
}
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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) };
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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}]");
|
||||||
|
|
||||||
|
@ -287,16 +306,52 @@ class VideoDownload {
|
||||||
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,6 +375,8 @@ 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);
|
||||||
}
|
}
|
||||||
|
@ -331,11 +388,27 @@ class VideoDownload {
|
||||||
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)
|
||||||
}
|
}
|
||||||
else throw NotImplementedError("NotImplemented video download: " + actualVideoSource.javaClass.name);
|
|
||||||
});
|
is DashManifestSourceDelegate -> {
|
||||||
|
downloadDashSource(context, "Video", client, actualVideoSource.source, actualVideoSource.url, File(downloadDir, videoFileName!!), progressCallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
else throw NotImplementedError("NotImplemented audio download: " + actualAudioSource.javaClass.name);
|
|
||||||
});
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
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) =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
suspendCancellableCoroutine { continuation ->
|
suspendCancellableCoroutine { continuation ->
|
||||||
val fileList = File(context.cacheDir, "fileList-${UUID.randomUUID()}.txt")
|
val cmd =
|
||||||
fileList.writeText(segmentFiles.joinToString("\n") { "file '${it.absolutePath}'" })
|
"-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";
|
||||||
}
|
}
|
||||||
|
|
|
@ -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.");
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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,13 +34,23 @@ class HLS {
|
||||||
val sessionDataList = mutableListOf<SessionData>()
|
val sessionDataList = mutableListOf<SessionData>()
|
||||||
var independentSegments = false
|
var independentSegments = false
|
||||||
|
|
||||||
|
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 ->
|
masterPlaylistContent.lines().forEachIndexed { index, line ->
|
||||||
when {
|
when {
|
||||||
line.startsWith("#EXT-X-STREAM-INF") -> {
|
line.startsWith("#EXT-X-STREAM-INF") -> {
|
||||||
val nextLine = masterPlaylistContent.lines().getOrNull(index + 1)
|
val nextLine = masterPlaylistContent.lines().getOrNull(index + 1)
|
||||||
?: throw Exception("Expected URI following #EXT-X-STREAM-INF, found none")
|
?: throw Exception("Expected URI following #EXT-X-STREAM-INF, found none")
|
||||||
val url = resolveUrl(baseUrl, nextLine)
|
val url = resolveUrl(baseUrl, nextLine)
|
||||||
|
|
||||||
variantPlaylists.add(VariantPlaylistReference(url, parseStreamInfo(line)))
|
variantPlaylists.add(VariantPlaylistReference(url, parseStreamInfo(line)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -45,6 +68,7 @@ class HLS {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return MasterPlaylist(variantPlaylists, mediaRenditions, sessionDataList, independentSegments)
|
return MasterPlaylist(variantPlaylists, mediaRenditions, sessionDataList, independentSegments)
|
||||||
}
|
}
|
||||||
|
@ -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")
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue