From d5cab0910e9dbe7925c09a6266d71b3f21a78044 Mon Sep 17 00:00:00 2001 From: Kai Date: Mon, 10 Feb 2025 22:21:06 -0600 Subject: [PATCH 1/2] fix HLS audio download and download audio only Changelog: changed --- .../futo/platformplayer/UISlideOverlays.kt | 2 +- .../platformplayer/downloads/VideoDownload.kt | 14 ++-- .../platformplayer/downloads/VideoExport.kt | 2 +- .../com/futo/platformplayer/parsers/HLS.kt | 78 +++++++++++++------ 4 files changed, 63 insertions(+), 33 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt b/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt index 582a74f7..a78cd611 100644 --- a/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt +++ b/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt @@ -292,7 +292,7 @@ class UISlideOverlays { val masterPlaylist: HLS.MasterPlaylist try { - masterPlaylist = HLS.parseMasterPlaylist(masterPlaylistContent, sourceUrl) + masterPlaylist = HLS.parseMasterPlaylist(masterPlaylistContent, sourceUrl, source is IHLSManifestAudioSource) masterPlaylist.getAudioSources().forEach { it -> 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 8ba2814f..011db8de 100644 --- a/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt +++ b/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt @@ -414,7 +414,7 @@ class VideoDownload { videoFilePath = File(downloadDir, videoFileName!!).absolutePath; } if(actualAudioSource != null) { - audioFileName = "${videoDetails!!.id.value!!} [${actualAudioSource!!.language}-${actualAudioSource!!.bitrate}].${audioContainerToExtension(actualAudioSource!!.container)}".sanitizeFileName(); + audioFileName = "${videoDetails!!.id.value!!} [${actualAudioSource!!.language}-${actualAudioSource!!.bitrate}].${audioContainerToExtension(if (actualAudioSource.container == "application/vnd.apple.mpegurl") actualAudioSource.codec else actualAudioSource.container)}".sanitizeFileName(); audioFilePath = File(downloadDir, audioFileName!!).absolutePath; } if(subtitleSource != null) { @@ -620,10 +620,8 @@ class VideoDownload { 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 cmd = + "-i \"concat:${segmentFiles.joinToString("|")}\" -c copy \"${targetFile.absolutePath}\"" val statisticsCallback = StatisticsCallback { _ -> //TODO: Show progress? @@ -633,7 +631,6 @@ class VideoDownload { 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)) { @@ -641,7 +638,6 @@ class VideoDownload { } else { "Command failed with state '${session.state}' and return code ${session.returnCode}, stack trace ${session.failStackTrace}" } - fileList.delete() continuation.resumeWithException(RuntimeException(errorMessage)) } }, @@ -1150,8 +1146,10 @@ class VideoDownload { fun audioContainerToExtension(container: String): String { if (container.contains("audio/mp4")) return "mp4a"; + else if (container.contains("video/mp4")) + return "mp4"; else if (container.contains("audio/mpeg")) - return "mpga"; + return "mp3"; else if (container.contains("audio/mp3")) return "mp3"; else if (container.contains("audio/webm")) diff --git a/app/src/main/java/com/futo/platformplayer/downloads/VideoExport.kt b/app/src/main/java/com/futo/platformplayer/downloads/VideoExport.kt index 7c1c4e09..7ebb70ff 100644 --- a/app/src/main/java/com/futo/platformplayer/downloads/VideoExport.kt +++ b/app/src/main/java/com/futo/platformplayer/downloads/VideoExport.kt @@ -81,7 +81,7 @@ class VideoExport { outputFile = f; } else if (a != null) { val outputFileName = videoLocal.name.sanitizeFileName(true) + "." + VideoDownload.audioContainerToExtension(a.container); - val f = downloadRoot.createFile(a.container, outputFileName) + val f = downloadRoot.createFile(if (a.container == "application/vnd.apple.mpegurl") a.codec else a.container, outputFileName) ?: throw Exception("Failed to create file in external directory."); Logger.i(TAG, "Copying audio."); 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 9d1a3faa..bc01b4a6 100644 --- a/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt +++ b/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt @@ -1,5 +1,11 @@ package com.futo.platformplayer.parsers +import android.net.Uri +import androidx.annotation.OptIn +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.hls.playlist.DefaultHlsPlaylistParserFactory +import androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist +import androidx.media3.exoplayer.hls.playlist.HlsMultivariantPlaylist import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantAudioUrlSource import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantSubtitleUrlSource import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantVideoUrlSource @@ -7,13 +13,20 @@ import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestAudi import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource import com.futo.platformplayer.toYesNo import com.futo.platformplayer.yesNoToBoolean +import java.io.ByteArrayInputStream import java.net.URI +import java.net.URLConnection import java.time.ZonedDateTime import java.time.format.DateTimeFormatter class HLS { companion object { - fun parseMasterPlaylist(masterPlaylistContent: String, sourceUrl: String): MasterPlaylist { + @OptIn(UnstableApi::class) + fun parseMasterPlaylist(masterPlaylistContent: String, sourceUrl: String, isAudioSource: Boolean? = null): MasterPlaylist { + val inputStream = ByteArrayInputStream(masterPlaylistContent.toByteArray()) + val playlist = DefaultHlsPlaylistParserFactory().createPlaylistParser() + .parse(Uri.parse(sourceUrl), inputStream) + val baseUrl = URI(sourceUrl).resolve("./").toString() val variantPlaylists = mutableListOf() @@ -21,27 +34,39 @@ class HLS { val sessionDataList = mutableListOf() var independentSegments = false - masterPlaylistContent.lines().forEachIndexed { index, line -> - when { - line.startsWith("#EXT-X-STREAM-INF") -> { - val nextLine = masterPlaylistContent.lines().getOrNull(index + 1) - ?: throw Exception("Expected URI following #EXT-X-STREAM-INF, found none") - val url = resolveUrl(baseUrl, nextLine) + if (playlist is HlsMediaPlaylist) { + independentSegments = playlist.hasIndependentSegments + if (isAudioSource == true) { + val firstSegmentUrlFile = + Uri.parse(playlist.segments[0].initializationSegment?.url ?: playlist.segments[0].url).buildUpon().clearQuery().fragment(null) + .build().toString() + mediaRenditions.add(MediaRendition("AUDIO", playlist.baseUri, "Single Playlist", null, null, null, null, null, URLConnection.guessContentTypeFromName(firstSegmentUrlFile))) + } else { + variantPlaylists.add(VariantPlaylistReference(playlist.baseUri, StreamInfo(null, null, null, null, null, null, null, null, null))) + } + } else if (playlist is HlsMultivariantPlaylist) { + masterPlaylistContent.lines().forEachIndexed { index, line -> + when { + line.startsWith("#EXT-X-STREAM-INF") -> { + val nextLine = masterPlaylistContent.lines().getOrNull(index + 1) + ?: throw Exception("Expected URI following #EXT-X-STREAM-INF, found none") + val url = resolveUrl(baseUrl, nextLine) - variantPlaylists.add(VariantPlaylistReference(url, parseStreamInfo(line))) - } + variantPlaylists.add(VariantPlaylistReference(url, parseStreamInfo(line))) + } - line.startsWith("#EXT-X-MEDIA") -> { - mediaRenditions.add(parseMediaRendition(line, baseUrl)) - } + line.startsWith("#EXT-X-MEDIA") -> { + mediaRenditions.add(parseMediaRendition(line, baseUrl)) + } - line == "#EXT-X-INDEPENDENT-SEGMENTS" -> { - independentSegments = true - } + line == "#EXT-X-INDEPENDENT-SEGMENTS" -> { + independentSegments = true + } - line.startsWith("#EXT-X-SESSION-DATA") -> { - val sessionData = parseSessionData(line) - sessionDataList.add(sessionData) + line.startsWith("#EXT-X-SESSION-DATA") -> { + val sessionData = parseSessionData(line) + sessionDataList.add(sessionData) + } } } } @@ -61,7 +86,13 @@ class HLS { val playlistType = lines.find { it.startsWith("#EXT-X-PLAYLIST-TYPE:") }?.substringAfter(":") val streamInfo = lines.find { it.startsWith("#EXT-X-STREAM-INF:") }?.let { parseStreamInfo(it) } + val initSegment = + lines.find { it.startsWith("#EXT-X-MAP:") }?.substringAfter(":")?.split(",")?.get(0) + ?.substringAfter("=")?.trim('"') val segments = mutableListOf() + if (initSegment != null) { + segments.add(MediaSegment(0.0, resolveUrl(sourceUrl, initSegment))) + } var currentSegment: MediaSegment? = null lines.forEach { line -> when { @@ -109,10 +140,10 @@ class HLS { } } - fun parseAndGetAudioSources(source: Any, content: String, url: String): List { + fun parseAndGetAudioSources(source: Any, content: String, url: String, isAudioSource: Boolean? = null): List { val masterPlaylist: MasterPlaylist try { - masterPlaylist = parseMasterPlaylist(content, url) + masterPlaylist = parseMasterPlaylist(content, url, isAudioSource) return masterPlaylist.getAudioSources() } catch (e: Throwable) { if (content.lines().any { it.startsWith("#EXTINF:") }) { @@ -270,7 +301,8 @@ class HLS { val name: String?, val isDefault: Boolean?, val isAutoSelect: Boolean?, - val isForced: Boolean? + val isForced: Boolean?, + val container: String? = null, ) { fun toM3U8Line(): String = buildString { append("#EXT-X-MEDIA:") @@ -340,7 +372,7 @@ class HLS { 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) + "AUDIO" -> HLSVariantAudioUrlSource(it.name?.ifEmpty { "Audio (${suffix})" } ?: "Audio (${suffix})", 0, "application/vnd.apple.mpegurl", it.container ?: "", it.language ?: "", null, false, it.uri) else -> null } } @@ -376,7 +408,7 @@ class HLS { val programDateTime: ZonedDateTime?, val playlistType: String?, val streamInfo: StreamInfo?, - val segments: List + val segments: List, ) { fun buildM3U8(): String = buildString { append("#EXTM3U\n") From 3d258180bd1588a659b3079b4f1b887cd5fd43fe Mon Sep 17 00:00:00 2001 From: Kai Date: Tue, 11 Feb 2025 10:31:47 -0600 Subject: [PATCH 2/2] restore hard code HLS as mp4 Changelog: changed --- .../com/futo/platformplayer/downloads/VideoDownload.kt | 6 +++--- .../java/com/futo/platformplayer/downloads/VideoExport.kt | 6 +++--- app/src/main/java/com/futo/platformplayer/parsers/HLS.kt | 8 ++------ 3 files changed, 8 insertions(+), 12 deletions(-) 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 011db8de..76528cd8 100644 --- a/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt +++ b/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt @@ -410,11 +410,11 @@ class VideoDownload { else audioSource; if(actualVideoSource != null) { - videoFileName = "${videoDetails!!.id.value!!} [${actualVideoSource!!.width}x${actualVideoSource!!.height}].${videoContainerToExtension(actualVideoSource!!.container)}".sanitizeFileName(); + videoFileName = "${videoDetails!!.id.value!!} [${actualVideoSource!!.width}x${actualVideoSource!!.height}].${videoContainerToExtension(actualVideoSource.container)}".sanitizeFileName(); videoFilePath = File(downloadDir, videoFileName!!).absolutePath; } if(actualAudioSource != null) { - audioFileName = "${videoDetails!!.id.value!!} [${actualAudioSource!!.language}-${actualAudioSource!!.bitrate}].${audioContainerToExtension(if (actualAudioSource.container == "application/vnd.apple.mpegurl") actualAudioSource.codec else actualAudioSource.container)}".sanitizeFileName(); + audioFileName = "${videoDetails!!.id.value!!} [${actualAudioSource!!.language}-${actualAudioSource!!.bitrate}].${audioContainerToExtension(actualAudioSource.container)}".sanitizeFileName(); audioFilePath = File(downloadDir, audioFileName!!).absolutePath; } if(subtitleSource != null) { @@ -1149,7 +1149,7 @@ class VideoDownload { else if (container.contains("video/mp4")) return "mp4"; else if (container.contains("audio/mpeg")) - return "mp3"; + return "mpga"; else if (container.contains("audio/mp3")) return "mp3"; else if (container.contains("audio/webm")) diff --git a/app/src/main/java/com/futo/platformplayer/downloads/VideoExport.kt b/app/src/main/java/com/futo/platformplayer/downloads/VideoExport.kt index 7ebb70ff..6761168c 100644 --- a/app/src/main/java/com/futo/platformplayer/downloads/VideoExport.kt +++ b/app/src/main/java/com/futo/platformplayer/downloads/VideoExport.kt @@ -69,7 +69,7 @@ class VideoExport { outputFile = f; } else if (v != null) { val outputFileName = videoLocal.name.sanitizeFileName(true) + "." + VideoDownload.videoContainerToExtension(v.container); - val f = downloadRoot.createFile(v.container, outputFileName) + val f = downloadRoot.createFile(if (v.container == "application/vnd.apple.mpegurl") "video/mp4" else v.container, outputFileName) ?: throw Exception("Failed to create file in external directory."); Logger.i(TAG, "Copying video."); @@ -81,8 +81,8 @@ class VideoExport { outputFile = f; } else if (a != null) { val outputFileName = videoLocal.name.sanitizeFileName(true) + "." + VideoDownload.audioContainerToExtension(a.container); - val f = downloadRoot.createFile(if (a.container == "application/vnd.apple.mpegurl") a.codec else a.container, outputFileName) - ?: throw Exception("Failed to create file in external directory."); + val f = downloadRoot.createFile(if (a.container == "application/vnd.apple.mpegurl") "video/mp4" else a.container, outputFileName) + ?: throw Exception("Failed to create file in external directory."); Logger.i(TAG, "Copying audio."); 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 bc01b4a6..916bc74c 100644 --- a/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt +++ b/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt @@ -37,10 +37,7 @@ class HLS { if (playlist is HlsMediaPlaylist) { independentSegments = playlist.hasIndependentSegments if (isAudioSource == true) { - val firstSegmentUrlFile = - Uri.parse(playlist.segments[0].initializationSegment?.url ?: playlist.segments[0].url).buildUpon().clearQuery().fragment(null) - .build().toString() - mediaRenditions.add(MediaRendition("AUDIO", playlist.baseUri, "Single Playlist", null, null, null, null, null, URLConnection.guessContentTypeFromName(firstSegmentUrlFile))) + mediaRenditions.add(MediaRendition("AUDIO", playlist.baseUri, "Single Playlist", null, null, null, null, null)) } else { variantPlaylists.add(VariantPlaylistReference(playlist.baseUri, StreamInfo(null, null, null, null, null, null, null, null, null))) } @@ -302,7 +299,6 @@ class HLS { val isDefault: Boolean?, val isAutoSelect: Boolean?, val isForced: Boolean?, - val container: String? = null, ) { fun toM3U8Line(): String = buildString { append("#EXT-X-MEDIA:") @@ -372,7 +368,7 @@ class HLS { 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.container ?: "", it.language ?: "", null, false, it.uri) + "AUDIO" -> HLSVariantAudioUrlSource(it.name?.ifEmpty { "Audio (${suffix})" } ?: "Audio (${suffix})", 0, "application/vnd.apple.mpegurl", "", it.language ?: "", null, false, it.uri) else -> null } }