From b6ad3fd991c239812b7254271f4af67fb00cd8ee Mon Sep 17 00:00:00 2001 From: Koen Date: Mon, 27 Nov 2023 13:49:34 +0000 Subject: [PATCH] HLS download implementation --- .../futo/platformplayer/UISlideOverlays.kt | 159 ++++++++++++-- .../streams/sources/HLSVariantUrlSource.kt | 51 +++++ .../platformplayer/downloads/VideoDownload.kt | 202 ++++++++++++++++-- .../platformplayer/helpers/VideoHelper.kt | 25 ++- .../com/futo/platformplayer/parsers/HLS.kt | 99 +++++++++ .../services/DownloadService.kt | 6 +- .../overlays/slideup/SlideUpMenuOverlay.kt | 3 +- 7 files changed, 503 insertions(+), 42 deletions(-) create mode 100644 app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/HLSVariantUrlSource.kt diff --git a/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt b/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt index e06c525f..22144ad4 100644 --- a/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt +++ b/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt @@ -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(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() + val audioButtons = arrayListOf() + //TODO: Implement subtitles + //val subtitleButtons = arrayListOf() + + 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() + 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(); 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, { diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/HLSVariantUrlSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/HLSVariantUrlSource.kt new file mode 100644 index 00000000..36df5fb2 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/HLSVariantUrlSource.kt @@ -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) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt b/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt index 7f082407..048e36d3 100644 --- a/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt +++ b/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt @@ -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() + 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() + 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() + 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, 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"; } diff --git a/app/src/main/java/com/futo/platformplayer/helpers/VideoHelper.kt b/app/src/main/java/com/futo/platformplayer/helpers/VideoHelper.kt index a2aa67ef..e40f83cb 100644 --- a/app/src/main/java/com/futo/platformplayer/helpers/VideoHelper.kt +++ b/app/src/main/java/com/futo/platformplayer/helpers/VideoHelper.kt @@ -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) : IVideoSource? = selectBestVideoSource(desc.videoSources.toList(), desiredPixelCount, prefContainers); fun selectBestVideoSource(sources: Iterable, desiredPixelCount : Int, prefContainers : Array) : IVideoSource? { diff --git a/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt b/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt index e07b8a17..57f42576 100644 --- a/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt +++ b/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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) { diff --git a/app/src/main/java/com/futo/platformplayer/services/DownloadService.kt b/app/src/main/java/com/futo/platformplayer/services/DownloadService.kt index a58a9b29..cf6e0ba2 100644 --- a/app/src/main/java/com/futo/platformplayer/services/DownloadService.kt +++ b/app/src/main/java/com/futo/platformplayer/services/DownloadService.kt @@ -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(); diff --git a/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuOverlay.kt b/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuOverlay.kt index cc8e30f1..2c34dc5e 100644 --- a/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuOverlay.kt +++ b/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuOverlay.kt @@ -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?){