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.media.models.ResultCapabilities
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.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.SubtitleRawSource
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.IPlatformVideoDetails
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.helpers.VideoHelper
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.Playlist
import com.futo.platformplayer.models.Subscription
import com.futo.platformplayer.parsers.HLS
import com.futo.platformplayer.states.*
import com.futo.platformplayer.views.Loader
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.overlays.slideup.*
import com.futo.platformplayer.views.video.FutoVideoPlayerBase
import com.google.android.exoplayer2.source.hls.playlist.HlsPlaylist
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.internal.notifyAll
import java.lang.IllegalStateException
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? {
val items = arrayListOf<View>();
var menu: SlideUpMenuOverlay? = null;
@ -166,30 +270,49 @@ class UISlideOverlays {
videoSources
.filter { it.isDownloadable() }
.map {
SlideUpMenuItem(container.context, R.drawable.ic_movie, it.name, "${it.width}x${it.height}", it, {
selectedVideo = it as IVideoUrlSource;
menu?.selectOption(videoSources, it);
if(selectedAudio != null || !requiresAudio)
menu?.setOk(container.context.getString(R.string.download));
}, false)
if (it is IVideoUrlSource) {
SlideUpMenuItem(container.context, R.drawable.ic_movie, it.name, "${it.width}x${it.height}", it, {
selectedVideo = it
menu?.selectOption(videoSources, it);
if(selectedAudio != null || !requiresAudio)
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()
));
if(Settings.instance.downloads.getDefaultVideoQualityPixels() > 0 && videoSources.size > 0)
selectedVideo = VideoHelper.selectBestVideoSource(videoSources.filter { it.isDownloadable() }.asIterable(),
if(Settings.instance.downloads.getDefaultVideoQualityPixels() > 0 && videoSources.size > 0) {
//TODO: Add HLS support here
selectedVideo = VideoHelper.selectBestVideoSource(
videoSources.filter { it is IVideoUrlSource && it.isDownloadable() }.asIterable(),
Settings.instance.downloads.getDefaultVideoQualityPixels(),
FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS) as IVideoUrlSource;
FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS
) as IVideoUrlSource;
}
audioSources?.let { audioSources ->
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.audio), audioSources, audioSources
.filter { VideoHelper.isDownloadable(it) }
.map {
SlideUpMenuItem(container.context, R.drawable.ic_music, it.name, "${it.bitrate}", it, {
selectedAudio = it as IAudioUrlSource;
menu?.selectOption(audioSources, it);
menu?.setOk(container.context.getString(R.string.download));
}, false);
if (it is IAudioUrlSource) {
SlideUpMenuItem(container.context, R.drawable.ic_music, it.name, "${it.bitrate}", it, {
selectedAudio = it
menu?.selectOption(audioSources, it);
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 preferredAudioSource = VideoHelper.selectBestAudioSource(asources.asIterable(),
@ -198,15 +321,15 @@ class UISlideOverlays {
if(Settings.instance.downloads.isHighBitrateDefault()) 99999999 else 1);
menu?.selectOption(asources, preferredAudioSource);
selectedAudio = VideoHelper.selectBestAudioSource(audioSources.filter { it.isDownloadable() }.asIterable(),
//TODO: Add HLS support here
selectedAudio = VideoHelper.selectBestAudioSource(audioSources.filter { it is IAudioUrlSource && it.isDownloadable() }.asIterable(),
FutoVideoPlayerBase.PREFERED_AUDIO_CONTAINERS,
Settings.instance.playback.getPrimaryLanguage(container.context),
if(Settings.instance.downloads.isHighBitrateDefault()) 9999999 else 1) as IAudioUrlSource?;
}
//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
.map {
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
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.logging.Logger
import com.futo.platformplayer.states.StateDownloads
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.sources.*
import com.futo.platformplayer.api.media.models.streams.sources.other.IStreamMetaDataSource
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.VideoHelper
import com.futo.platformplayer.isDownloadable
import com.futo.platformplayer.parsers.HLS
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
import com.futo.platformplayer.serializers.OffsetDateTimeSerializer
import com.futo.platformplayer.toHumanBitrate
import com.futo.platformplayer.toHumanBytesSpeed
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.time.OffsetDateTime
import java.util.UUID
import java.util.concurrent.Executors
import java.util.concurrent.ForkJoinPool
import java.util.concurrent.ForkJoinTask
import java.util.concurrent.ThreadLocalRandom
import kotlin.coroutines.resumeWithException
@kotlinx.serialization.Serializable
class VideoDownload {
@ -137,7 +149,7 @@ class VideoDownload {
return items.joinToString("");
}
suspend fun prepare() {
suspend fun prepare(client: ManagedHttpClient) {
Logger.i(TAG, "VideoDownload Prepare [${name}]");
if(video == null && videoDetails == null)
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());
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");
if(vsource != null) {
if (vsource is IVideoUrlSource)
videoSource = VideoUrlSource.fromUrlSource(vsource);
videoSource = VideoUrlSource.fromUrlSource(vsource)
else
throw DownloadException("Video source is not supported for downloading (yet)", false);
}
}
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
else throw DownloadException("Could not find a valid video or audio source for download")
if(asource == null)
audioSource = null;
else if(asource is IAudioUrlSource)
audioSource = AudioUrlSource.fromUrlSource(asource);
audioSource = AudioUrlSource.fromUrlSource(asource)
else
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");
}
}
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}]");
if(videoDetails == null || (videoSource == null && audioSource == null))
throw IllegalStateException("Missing information for download to complete");
@ -199,7 +253,7 @@ class VideoDownload {
videoFilePath = File(downloadDir, videoFileName!!).absolutePath;
}
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;
}
if(subtitleSource != null) {
@ -217,7 +271,8 @@ class VideoDownload {
if(videoSource != null) {
sourcesToDownload.add(async {
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) {
lastVideoLength = length;
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) {
sourcesToDownload.add(async {
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) {
lastAudioLength = length;
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) {
@ -279,7 +345,105 @@ class VideoDownload {
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())
targetFile.delete();
@ -472,8 +636,10 @@ class VideoDownload {
val expectedFile = File(videoFilePath!!);
if(!expectedFile.exists())
throw IllegalStateException("Video file missing after download");
if(expectedFile.length() != videoFileSize)
throw IllegalStateException("Expected size [${videoFileSize}], but found ${expectedFile.length()}");
if (videoSource?.container != "application/vnd.apple.mpegurl") {
if (expectedFile.length() != videoFileSize)
throw IllegalStateException("Expected size [${videoFileSize}], but found ${expectedFile.length()}");
}
}
if(audioSource != null) {
if(audioFilePath == null)
@ -481,8 +647,10 @@ class VideoDownload {
val expectedFile = File(audioFilePath!!);
if(!expectedFile.exists())
throw IllegalStateException("Audio file missing after download");
if(expectedFile.length() != audioFileSize)
throw IllegalStateException("Expected size [${audioFileSize}], but found ${expectedFile.length()}");
if (audioSource?.container != "application/vnd.apple.mpegurl") {
if (expectedFile.length() != audioFileSize)
throw IllegalStateException("Expected size [${audioFileSize}], but found ${expectedFile.length()}");
}
}
if(subtitleSource != null) {
if(subtitleFilePath == null)
@ -560,7 +728,7 @@ class VideoDownload {
const val GROUP_PLAYLIST = "Playlist";
fun videoContainerToExtension(container: String): String? {
if (container.contains("video/mp4"))
if (container.contains("video/mp4") || container == "application/vnd.apple.mpegurl")
return "mp4";
else if (container.contains("application/x-mpegURL"))
return "m3u8";
@ -585,6 +753,8 @@ class VideoDownload {
return "mp3";
else if (container.contains("audio/webm"))
return "webma";
else if (container == "application/vnd.apple.mpegurl")
return "mp4";
else
return "audio";
}

View file

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

View file

@ -1,8 +1,22 @@
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.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.views.overlays.slideup.SlideUpMenuGroup
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem
import com.futo.platformplayer.yesNoToBoolean
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.net.URI
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
@ -85,6 +99,48 @@ class HLS {
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 {
val baseUri = URI(baseUrl)
val urlUri = URI(url)
@ -269,6 +325,49 @@ class HLS {
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) {

View file

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

View file

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