Merge branch 'hls-download' into 'master'

HLS download implementation

See merge request videostreaming/grayjay!6
This commit is contained in:
Koen 2023-11-27 13:49:34 +00:00
commit 3d2840fe15
7 changed files with 503 additions and 42 deletions

View File

@ -12,18 +12,25 @@ import android.widget.TextView
import com.futo.platformplayer.api.http.ManagedHttpClient 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.streams.VideoUnMuxedSourceDescriptor import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantAudioUrlSource
import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantSubtitleUrlSource
import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantVideoUrlSource
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.IHLSManifestAudioSource
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.models.streams.sources.IVideoUrlSource
import com.futo.platformplayer.api.media.models.streams.sources.SubtitleRawSource import com.futo.platformplayer.api.media.models.streams.sources.SubtitleRawSource
import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails 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.casting.StateCasting
import com.futo.platformplayer.downloads.VideoLocal import com.futo.platformplayer.downloads.VideoLocal
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.models.Playlist import com.futo.platformplayer.models.Playlist
import com.futo.platformplayer.models.Subscription import com.futo.platformplayer.models.Subscription
import com.futo.platformplayer.parsers.HLS
import com.futo.platformplayer.states.* import com.futo.platformplayer.states.*
import com.futo.platformplayer.views.Loader import com.futo.platformplayer.views.Loader
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuGroup import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuGroup
@ -33,10 +40,12 @@ import com.futo.platformplayer.views.pills.RoundButton
import com.futo.platformplayer.views.pills.RoundButtonGroup import com.futo.platformplayer.views.pills.RoundButtonGroup
import com.futo.platformplayer.views.overlays.slideup.* import com.futo.platformplayer.views.overlays.slideup.*
import com.futo.platformplayer.views.video.FutoVideoPlayerBase import com.futo.platformplayer.views.video.FutoVideoPlayerBase
import com.google.android.exoplayer2.source.hls.playlist.HlsPlaylist
import kotlinx.coroutines.CoroutineScope 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 okhttp3.internal.notifyAll
import java.lang.IllegalStateException import java.lang.IllegalStateException
class UISlideOverlays { class UISlideOverlays {
@ -127,6 +136,101 @@ class UISlideOverlays {
} }
} }
fun showHlsPicker(video: IPlatformVideoDetails, source: Any, sourceUrl: String, container: ViewGroup): SlideUpMenuOverlay {
val items = arrayListOf<View>(Loader(container.context))
val slideUpMenuOverlay = SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.download_video), null, true, items)
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
val masterPlaylistResponse = ManagedHttpClient().get(sourceUrl)
check(masterPlaylistResponse.isOk) { "Failed to get master playlist: ${masterPlaylistResponse.code}" }
val masterPlaylistContent = masterPlaylistResponse.body?.string()
?: throw Exception("Master playlist content is empty")
val videoButtons = arrayListOf<SlideUpMenuItem>()
val audioButtons = arrayListOf<SlideUpMenuItem>()
//TODO: Implement subtitles
//val subtitleButtons = arrayListOf<SlideUpMenuItem>()
var selectedVideoVariant: HLSVariantVideoUrlSource? = null
var selectedAudioVariant: HLSVariantAudioUrlSource? = null
//TODO: Implement subtitles
//var selectedSubtitleVariant: HLSVariantSubtitleUrlSource? = null
val masterPlaylist: HLS.MasterPlaylist
try {
masterPlaylist = HLS.parseMasterPlaylist(masterPlaylistContent, sourceUrl)
masterPlaylist.getAudioSources().forEach { it ->
audioButtons.add(SlideUpMenuItem(container.context, R.drawable.ic_music, it.name, listOf(it.language, it.codec).mapNotNull { x -> x.ifEmpty { null } }.joinToString(", "), it, {
selectedAudioVariant = it
slideUpMenuOverlay.selectOption(audioButtons, it)
slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
}, 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))
}*/
masterPlaylist.getVideoSources().forEach {
videoButtons.add(SlideUpMenuItem(container.context, R.drawable.ic_movie, it.name, "${it.width}x${it.height}", it, {
selectedVideoVariant = it
slideUpMenuOverlay.selectOption(videoButtons, it)
slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
}, 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)
}
} catch (e: Throwable) {
if (masterPlaylistContent.lines().any { it.startsWith("#EXTINF:") }) {
withContext(Dispatchers.Main) {
if (source is IHLSManifestSource) {
StateDownloads.instance.download(video, HLSVariantVideoUrlSource("variant", 0, 0, "application/vnd.apple.mpegurl", "", null, 0, false, sourceUrl), null, null)
UIDialogs.toast(container.context, "Variant video HLS playlist download started")
slideUpMenuOverlay.hide()
} else if (source is IHLSManifestAudioSource) {
StateDownloads.instance.download(video, null, HLSVariantAudioUrlSource("variant", 0, "application/vnd.apple.mpegurl", "", "", null, false, sourceUrl), null)
UIDialogs.toast(container.context, "Variant audio HLS playlist download started")
slideUpMenuOverlay.hide()
} else {
throw NotImplementedError()
}
}
} else {
throw e
}
}
}
return slideUpMenuOverlay.apply { show() }
}
fun showDownloadVideoOverlay(video: IPlatformVideoDetails, container: ViewGroup, contentResolver: ContentResolver? = null): SlideUpMenuOverlay? { fun showDownloadVideoOverlay(video: IPlatformVideoDetails, container: ViewGroup, contentResolver: ContentResolver? = null): SlideUpMenuOverlay? {
val items = arrayListOf<View>(); val items = arrayListOf<View>();
var menu: SlideUpMenuOverlay? = null; var menu: SlideUpMenuOverlay? = null;
@ -166,30 +270,49 @@ class UISlideOverlays {
videoSources videoSources
.filter { it.isDownloadable() } .filter { it.isDownloadable() }
.map { .map {
SlideUpMenuItem(container.context, R.drawable.ic_movie, it.name, "${it.width}x${it.height}", it, { if (it is IVideoUrlSource) {
selectedVideo = it as IVideoUrlSource; SlideUpMenuItem(container.context, R.drawable.ic_movie, it.name, "${it.width}x${it.height}", it, {
menu?.selectOption(videoSources, it); selectedVideo = it
if(selectedAudio != null || !requiresAudio) menu?.selectOption(videoSources, it);
menu?.setOk(container.context.getString(R.string.download)); if(selectedAudio != null || !requiresAudio)
}, false) menu?.setOk(container.context.getString(R.string.download));
}, false)
} else if (it is IHLSManifestSource) {
SlideUpMenuItem(container.context, R.drawable.ic_movie, it.name, "HLS", it, {
showHlsPicker(video, it, it.url, container)
}, false)
} else {
throw Exception("Unhandled source type")
}
}).flatten().toList() }).flatten().toList()
)); ));
if(Settings.instance.downloads.getDefaultVideoQualityPixels() > 0 && videoSources.size > 0) if(Settings.instance.downloads.getDefaultVideoQualityPixels() > 0 && videoSources.size > 0) {
selectedVideo = VideoHelper.selectBestVideoSource(videoSources.filter { it.isDownloadable() }.asIterable(), //TODO: Add HLS support here
selectedVideo = VideoHelper.selectBestVideoSource(
videoSources.filter { it is IVideoUrlSource && it.isDownloadable() }.asIterable(),
Settings.instance.downloads.getDefaultVideoQualityPixels(), Settings.instance.downloads.getDefaultVideoQualityPixels(),
FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS) as IVideoUrlSource; FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS
) as IVideoUrlSource;
}
audioSources?.let { audioSources -> audioSources?.let { audioSources ->
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.audio), audioSources, audioSources items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.audio), audioSources, audioSources
.filter { VideoHelper.isDownloadable(it) } .filter { VideoHelper.isDownloadable(it) }
.map { .map {
SlideUpMenuItem(container.context, R.drawable.ic_music, it.name, "${it.bitrate}", it, { if (it is IAudioUrlSource) {
selectedAudio = it as IAudioUrlSource; SlideUpMenuItem(container.context, R.drawable.ic_music, it.name, "${it.bitrate}", it, {
menu?.selectOption(audioSources, it); selectedAudio = it
menu?.setOk(container.context.getString(R.string.download)); menu?.selectOption(audioSources, it);
}, false); menu?.setOk(container.context.getString(R.string.download));
}, false);
} else if (it is IHLSManifestAudioSource) {
SlideUpMenuItem(container.context, R.drawable.ic_movie, it.name, "HLS Audio", it, {
showHlsPicker(video, it, it.url, container)
}, false)
} else {
throw Exception("Unhandled source type")
}
})); }));
val asources = audioSources; val asources = audioSources;
val preferredAudioSource = VideoHelper.selectBestAudioSource(asources.asIterable(), val preferredAudioSource = VideoHelper.selectBestAudioSource(asources.asIterable(),
@ -198,15 +321,15 @@ class UISlideOverlays {
if(Settings.instance.downloads.isHighBitrateDefault()) 99999999 else 1); if(Settings.instance.downloads.isHighBitrateDefault()) 99999999 else 1);
menu?.selectOption(asources, preferredAudioSource); menu?.selectOption(asources, preferredAudioSource);
//TODO: Add HLS support here
selectedAudio = VideoHelper.selectBestAudioSource(audioSources.filter { it.isDownloadable() }.asIterable(), selectedAudio = VideoHelper.selectBestAudioSource(audioSources.filter { it is IAudioUrlSource && it.isDownloadable() }.asIterable(),
FutoVideoPlayerBase.PREFERED_AUDIO_CONTAINERS, FutoVideoPlayerBase.PREFERED_AUDIO_CONTAINERS,
Settings.instance.playback.getPrimaryLanguage(container.context), Settings.instance.playback.getPrimaryLanguage(container.context),
if(Settings.instance.downloads.isHighBitrateDefault()) 9999999 else 1) as IAudioUrlSource?; if(Settings.instance.downloads.isHighBitrateDefault()) 9999999 else 1) as IAudioUrlSource?;
} }
//ContentResolver is required for subtitles.. //ContentResolver is required for subtitles..
if(contentResolver != null) { if(contentResolver != null && subtitleSources.isNotEmpty()) {
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.subtitles), subtitleSources, subtitleSources items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.subtitles), subtitleSources, subtitleSources
.map { .map {
SlideUpMenuItem(container.context, R.drawable.ic_edit, it.name, "", it, { SlideUpMenuItem(container.context, R.drawable.ic_edit, it.name, "", it, {

View File

@ -0,0 +1,51 @@
package com.futo.platformplayer.api.media.models.streams.sources
import android.net.Uri
import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
class HLSVariantVideoUrlSource(
override val name: String,
override val width: Int,
override val height: Int,
override val container: String,
override val codec: String,
override val bitrate: Int?,
override val duration: Long,
override val priority: Boolean,
val url: String
) : IVideoUrlSource {
override fun getVideoUrl(): String {
return url
}
}
class HLSVariantAudioUrlSource(
override val name: String,
override val bitrate: Int,
override val container: String,
override val codec: String,
override val language: String,
override val duration: Long?,
override val priority: Boolean,
val url: String
) : IAudioUrlSource {
override fun getAudioUrl(): String {
return url
}
}
class HLSVariantSubtitleUrlSource(
override val name: String,
override val url: String,
override val format: String,
) : ISubtitleSource {
override val hasFetch: Boolean = false
override fun getSubtitles(): String? {
return null
}
override suspend fun getSubtitlesURI(): Uri? {
return Uri.parse(url)
}
}

View File

@ -1,11 +1,17 @@
package com.futo.platformplayer.downloads package com.futo.platformplayer.downloads
import android.content.Context
import android.util.Log
import com.arthenica.ffmpegkit.FFmpegKit
import com.arthenica.ffmpegkit.ReturnCode
import com.arthenica.ffmpegkit.StatisticsCallback
import com.futo.platformplayer.Settings import com.futo.platformplayer.Settings
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
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.api.http.ManagedHttpClient 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.sources.* import com.futo.platformplayer.api.media.models.streams.sources.*
import com.futo.platformplayer.api.media.models.streams.sources.other.IStreamMetaDataSource import com.futo.platformplayer.api.media.models.streams.sources.other.IStreamMetaDataSource
import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.api.media.models.video.IPlatformVideo
@ -18,22 +24,28 @@ import com.futo.platformplayer.hasAnySource
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.isDownloadable import com.futo.platformplayer.isDownloadable
import com.futo.platformplayer.parsers.HLS
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
import com.futo.platformplayer.serializers.OffsetDateTimeSerializer
import com.futo.platformplayer.toHumanBitrate import com.futo.platformplayer.toHumanBitrate
import com.futo.platformplayer.toHumanBytesSpeed import com.futo.platformplayer.toHumanBytesSpeed
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Deferred import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import java.io.IOException import java.io.IOException
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.util.UUID
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 kotlin.coroutines.resumeWithException
@kotlinx.serialization.Serializable @kotlinx.serialization.Serializable
class VideoDownload { class VideoDownload {
@ -137,7 +149,7 @@ class VideoDownload {
return items.joinToString(""); return items.joinToString("");
} }
suspend fun prepare() { suspend fun prepare(client: ManagedHttpClient) {
Logger.i(TAG, "VideoDownload Prepare [${name}]"); Logger.i(TAG, "VideoDownload Prepare [${name}]");
if(video == null && videoDetails == null) if(video == null && videoDetails == null)
throw IllegalStateException("Missing information for download to complete"); throw IllegalStateException("Missing information for download to complete");
@ -157,24 +169,65 @@ class VideoDownload {
videoDetails = SerializedPlatformVideoDetails.fromVideo(original, if (subtitleSource != null) listOf(subtitleSource!!) else listOf()); videoDetails = SerializedPlatformVideoDetails.fromVideo(original, if (subtitleSource != null) listOf(subtitleSource!!) else listOf());
if(videoSource == null && targetPixelCount != null) { if(videoSource == null && targetPixelCount != null) {
val vsource = VideoHelper.selectBestVideoSource(videoDetails!!.video, targetPixelCount!!.toInt(), arrayOf()) val videoSources = arrayListOf<IVideoSource>()
for (source in original.video.videoSources) {
if (source is IHLSManifestSource) {
try {
val playlistResponse = client.get(source.url)
if (playlistResponse.isOk) {
val playlistContent = playlistResponse.body?.string()
if (playlistContent != null) {
videoSources.addAll(HLS.parseAndGetVideoSources(source, playlistContent, source.url))
}
}
} catch (e: Throwable) {
Log.i(TAG, "Failed to get HLS video sources", e)
}
} else {
videoSources.add(source)
}
}
val vsource = VideoHelper.selectBestVideoSource(videoSources, targetPixelCount!!.toInt(), arrayOf())
// ?: throw IllegalStateException("Could not find a valid video source for video"); // ?: throw IllegalStateException("Could not find a valid video source for video");
if(vsource != null) { if(vsource != null) {
if (vsource is IVideoUrlSource) if (vsource is IVideoUrlSource)
videoSource = VideoUrlSource.fromUrlSource(vsource); videoSource = VideoUrlSource.fromUrlSource(vsource)
else else
throw DownloadException("Video source is not supported for downloading (yet)", false); throw DownloadException("Video source is not supported for downloading (yet)", false);
} }
} }
if(audioSource == null && targetBitrate != null) { if(audioSource == null && targetBitrate != null) {
val asource = VideoHelper.selectBestAudioSource(videoDetails!!.video, arrayOf(), null, targetPixelCount) val audioSources = arrayListOf<IAudioSource>()
val video = original.video
if (video is VideoUnMuxedSourceDescriptor) {
for (source in video.audioSources) {
if (source is IHLSManifestSource) {
try {
val playlistResponse = client.get(source.url)
if (playlistResponse.isOk) {
val playlistContent = playlistResponse.body?.string()
if (playlistContent != null) {
audioSources.addAll(HLS.parseAndGetAudioSources(source, playlistContent, source.url))
}
}
} catch (e: Throwable) {
Log.i(TAG, "Failed to get HLS audio sources", e)
}
} else {
audioSources.add(source)
}
}
}
val asource = VideoHelper.selectBestAudioSource(audioSources, arrayOf(), null, targetBitrate)
?: if(videoSource != null ) null ?: if(videoSource != null ) null
else throw DownloadException("Could not find a valid video or audio source for download") else throw DownloadException("Could not find a valid video or audio source for download")
if(asource == null) if(asource == null)
audioSource = null; audioSource = null;
else if(asource is IAudioUrlSource) else if(asource is IAudioUrlSource)
audioSource = AudioUrlSource.fromUrlSource(asource); audioSource = AudioUrlSource.fromUrlSource(asource)
else else
throw DownloadException("Audio source is not supported for downloading (yet)", false); throw DownloadException("Audio source is not supported for downloading (yet)", false);
} }
@ -183,7 +236,8 @@ class VideoDownload {
throw DownloadException("No valid sources found for video/audio"); throw DownloadException("No valid sources found for video/audio");
} }
} }
suspend fun download(client: ManagedHttpClient, onProgress: ((Double) -> Unit)? = null) = coroutineScope {
suspend fun download(context: Context, client: ManagedHttpClient, onProgress: ((Double) -> Unit)? = null) = coroutineScope {
Logger.i(TAG, "VideoDownload Download [${name}]"); Logger.i(TAG, "VideoDownload Download [${name}]");
if(videoDetails == null || (videoSource == null && audioSource == null)) if(videoDetails == null || (videoSource == null && audioSource == null))
throw IllegalStateException("Missing information for download to complete"); throw IllegalStateException("Missing information for download to complete");
@ -199,7 +253,7 @@ class VideoDownload {
videoFilePath = File(downloadDir, videoFileName!!).absolutePath; videoFilePath = File(downloadDir, videoFileName!!).absolutePath;
} }
if(audioSource != null) { if(audioSource != null) {
audioFileName = "${videoDetails!!.id.value!!} [${audioSource!!.bitrate}].${audioContainerToExtension(audioSource!!.container)}".sanitizeFileName(); audioFileName = "${videoDetails!!.id.value!!} [${audioSource!!.language}-${audioSource!!.bitrate}].${audioContainerToExtension(audioSource!!.container)}".sanitizeFileName();
audioFilePath = File(downloadDir, audioFileName!!).absolutePath; audioFilePath = File(downloadDir, audioFileName!!).absolutePath;
} }
if(subtitleSource != null) { if(subtitleSource != null) {
@ -217,7 +271,8 @@ class VideoDownload {
if(videoSource != null) { if(videoSource != null) {
sourcesToDownload.add(async { sourcesToDownload.add(async {
Logger.i(TAG, "Started downloading video"); Logger.i(TAG, "Started downloading video");
videoFileSize = downloadSource("Video", client, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!)) { length, totalRead, speed ->
val progressCallback = { length: Long, totalRead: Long, speed: Long ->
synchronized(progressLock) { synchronized(progressLock) {
lastVideoLength = length; lastVideoLength = length;
lastVideoRead = totalRead; lastVideoRead = totalRead;
@ -235,12 +290,18 @@ class VideoDownload {
} }
} }
} }
videoFileSize = when (videoSource!!.container) {
"application/vnd.apple.mpegurl" -> downloadHlsSource(context, "Video", client, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
else -> downloadFileSource("Video", client, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
}
}); });
} }
if(audioSource != null) { if(audioSource != null) {
sourcesToDownload.add(async { sourcesToDownload.add(async {
Logger.i(TAG, "Started downloading audio"); Logger.i(TAG, "Started downloading audio");
audioFileSize = downloadSource("Audio", client, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!)) { length, totalRead, speed ->
val progressCallback = { length: Long, totalRead: Long, speed: Long ->
synchronized(progressLock) { synchronized(progressLock) {
lastAudioLength = length; lastAudioLength = length;
lastAudioRead = totalRead; lastAudioRead = totalRead;
@ -258,6 +319,11 @@ class VideoDownload {
} }
} }
} }
audioFileSize = when (audioSource!!.container) {
"application/vnd.apple.mpegurl" -> downloadHlsSource(context, "Audio", client, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
else -> downloadFileSource("Audio", client, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
}
}); });
} }
if (subtitleSource != null) { if (subtitleSource != null) {
@ -279,7 +345,105 @@ class VideoDownload {
throw ex; throw ex;
} }
} }
private fun downloadSource(name: String, client: ManagedHttpClient, videoUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
private suspend fun downloadHlsSource(context: Context, name: String, client: ManagedHttpClient, hlsUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
if(targetFile.exists())
targetFile.delete();
var downloadedTotalLength = 0L
val segmentFiles = arrayListOf<File>()
try {
val response = client.get(hlsUrl)
check(response.isOk) { "Failed to get variant playlist: ${response.code}" }
val vpContent = response.body?.string()
?: throw Exception("Variant playlist content is empty")
val variantPlaylist = HLS.parseVariantPlaylist(vpContent, hlsUrl)
variantPlaylist.segments.forEachIndexed { index, segment ->
if (segment !is HLS.MediaSegment) {
return@forEachIndexed
}
Logger.i(TAG, "Download '$name' segment $index Sequential");
val segmentFile = File(context.cacheDir, "segment-${UUID.randomUUID()}")
segmentFiles.add(segmentFile)
val segmentLength = downloadSource_Sequential(client, segmentFile.outputStream(), segment.uri) { segmentLength, totalRead, lastSpeed ->
val averageSegmentLength = if (index == 0) segmentLength else downloadedTotalLength / index
val expectedTotalLength = averageSegmentLength * (variantPlaylist.segments.size - 1) + segmentLength
onProgress(expectedTotalLength, downloadedTotalLength + totalRead, lastSpeed)
}
downloadedTotalLength += segmentLength
}
Logger.i(TAG, "Combining segments into $targetFile");
combineSegments(context, segmentFiles, targetFile)
Logger.i(TAG, "${name} downloadSource Finished");
}
catch(ioex: IOException) {
if(targetFile.exists() ?: false)
targetFile.delete();
if(ioex.message?.contains("ENOSPC") ?: false)
throw Exception("Not enough space on device", ioex);
else
throw ioex;
}
catch(ex: Throwable) {
if(targetFile.exists() ?: false)
targetFile.delete();
throw ex;
}
finally {
for (segmentFile in segmentFiles) {
segmentFile.delete()
}
}
return downloadedTotalLength;
}
private suspend fun combineSegments(context: Context, segmentFiles: List<File>, targetFile: File) = withContext(Dispatchers.IO) {
suspendCancellableCoroutine { continuation ->
val fileList = File(context.cacheDir, "fileList-${UUID.randomUUID()}.txt")
fileList.writeText(segmentFiles.joinToString("\n") { "file '${it.absolutePath}'" })
val cmd = "-f concat -safe 0 -i \"${fileList.absolutePath}\" -c copy \"${targetFile.absolutePath}\""
val statisticsCallback = StatisticsCallback { statistics ->
//TODO: Show progress?
}
val executorService = Executors.newSingleThreadExecutor()
val session = FFmpegKit.executeAsync(cmd,
{ session ->
if (ReturnCode.isSuccess(session.returnCode)) {
fileList.delete()
continuation.resumeWith(Result.success(Unit))
} else {
val errorMessage = if (ReturnCode.isCancel(session.returnCode)) {
"Command cancelled"
} else {
"Command failed with state '${session.state}' and return code ${session.returnCode}, stack trace ${session.failStackTrace}"
}
fileList.delete()
continuation.resumeWithException(RuntimeException(errorMessage))
}
},
{ Logger.v(TAG, it.message) },
statisticsCallback,
executorService
)
continuation.invokeOnCancellation {
session.cancel()
}
}
}
private fun downloadFileSource(name: String, client: ManagedHttpClient, videoUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
if(targetFile.exists()) if(targetFile.exists())
targetFile.delete(); targetFile.delete();
@ -472,8 +636,10 @@ 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(expectedFile.length() != videoFileSize) if (videoSource?.container != "application/vnd.apple.mpegurl") {
throw IllegalStateException("Expected size [${videoFileSize}], but found ${expectedFile.length()}"); if (expectedFile.length() != videoFileSize)
throw IllegalStateException("Expected size [${videoFileSize}], but found ${expectedFile.length()}");
}
} }
if(audioSource != null) { if(audioSource != null) {
if(audioFilePath == null) if(audioFilePath == null)
@ -481,8 +647,10 @@ 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(expectedFile.length() != audioFileSize) if (audioSource?.container != "application/vnd.apple.mpegurl") {
throw IllegalStateException("Expected size [${audioFileSize}], but found ${expectedFile.length()}"); if (expectedFile.length() != audioFileSize)
throw IllegalStateException("Expected size [${audioFileSize}], but found ${expectedFile.length()}");
}
} }
if(subtitleSource != null) { if(subtitleSource != null) {
if(subtitleFilePath == null) if(subtitleFilePath == null)
@ -560,7 +728,7 @@ class VideoDownload {
const val GROUP_PLAYLIST = "Playlist"; const val GROUP_PLAYLIST = "Playlist";
fun videoContainerToExtension(container: String): String? { fun videoContainerToExtension(container: String): String? {
if (container.contains("video/mp4")) if (container.contains("video/mp4") || container == "application/vnd.apple.mpegurl")
return "mp4"; return "mp4";
else if (container.contains("application/x-mpegURL")) else if (container.contains("application/x-mpegURL"))
return "m3u8"; return "m3u8";
@ -585,6 +753,8 @@ class VideoDownload {
return "mp3"; return "mp3";
else if (container.contains("audio/webm")) else if (container.contains("audio/webm"))
return "webma"; return "webma";
else if (container == "application/vnd.apple.mpegurl")
return "mp4";
else else
return "audio"; return "audio";
} }

View File

@ -3,8 +3,11 @@ package com.futo.platformplayer.helpers
import android.net.Uri import android.net.Uri
import com.futo.platformplayer.api.media.models.streams.IVideoSourceDescriptor 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.HLSManifestSource
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.IHLSManifestAudioSource
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
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.models.video.IPlatformVideoDetails import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
@ -20,11 +23,23 @@ import com.google.android.exoplayer2.upstream.ResolvingDataSource
class VideoHelper { class VideoHelper {
companion object { companion object {
fun isDownloadable(detail: IPlatformVideoDetails) = fun isDownloadable(detail: IPlatformVideoDetails): Boolean {
(detail.video.videoSources.any { isDownloadable(it) }) || if (detail.video.videoSources.any { isDownloadable(it) }) {
(if (detail is VideoUnMuxedSourceDescriptor) detail.audioSources.any { isDownloadable(it) } else false); return true
fun isDownloadable(source: IVideoSource) = source is IVideoUrlSource; }
fun isDownloadable(source: IAudioSource) = source is IAudioUrlSource;
val descriptor = detail.video
if (descriptor is VideoUnMuxedSourceDescriptor) {
if (descriptor.audioSources.any { isDownloadable(it) }) {
return true
}
}
return false
}
fun isDownloadable(source: IVideoSource) = source is IVideoUrlSource || source is IHLSManifestSource;
fun isDownloadable(source: IAudioSource) = source is IAudioUrlSource || source is IHLSManifestAudioSource;
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);
fun selectBestVideoSource(sources: Iterable<IVideoSource>, desiredPixelCount : Int, prefContainers : Array<String>) : IVideoSource? { fun selectBestVideoSource(sources: Iterable<IVideoSource>, desiredPixelCount : Int, prefContainers : Array<String>) : IVideoSource? {

View File

@ -1,8 +1,22 @@
package com.futo.platformplayer.parsers package com.futo.platformplayer.parsers
import android.view.View
import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantAudioUrlSource
import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantSubtitleUrlSource
import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantVideoUrlSource
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestAudioSource
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
import com.futo.platformplayer.states.StateDownloads
import com.futo.platformplayer.toYesNo import com.futo.platformplayer.toYesNo
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuGroup
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem
import com.futo.platformplayer.yesNoToBoolean import com.futo.platformplayer.yesNoToBoolean
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.net.URI import java.net.URI
import java.time.ZonedDateTime import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
@ -85,6 +99,48 @@ class HLS {
return VariantPlaylist(version, targetDuration, mediaSequence, discontinuitySequence, programDateTime, playlistType, streamInfo, segments) return VariantPlaylist(version, targetDuration, mediaSequence, discontinuitySequence, programDateTime, playlistType, streamInfo, segments)
} }
fun parseAndGetVideoSources(source: Any, content: String, url: String): List<HLSVariantVideoUrlSource> {
val masterPlaylist: MasterPlaylist
try {
masterPlaylist = parseMasterPlaylist(content, url)
return masterPlaylist.getVideoSources()
} catch (e: Throwable) {
if (content.lines().any { it.startsWith("#EXTINF:") }) {
return if (source is IHLSManifestSource) {
listOf(HLSVariantVideoUrlSource("variant", 0, 0, "application/vnd.apple.mpegurl", "", null, 0, false, url))
} else if (source is IHLSManifestAudioSource) {
listOf()
} else {
throw NotImplementedError()
}
} else {
throw e
}
}
}
fun parseAndGetAudioSources(source: Any, content: String, url: String): List<HLSVariantAudioUrlSource> {
val masterPlaylist: MasterPlaylist
try {
masterPlaylist = parseMasterPlaylist(content, url)
return masterPlaylist.getAudioSources()
} catch (e: Throwable) {
if (content.lines().any { it.startsWith("#EXTINF:") }) {
return if (source is IHLSManifestSource) {
listOf()
} else if (source is IHLSManifestAudioSource) {
listOf(HLSVariantAudioUrlSource("variant", 0, "application/vnd.apple.mpegurl", "", "", null, false, url))
} else {
throw NotImplementedError()
}
} else {
throw e
}
}
}
//TODO: getSubtitleSources
private fun resolveUrl(baseUrl: String, url: String): String { private fun resolveUrl(baseUrl: String, url: String): String {
val baseUri = URI(baseUrl) val baseUri = URI(baseUrl)
val urlUri = URI(url) val urlUri = URI(url)
@ -269,6 +325,49 @@ class HLS {
return builder.toString() return builder.toString()
} }
fun getVideoSources(): List<HLSVariantVideoUrlSource> {
return variantPlaylistsRefs.map {
var width: Int? = null
var height: Int? = null
val resolutionTokens = it.streamInfo.resolution?.split('x')
if (resolutionTokens?.isNotEmpty() == true) {
width = resolutionTokens[0].toIntOrNull()
height = resolutionTokens[1].toIntOrNull()
}
val suffix = listOf(it.streamInfo.video, it.streamInfo.codecs).mapNotNull { x -> x?.ifEmpty { null } }.joinToString(", ")
HLSVariantVideoUrlSource(suffix, width ?: 0, height ?: 0, "application/vnd.apple.mpegurl", it.streamInfo.codecs ?: "", it.streamInfo.bandwidth, 0, false, it.url)
}
}
fun getAudioSources(): List<HLSVariantAudioUrlSource> {
return mediaRenditions.mapNotNull {
if (it.uri == null) {
return@mapNotNull null
}
val suffix = listOf(it.language, it.groupID).mapNotNull { x -> x?.ifEmpty { null } }.joinToString(", ")
return@mapNotNull when (it.type) {
"AUDIO" -> HLSVariantAudioUrlSource(it.name?.ifEmpty { "Audio (${suffix})" } ?: "Audio (${suffix})", 0, "application/vnd.apple.mpegurl", "", it.language ?: "", null, false, it.uri)
else -> null
}
}
}
fun getSubtitleSources(): List<HLSVariantSubtitleUrlSource> {
return mediaRenditions.mapNotNull {
if (it.uri == null) {
return@mapNotNull null
}
val suffix = listOf(it.language, it.groupID).mapNotNull { x -> x?.ifEmpty { null } }.joinToString(", ")
return@mapNotNull when (it.type) {
"SUBTITLE" -> HLSVariantSubtitleUrlSource(it.name?.ifEmpty { "Subtitle (${suffix})" } ?: "Subtitle (${suffix})", it.uri, "application/vnd.apple.mpegurl")
else -> null
}
}
}
} }
data class VariantPlaylistReference(val url: String, val streamInfo: StreamInfo) { data class VariantPlaylistReference(val url: String, val streamInfo: StreamInfo) {

View File

@ -162,6 +162,8 @@ class DownloadService : Service() {
Logger.i(TAG, "doDownloading - Ending Downloads"); Logger.i(TAG, "doDownloading - Ending Downloads");
stopService(this); stopService(this);
} }
private suspend fun doDownload(download: VideoDownload) { private suspend fun doDownload(download: VideoDownload) {
if(!Settings.instance.downloads.shouldDownload()) if(!Settings.instance.downloads.shouldDownload())
throw IllegalStateException("Downloading disabled on current network"); throw IllegalStateException("Downloading disabled on current network");
@ -183,14 +185,14 @@ class DownloadService : Service() {
Logger.i(TAG, "Preparing [${download.name}] started"); Logger.i(TAG, "Preparing [${download.name}] started");
if(download.state == VideoDownload.State.PREPARING) if(download.state == VideoDownload.State.PREPARING)
download.prepare(); download.prepare(_client);
download.changeState(VideoDownload.State.DOWNLOADING); download.changeState(VideoDownload.State.DOWNLOADING);
notifyDownload(download); notifyDownload(download);
var lastNotifyTime: Long = 0L; var lastNotifyTime: Long = 0L;
Logger.i(TAG, "Downloading [${download.name}] started"); Logger.i(TAG, "Downloading [${download.name}] started");
//TODO: Use plugin client? //TODO: Use plugin client?
download.download(_client) { progress -> download.download(applicationContext, _client) { progress ->
download.progress = progress; download.progress = progress;
val currentTime = System.currentTimeMillis(); val currentTime = System.currentTimeMillis();

View File

@ -73,8 +73,9 @@ class SlideUpMenuOverlay : RelativeLayout {
item.setParentClickListener { hide() }; item.setParentClickListener { hide() };
else if(item is SlideUpMenuItem) else if(item is SlideUpMenuItem)
item.setParentClickListener { hide() }; item.setParentClickListener { hide() };
} }
_groupItems = items;
} }
private fun init(animated: Boolean, okText: String?){ private fun init(animated: Boolean, okText: String?){