diff --git a/app/src/main/java/com/futo/platformplayer/Extensions_Content.kt b/app/src/main/java/com/futo/platformplayer/Extensions_Content.kt new file mode 100644 index 00000000..39944ee5 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/Extensions_Content.kt @@ -0,0 +1,15 @@ +package com.futo.platformplayer + +import com.futo.platformplayer.api.media.models.streams.IVideoSourceDescriptor +import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor +import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource +import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource +import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails +import com.futo.platformplayer.helpers.VideoHelper + +fun IPlatformVideoDetails.isDownloadable(): Boolean = VideoHelper.isDownloadable(this); +fun IVideoSource.isDownloadable(): Boolean = VideoHelper.isDownloadable(this); +fun IAudioSource.isDownloadable(): Boolean = VideoHelper.isDownloadable(this); + + +fun IVideoSourceDescriptor.hasAnySource(): Boolean = this.videoSources.any() || (this is VideoUnMuxedSourceDescriptor && this.audioSources.any()); \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt b/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt index 77b537a4..56e13659 100644 --- a/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt +++ b/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt @@ -4,7 +4,6 @@ import android.content.ContentResolver import android.view.View import android.view.ViewGroup import com.futo.platformplayer.api.http.ManagedHttpClient -import com.futo.platformplayer.api.http.server.handlers.HttpConstantHandler import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlSource import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource @@ -29,7 +28,6 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import java.io.File class UISlideOverlays { companion object { @@ -68,6 +66,12 @@ class UISlideOverlays { return null; } + if(!VideoHelper.isDownloadable(video)) { + Logger.i(TAG, "Attempted to open downloads without valid sources for [${video.name}]: ${video.url}"); + UIDialogs.toast( "No downloadable sources (yet)"); + return null; + } + items.add(SlideUpMenuGroup(container.context, "Video", videoSources, listOf(listOf(SlideUpMenuItem(container.context, R.drawable.ic_movie, "None", "Audio Only", "none", { selectedVideo = null; @@ -76,7 +80,7 @@ class UISlideOverlays { menu?.setOk("Download"); }, false)) + videoSources - .filter { it is IVideoUrlSource } + .filter { it.isDownloadable() } .map { SlideUpMenuItem(container.context, R.drawable.ic_movie, it.name, "${it.width}x${it.height}", it, { selectedVideo = it as IVideoUrlSource; @@ -88,14 +92,14 @@ class UISlideOverlays { )); if(Settings.instance.downloads.getDefaultVideoQualityPixels() > 0 && videoSources.size > 0) - selectedVideo = VideoHelper.selectBestVideoSource(videoSources.filter { it is IVideoUrlSource }.asIterable(), + selectedVideo = VideoHelper.selectBestVideoSource(videoSources.filter { it.isDownloadable() }.asIterable(), Settings.instance.downloads.getDefaultVideoQualityPixels(), FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS) as IVideoUrlSource; audioSources?.let { audioSources -> items.add(SlideUpMenuGroup(container.context, "Audio", audioSources, audioSources - .filter { it is IAudioUrlSource } + .filter { VideoHelper.isDownloadable(it) } .map { SlideUpMenuItem(container.context, R.drawable.ic_music, it.name, "${it.bitrate}", it, { selectedAudio = it as IAudioUrlSource; @@ -111,7 +115,7 @@ class UISlideOverlays { menu?.selectOption(asources, preferredAudioSource); - selectedAudio = VideoHelper.selectBestAudioSource(audioSources.filter { it is IAudioUrlSource }.asIterable(), + selectedAudio = VideoHelper.selectBestAudioSource(audioSources.filter { it.isDownloadable() }.asIterable(), FutoVideoPlayerBase.PREFERED_AUDIO_CONTAINERS, Settings.instance.playback.getPrimaryLanguage(container.context), if(Settings.instance.downloads.isHighBitrateDefault()) 9999999 else 1) as IAudioUrlSource?; diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSHLSManifestAudioSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSHLSManifestAudioSource.kt index c006f2ec..8636cbb8 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSHLSManifestAudioSource.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSHLSManifestAudioSource.kt @@ -9,7 +9,7 @@ import com.futo.platformplayer.getOrNull import com.futo.platformplayer.getOrThrow import com.futo.platformplayer.orNull -class JSHLSManifestAudioSource : IAudioUrlSource, IHLSManifestAudioSource, JSSource { +class JSHLSManifestAudioSource : IHLSManifestAudioSource, JSSource { override val container : String get() = "application/vnd.apple.mpegurl"; override val codec: String = "HLS"; override val name : String; @@ -31,9 +31,6 @@ class JSHLSManifestAudioSource : IAudioUrlSource, IHLSManifestAudioSource, JSSou priority = obj.getOrNull(config, "priority", contextName) ?: false; } - override fun getAudioUrl(): String { - return url; - } companion object { fun fromV8HLSNullable(config: IV8PluginConfig, obj: V8Value?) : JSHLSManifestAudioSource? = obj.orNull { fromV8HLS(config, it as V8ValueObject) }; diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSHLSManifestSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSHLSManifestSource.kt index 27ba3352..8c785c17 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSHLSManifestSource.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSHLSManifestSource.kt @@ -7,7 +7,7 @@ import com.futo.platformplayer.engine.IV8PluginConfig import com.futo.platformplayer.getOrNull import com.futo.platformplayer.getOrThrow -class JSHLSManifestSource : IVideoUrlSource, IHLSManifestSource, JSSource { +class JSHLSManifestSource : IHLSManifestSource, JSSource { override val width : Int = 0; override val height : Int = 0; override val container : String get() = "application/vnd.apple.mpegurl"; @@ -28,8 +28,4 @@ class JSHLSManifestSource : IVideoUrlSource, IHLSManifestSource, JSSource { priority = obj.getOrNull(config, "priority", contextName) ?: false; } - - override fun getVideoUrl(): String { - return url; - } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/downloads/PlaylistDownloadDescriptor.kt b/app/src/main/java/com/futo/platformplayer/downloads/PlaylistDownloadDescriptor.kt index cbbd195a..a79b4861 100644 --- a/app/src/main/java/com/futo/platformplayer/downloads/PlaylistDownloadDescriptor.kt +++ b/app/src/main/java/com/futo/platformplayer/downloads/PlaylistDownloadDescriptor.kt @@ -1,8 +1,19 @@ package com.futo.platformplayer.downloads +import com.futo.platformplayer.api.media.models.video.IPlatformVideo + @kotlinx.serialization.Serializable data class PlaylistDownloadDescriptor( val id: String, val targetPxCount: Long?, val targetBitrate: Long? -); \ No newline at end of file +) { + var preventDownload: MutableList = arrayListOf(); + + fun getPreventDownloadList(): List = synchronized(preventDownload){ preventDownload }; + fun shouldDownload(video: IPlatformVideo): Boolean { + synchronized(preventDownload) { + return !preventDownload.contains(video.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 16e4dfbb..8543574b 100644 --- a/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt +++ b/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt @@ -13,8 +13,11 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideoDetails import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.exceptions.DownloadException +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.serializers.OffsetDateTimeNullableSerializer import com.futo.platformplayer.serializers.OffsetDateTimeSerializer import com.futo.platformplayer.toHumanBitrate @@ -147,27 +150,37 @@ class VideoDownload { if(original !is IPlatformVideoDetails) throw IllegalStateException("Original content is not media?"); + if(original.video.hasAnySource() && !original.isDownloadable()) { + Logger.i(TAG, "Attempted to download unsupported video [${original.name}]:${original.url}"); + throw DownloadException("Unsupported video for downloading", false); + } + 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()) - ?: throw IllegalStateException("Could not find a valid video source for video"); - if(vsource is IVideoUrlSource) - videoSource = VideoUrlSource.fromUrlSource(vsource); - else - throw IllegalStateException("Download video source is not a url source"); + // ?: throw IllegalStateException("Could not find a valid video source for video"); + if(vsource != null) { + if (vsource is IVideoUrlSource) + 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) ?: if(videoSource != null ) null - else throw IllegalStateException("Could not find a valid audio source for video"); + 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); else - throw IllegalStateException("Download audio source is not a url source"); + throw DownloadException("Audio source is not supported for downloading (yet)", false); } + + if(videoSource == null && audioSource == null) + throw DownloadException("No valid sources found for video/audio"); } } suspend fun download(client: ManagedHttpClient, onProgress: ((Double) -> Unit)? = null) = coroutineScope { diff --git a/app/src/main/java/com/futo/platformplayer/exceptions/DownloadException.kt b/app/src/main/java/com/futo/platformplayer/exceptions/DownloadException.kt new file mode 100644 index 00000000..9cfb1f59 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/exceptions/DownloadException.kt @@ -0,0 +1,12 @@ +package com.futo.platformplayer.exceptions + +class DownloadException : Throwable { + val isRetryable: Boolean; + + constructor(innerException: Throwable, retryable: Boolean = true): super(innerException) { + isRetryable = retryable; + } + constructor(msg: String, retryable: Boolean = true): super(msg) { + isRetryable = retryable; + } +} \ No newline at end of file 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 21799036..a2aa67ef 100644 --- a/app/src/main/java/com/futo/platformplayer/helpers/VideoHelper.kt +++ b/app/src/main/java/com/futo/platformplayer/helpers/VideoHelper.kt @@ -4,7 +4,10 @@ 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.IAudioSource +import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlSource 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 import com.futo.platformplayer.api.media.platforms.js.models.sources.JSAudioUrlRangeSource import com.futo.platformplayer.api.media.platforms.js.models.sources.JSVideoUrlRangeSource import com.futo.platformplayer.logging.Logger @@ -17,6 +20,12 @@ 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 selectBestVideoSource(desc: IVideoSourceDescriptor, desiredPixelCount : Int, prefContainers : Array) : IVideoSource? = selectBestVideoSource(desc.videoSources.toList(), desiredPixelCount, prefContainers); fun selectBestVideoSource(sources: Iterable, desiredPixelCount : Int, prefContainers : Array) : IVideoSource? { val targetVideo = if(desiredPixelCount > 0) 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 8803c4c0..00ea5fd6 100644 --- a/app/src/main/java/com/futo/platformplayer/services/DownloadService.kt +++ b/app/src/main/java/com/futo/platformplayer/services/DownloadService.kt @@ -12,6 +12,7 @@ import com.futo.platformplayer.* import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.downloads.VideoDownload +import com.futo.platformplayer.exceptions.DownloadException import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.states.Announcement import com.futo.platformplayer.states.AnnouncementType @@ -134,6 +135,22 @@ class DownloadService : Service() { Logger.w(TAG, "Video had no video or videodetail, removing download"); StateDownloads.instance.removeDownload(currentVideo); } + else if(ex is DownloadException && !ex.isRetryable) { + Logger.w(TAG, "Video had exception that should not be retried"); + StateDownloads.instance.removeDownload(currentVideo); + //Ensure impossible downloads are not retried for playlists + if(currentVideo.video != null && currentVideo.groupID != null && currentVideo.groupType == VideoDownload.GROUP_PLAYLIST) { + StateDownloads.instance.getPlaylistDownload(currentVideo.groupID!!)?.let { + synchronized(it.preventDownload) { + if(currentVideo?.video?.url != null && !it.preventDownload.contains(currentVideo!!.video!!.url)) { + it.preventDownload.add(currentVideo!!.video!!.url); + StateDownloads.instance.savePlaylistDownload(it); + Logger.w(TAG, "Preventing further download attempts in playlist [${it.id}] for [${currentVideo?.name}]:${currentVideo?.video?.url}"); + } + } + } + } + } else Logger.e(TAG, "Failed download [${currentVideo.name}]: ${ex.message}", ex); currentVideo.error = ex.message; diff --git a/app/src/main/java/com/futo/platformplayer/states/StateDownloads.kt b/app/src/main/java/com/futo/platformplayer/states/StateDownloads.kt index a689a6a8..70799da2 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateDownloads.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateDownloads.kt @@ -108,6 +108,11 @@ class StateDownloads { fun getPlaylistDownload(playlistId: String): PlaylistDownloadDescriptor? { return _downloadPlaylists.findItem { it.id == playlistId }; } + fun savePlaylistDownload(playlistDownload: PlaylistDownloadDescriptor) { + synchronized(playlistDownload.preventDownload) { + _downloadPlaylists.save(playlistDownload); + } + } fun deleteCachedPlaylist(id: String) { val pdl = getPlaylistDownload(id); if(pdl != null) @@ -157,12 +162,15 @@ class StateDownloads { val playlistsDownloaded = getCachedPlaylists(); for(playlist in playlistsDownloaded) { val playlistDownload = getPlaylistDownload(playlist.playlist.id) ?: continue; - - if(playlist.playlist.videos.any{ getCachedVideo(it.id) == null }) { - Logger.i(TAG, "Found new videos on playlist [${playlist.playlist.name}]"); + val toIgnore = playlistDownload.getPreventDownloadList(); + val missingVideoCount = playlist.playlist.videos.count { !toIgnore.contains(it.url) && getCachedVideo(it.id) == null }; + if(missingVideoCount > 0) { + Logger.i(TAG, "Found new videos (${missingVideoCount}) on playlist [${playlist.playlist.name}] to download"); continueDownload(playlistDownload, playlist.playlist); hasChanged = true; } + else + Logger.v(TAG, "Offline playlist [${playlist.playlist.name}] is up to date"); } return hasChanged; } @@ -171,6 +179,11 @@ class StateDownloads { var hasNew = false; for(item in playlist.videos) { val existing = getCachedVideo(item.id); + + if(!playlistDownload.shouldDownload(item)) { + Logger.i(TAG, "Not downloading for playlist [${playlistDownload.id}] Video [${item.name}]:${item.url}") + continue; + } if(existing == null) { val ongoingDownload = getDownloading().find { it.id.value == item.id.value && it.id.value != null }; if(ongoingDownload != null) {