From f4610d0df50b905c69616b2fd2c8c60f5ca75646 Mon Sep 17 00:00:00 2001 From: Kai Date: Mon, 4 Nov 2024 10:40:48 -0600 Subject: [PATCH 1/3] fixed m3u8 parsing bug that caused Patreon video downloads to crash Grayjay --- .../com/futo/platformplayer/parsers/HLS.kt | 201 +++++++++++++----- 1 file changed, 145 insertions(+), 56 deletions(-) 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 734248b2..e7076236 100644 --- a/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt +++ b/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt @@ -46,36 +46,53 @@ class HLS { } } - return MasterPlaylist(variantPlaylists, mediaRenditions, sessionDataList, independentSegments) + return MasterPlaylist( + variantPlaylists, mediaRenditions, sessionDataList, independentSegments + ) } fun parseVariantPlaylist(content: String, sourceUrl: String): VariantPlaylist { val lines = content.lines() - val version = lines.find { it.startsWith("#EXT-X-VERSION:") }?.substringAfter(":")?.toIntOrNull() - val targetDuration = lines.find { it.startsWith("#EXT-X-TARGETDURATION:") }?.substringAfter(":")?.toIntOrNull() - val mediaSequence = lines.find { it.startsWith("#EXT-X-MEDIA-SEQUENCE:") }?.substringAfter(":")?.toLongOrNull() - val discontinuitySequence = lines.find { it.startsWith("#EXT-X-DISCONTINUITY-SEQUENCE:") }?.substringAfter(":")?.toIntOrNull() - val programDateTime = lines.find { it.startsWith("#EXT-X-PROGRAM-DATE-TIME:") }?.substringAfter(":")?.let { - ZonedDateTime.parse(it, DateTimeFormatter.ISO_DATE_TIME) - } - 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 version = + lines.find { it.startsWith("#EXT-X-VERSION:") }?.substringAfter(":")?.toIntOrNull() + val targetDuration = + lines.find { it.startsWith("#EXT-X-TARGETDURATION:") }?.substringAfter(":") + ?.toIntOrNull() + val mediaSequence = + lines.find { it.startsWith("#EXT-X-MEDIA-SEQUENCE:") }?.substringAfter(":") + ?.toLongOrNull() + val discontinuitySequence = + lines.find { it.startsWith("#EXT-X-DISCONTINUITY-SEQUENCE:") }?.substringAfter(":") + ?.toIntOrNull() + val programDateTime = + lines.find { it.startsWith("#EXT-X-PROGRAM-DATE-TIME:") }?.substringAfter(":") + ?.let { + ZonedDateTime.parse(it, DateTimeFormatter.ISO_DATE_TIME) + } + 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 segments = mutableListOf() var currentSegment: MediaSegment? = null lines.forEach { line -> when { line.startsWith("#EXTINF:") -> { - val duration = line.substringAfter(":").substringBefore(",").toDoubleOrNull() - ?: throw Exception("Invalid segment duration format") + val duration = + line.substringAfter(":").substringBefore(",").toDoubleOrNull() + ?: throw Exception("Invalid segment duration format") currentSegment = MediaSegment(duration = duration) } + line == "#EXT-X-DISCONTINUITY" -> { segments.add(DiscontinuitySegment()) } - line =="#EXT-X-ENDLIST" -> { + + line == "#EXT-X-ENDLIST" -> { segments.add(EndListSegment()) } + else -> { currentSegment?.let { it.uri = resolveUrl(sourceUrl, line) @@ -86,22 +103,51 @@ class HLS { } } - return VariantPlaylist(version, targetDuration, mediaSequence, discontinuitySequence, programDateTime, playlistType, streamInfo, segments) + return VariantPlaylist( + version, + targetDuration, + mediaSequence, + discontinuitySequence, + programDateTime, + playlistType, + streamInfo, + segments + ) } - fun parseAndGetVideoSources(source: Any, content: String, url: String): List { + 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() + return when (source) { + is IHLSManifestSource -> { + listOf( + HLSVariantVideoUrlSource( + "variant", + 0, + 0, + "application/vnd.apple.mpegurl", + "", + null, + 0, + false, + url + ) + ) + } + + is IHLSManifestAudioSource -> { + listOf() + } + + else -> { + throw NotImplementedError() + } } } else { throw e @@ -109,19 +155,38 @@ class HLS { } } - fun parseAndGetAudioSources(source: Any, content: String, url: String): List { + 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() + return when (source) { + is IHLSManifestSource -> { + listOf() + } + + is IHLSManifestAudioSource -> { + listOf( + HLSVariantAudioUrlSource( + "variant", + 0, + "application/vnd.apple.mpegurl", + "", + "", + null, + false, + url + ) + ) + } + + else -> { + throw NotImplementedError() + } } } else { throw e @@ -182,13 +247,14 @@ class HLS { private fun parseAttributes(content: String): Map { val attributes = mutableMapOf() - val attributePairs = content.substringAfter(":").splitToSequence(',') + val maybeAttributePairs = content.substringAfter(":").splitToSequence(',') var currentPair = StringBuilder() - for (pair in attributePairs) { + for (pair in maybeAttributePairs) { currentPair.append(pair) if (currentPair.count { it == '\"' } % 2 == 0) { // Check if the number of quotes is even - val (key, value) = currentPair.toString().split('=') + val key = currentPair.toString().substringBefore("=") + val value = currentPair.toString().substringAfter("=") attributes[key.trim()] = value.trim().removeSurrounding("\"") currentPair = StringBuilder() // Reset for the next attribute } else { @@ -201,33 +267,30 @@ class HLS { private val _quoteList = listOf("GROUP-ID", "NAME", "URI", "CODECS", "AUDIO", "VIDEO") private fun shouldQuote(key: String, value: String?): Boolean { - if (value == null) - return false; + if (value == null) return false - if (value.contains(',')) - return true; + if (value.contains(',')) return true return _quoteList.contains(key) } - private fun appendAttributes(stringBuilder: StringBuilder, vararg attributes: Pair) { - attributes.filter { it.second != null } - .joinToString(",") { + + private fun appendAttributes( + stringBuilder: StringBuilder, vararg attributes: Pair + ) { + attributes.filter { it.second != null }.joinToString(",") { val value = it.second "${it.first}=${if (shouldQuote(it.first, it.second)) "\"$value\"" else value}" - } - .let { if (it.isNotEmpty()) stringBuilder.append(it) } + }.let { if (it.isNotEmpty()) stringBuilder.append(it) } } } data class SessionData( - val dataId: String, - val value: String + val dataId: String, val value: String ) { fun toM3U8Line(): String = buildString { append("#EXT-X-SESSION-DATA:") - appendAttributes(this, - "DATA-ID" to dataId, - "VALUE" to value + appendAttributes( + this, "DATA-ID" to dataId, "VALUE" to value ) append("\n") } @@ -246,7 +309,8 @@ class HLS { ) { fun toM3U8Line(): String = buildString { append("#EXT-X-STREAM-INF:") - appendAttributes(this, + appendAttributes( + this, "BANDWIDTH" to bandwidth?.toString(), "RESOLUTION" to resolution, "CODECS" to codecs, @@ -273,7 +337,8 @@ class HLS { ) { fun toM3U8Line(): String = buildString { append("#EXT-X-MEDIA:") - appendAttributes(this, + appendAttributes( + this, "TYPE" to type, "URI" to uri, "GROUP-ID" to groupID, @@ -326,8 +391,20 @@ class HLS { 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) + 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 + ) } } @@ -337,9 +414,19 @@ class HLS { return@mapNotNull null } - val suffix = listOf(it.language, it.groupID).mapNotNull { x -> x?.ifEmpty { null } }.joinToString(", ") + 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.language ?: "", + null, + false, + it.uri) + else -> null } } @@ -351,9 +438,12 @@ class HLS { return@mapNotNull null } - val suffix = listOf(it.language, it.groupID).mapNotNull { x -> x?.ifEmpty { null } }.joinToString(", ") + 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") + "SUBTITLE" -> HLSVariantSubtitleUrlSource(it.name?.ifEmpty { "Subtitle (${suffix})" } + ?: "Subtitle (${suffix})", it.uri, "application/vnd.apple.mpegurl") + else -> null } } @@ -397,9 +487,8 @@ class HLS { abstract fun toM3U8Line(): String } - data class MediaSegment ( - val duration: Double, - var uri: String = "" + data class MediaSegment( + val duration: Double, var uri: String = "" ) : Segment() { override fun toM3U8Line(): String = buildString { append("#EXTINF:${duration},\n") From 8f6eac7ca267f7faba3d5e7ec652bb86e2bc6494 Mon Sep 17 00:00:00 2001 From: Kai Date: Tue, 5 Nov 2024 13:27:22 -0600 Subject: [PATCH 2/3] undo some formatting changes --- .../com/futo/platformplayer/parsers/HLS.kt | 147 +++--------------- 1 file changed, 22 insertions(+), 125 deletions(-) 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 e7076236..d7691733 100644 --- a/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt +++ b/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt @@ -103,16 +103,7 @@ class HLS { } } - return VariantPlaylist( - version, - targetDuration, - mediaSequence, - discontinuitySequence, - programDateTime, - playlistType, - streamInfo, - segments - ) + return VariantPlaylist(version, targetDuration, mediaSequence, discontinuitySequence, programDateTime, playlistType, streamInfo, segments) } fun parseAndGetVideoSources( @@ -127,17 +118,7 @@ class HLS { return when (source) { is IHLSManifestSource -> { listOf( - HLSVariantVideoUrlSource( - "variant", - 0, - 0, - "application/vnd.apple.mpegurl", - "", - null, - 0, - false, - url - ) + HLSVariantVideoUrlSource("variant", 0, 0, "application/vnd.apple.mpegurl", "", null, 0, false, url) ) } @@ -171,16 +152,7 @@ class HLS { is IHLSManifestAudioSource -> { listOf( - HLSVariantAudioUrlSource( - "variant", - 0, - "application/vnd.apple.mpegurl", - "", - "", - null, - false, - url - ) + HLSVariantAudioUrlSource("variant", 0, "application/vnd.apple.mpegurl", "", "", null, false, url) ) } @@ -211,15 +183,7 @@ class HLS { private fun parseStreamInfo(content: String): StreamInfo { val attributes = parseAttributes(content) return StreamInfo( - bandwidth = attributes["BANDWIDTH"]?.toIntOrNull(), - resolution = attributes["RESOLUTION"], - codecs = attributes["CODECS"], - frameRate = attributes["FRAME-RATE"], - videoRange = attributes["VIDEO-RANGE"], - audio = attributes["AUDIO"], - video = attributes["VIDEO"], - subtitles = attributes["SUBTITLES"], - closedCaptions = attributes["CLOSED-CAPTIONS"] + bandwidth = attributes["BANDWIDTH"]?.toIntOrNull(), resolution = attributes["RESOLUTION"], codecs = attributes["CODECS"], frameRate = attributes["FRAME-RATE"], videoRange = attributes["VIDEO-RANGE"], audio = attributes["AUDIO"], video = attributes["VIDEO"], subtitles = attributes["SUBTITLES"], closedCaptions = attributes["CLOSED-CAPTIONS"] ) } @@ -227,14 +191,7 @@ class HLS { val attributes = parseAttributes(line) val uri = attributes["URI"]?.let { resolveUrl(baseUrl, it) } return MediaRendition( - type = attributes["TYPE"], - uri = uri, - groupID = attributes["GROUP-ID"], - language = attributes["LANGUAGE"], - name = attributes["NAME"], - isDefault = attributes["DEFAULT"]?.yesNoToBoolean(), - isAutoSelect = attributes["AUTOSELECT"]?.yesNoToBoolean(), - isForced = attributes["FORCED"]?.yesNoToBoolean() + type = attributes["TYPE"], uri = uri, groupID = attributes["GROUP-ID"], language = attributes["LANGUAGE"], name = attributes["NAME"], isDefault = attributes["DEFAULT"]?.yesNoToBoolean(), isAutoSelect = attributes["AUTOSELECT"]?.yesNoToBoolean(), isForced = attributes["FORCED"]?.yesNoToBoolean() ) } @@ -278,9 +235,9 @@ class HLS { stringBuilder: StringBuilder, vararg attributes: Pair ) { attributes.filter { it.second != null }.joinToString(",") { - val value = it.second - "${it.first}=${if (shouldQuote(it.first, it.second)) "\"$value\"" else value}" - }.let { if (it.isNotEmpty()) stringBuilder.append(it) } + val value = it.second + "${it.first}=${if (shouldQuote(it.first, it.second)) "\"$value\"" else value}" + }.let { if (it.isNotEmpty()) stringBuilder.append(it) } } } @@ -296,69 +253,26 @@ class HLS { } } - data class StreamInfo( - val bandwidth: Int?, - val resolution: String?, - val codecs: String?, - val frameRate: String?, - val videoRange: String?, - val audio: String?, - val video: String?, - val subtitles: String?, - val closedCaptions: String? - ) { + data class StreamInfo(val bandwidth: Int?, val resolution: String?, val codecs: String?, val frameRate: String?, val videoRange: String?, val audio: String?, val video: String?, val subtitles: String?, val closedCaptions: String?) { fun toM3U8Line(): String = buildString { append("#EXT-X-STREAM-INF:") - appendAttributes( - this, - "BANDWIDTH" to bandwidth?.toString(), - "RESOLUTION" to resolution, - "CODECS" to codecs, - "FRAME-RATE" to frameRate, - "VIDEO-RANGE" to videoRange, - "AUDIO" to audio, - "VIDEO" to video, - "SUBTITLES" to subtitles, - "CLOSED-CAPTIONS" to closedCaptions - ) + appendAttributes(this, "BANDWIDTH" to bandwidth?.toString(), "RESOLUTION" to resolution, "CODECS" to codecs, "FRAME-RATE" to frameRate, "VIDEO-RANGE" to videoRange, "AUDIO" to audio, "VIDEO" to video, "SUBTITLES" to subtitles, "CLOSED-CAPTIONS" to closedCaptions) append("\n") } } data class MediaRendition( - val type: String?, - val uri: String?, - val groupID: String?, - val language: String?, - val name: String?, - val isDefault: Boolean?, - val isAutoSelect: Boolean?, - val isForced: Boolean? + val type: String?, val uri: String?, val groupID: String?, val language: String?, val name: String?, val isDefault: Boolean?, val isAutoSelect: Boolean?, val isForced: Boolean? ) { fun toM3U8Line(): String = buildString { append("#EXT-X-MEDIA:") - appendAttributes( - this, - "TYPE" to type, - "URI" to uri, - "GROUP-ID" to groupID, - "LANGUAGE" to language, - "NAME" to name, - "DEFAULT" to isDefault.toYesNo(), - "AUTOSELECT" to isAutoSelect.toYesNo(), - "FORCED" to isForced.toYesNo() - ) + appendAttributes(this, "TYPE" to type, "URI" to uri, "GROUP-ID" to groupID, "LANGUAGE" to language, "NAME" to name, "DEFAULT" to isDefault.toYesNo(), "AUTOSELECT" to isAutoSelect.toYesNo(), "FORCED" to isForced.toYesNo()) append("\n") } } - data class MasterPlaylist( - val variantPlaylistsRefs: List, - val mediaRenditions: List, - val sessionDataList: List, - val independentSegments: Boolean - ) { + data class MasterPlaylist(val variantPlaylistsRefs: List, val mediaRenditions: List, val sessionDataList: List, val independentSegments: Boolean) { fun buildM3U8(): String { val builder = StringBuilder() builder.append("#EXTM3U\n") @@ -395,15 +309,9 @@ class HLS { 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 + suffix, width ?: 0, height + ?: 0, "application/vnd.apple.mpegurl", it.streamInfo.codecs + ?: "", it.streamInfo.bandwidth, 0, false, it.url ) } } @@ -418,14 +326,8 @@ class HLS { .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 (${suffix})", 0, "application/vnd.apple.mpegurl", "", it.language + ?: "", null, false, it.uri) else -> null } @@ -458,14 +360,7 @@ class HLS { } data class VariantPlaylist( - val version: Int?, - val targetDuration: Int?, - val mediaSequence: Long?, - val discontinuitySequence: Int?, - val programDateTime: ZonedDateTime?, - val playlistType: String?, - val streamInfo: StreamInfo?, - val segments: List + val version: Int?, val targetDuration: Int?, val mediaSequence: Long?, val discontinuitySequence: Int?, val programDateTime: ZonedDateTime?, val playlistType: String?, val streamInfo: StreamInfo?, val segments: List ) { fun buildM3U8(): String = buildString { append("#EXTM3U\n") @@ -474,7 +369,9 @@ class HLS { mediaSequence?.let { append("#EXT-X-MEDIA-SEQUENCE:$it\n") } discontinuitySequence?.let { append("#EXT-X-DISCONTINUITY-SEQUENCE:$it\n") } playlistType?.let { append("#EXT-X-PLAYLIST-TYPE:$it\n") } - programDateTime?.let { append("#EXT-X-PROGRAM-DATE-TIME:${it.format(DateTimeFormatter.ISO_DATE_TIME)}\n") } + programDateTime?.let { + append("#EXT-X-PROGRAM-DATE-TIME:${it.format(DateTimeFormatter.ISO_DATE_TIME)}\n") + } streamInfo?.let { append(it.toM3U8Line()) } segments.forEach { segment -> From 4dcc2dd0ca0c34795ba5e46a9f5f8962f93797da Mon Sep 17 00:00:00 2001 From: Kai DeLorenzo Date: Tue, 19 Nov 2024 11:45:54 -0500 Subject: [PATCH 3/3] revert style changes --- .../com/futo/platformplayer/parsers/HLS.kt | 233 ++++++++++-------- 1 file changed, 124 insertions(+), 109 deletions(-) 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 d7691733..9d1a3faa 100644 --- a/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt +++ b/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt @@ -46,53 +46,36 @@ class HLS { } } - return MasterPlaylist( - variantPlaylists, mediaRenditions, sessionDataList, independentSegments - ) + return MasterPlaylist(variantPlaylists, mediaRenditions, sessionDataList, independentSegments) } fun parseVariantPlaylist(content: String, sourceUrl: String): VariantPlaylist { val lines = content.lines() - val version = - lines.find { it.startsWith("#EXT-X-VERSION:") }?.substringAfter(":")?.toIntOrNull() - val targetDuration = - lines.find { it.startsWith("#EXT-X-TARGETDURATION:") }?.substringAfter(":") - ?.toIntOrNull() - val mediaSequence = - lines.find { it.startsWith("#EXT-X-MEDIA-SEQUENCE:") }?.substringAfter(":") - ?.toLongOrNull() - val discontinuitySequence = - lines.find { it.startsWith("#EXT-X-DISCONTINUITY-SEQUENCE:") }?.substringAfter(":") - ?.toIntOrNull() - val programDateTime = - lines.find { it.startsWith("#EXT-X-PROGRAM-DATE-TIME:") }?.substringAfter(":") - ?.let { - ZonedDateTime.parse(it, DateTimeFormatter.ISO_DATE_TIME) - } - 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 version = lines.find { it.startsWith("#EXT-X-VERSION:") }?.substringAfter(":")?.toIntOrNull() + val targetDuration = lines.find { it.startsWith("#EXT-X-TARGETDURATION:") }?.substringAfter(":")?.toIntOrNull() + val mediaSequence = lines.find { it.startsWith("#EXT-X-MEDIA-SEQUENCE:") }?.substringAfter(":")?.toLongOrNull() + val discontinuitySequence = lines.find { it.startsWith("#EXT-X-DISCONTINUITY-SEQUENCE:") }?.substringAfter(":")?.toIntOrNull() + val programDateTime = lines.find { it.startsWith("#EXT-X-PROGRAM-DATE-TIME:") }?.substringAfter(":")?.let { + ZonedDateTime.parse(it, DateTimeFormatter.ISO_DATE_TIME) + } + 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 segments = mutableListOf() var currentSegment: MediaSegment? = null lines.forEach { line -> when { line.startsWith("#EXTINF:") -> { - val duration = - line.substringAfter(":").substringBefore(",").toDoubleOrNull() - ?: throw Exception("Invalid segment duration format") + val duration = line.substringAfter(":").substringBefore(",").toDoubleOrNull() + ?: throw Exception("Invalid segment duration format") currentSegment = MediaSegment(duration = duration) } - line == "#EXT-X-DISCONTINUITY" -> { segments.add(DiscontinuitySegment()) } - - line == "#EXT-X-ENDLIST" -> { + line =="#EXT-X-ENDLIST" -> { segments.add(EndListSegment()) } - else -> { currentSegment?.let { it.uri = resolveUrl(sourceUrl, line) @@ -106,29 +89,19 @@ class HLS { return VariantPlaylist(version, targetDuration, mediaSequence, discontinuitySequence, programDateTime, playlistType, streamInfo, segments) } - fun parseAndGetVideoSources( - source: Any, content: String, url: String - ): List { + 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 when (source) { - is IHLSManifestSource -> { - listOf( - HLSVariantVideoUrlSource("variant", 0, 0, "application/vnd.apple.mpegurl", "", null, 0, false, url) - ) - } - - is IHLSManifestAudioSource -> { - listOf() - } - - else -> { - throw NotImplementedError() - } + 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 @@ -136,29 +109,19 @@ class HLS { } } - fun parseAndGetAudioSources( - source: Any, content: String, url: String - ): List { + 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 when (source) { - is IHLSManifestSource -> { - listOf() - } - - is IHLSManifestAudioSource -> { - listOf( - HLSVariantAudioUrlSource("variant", 0, "application/vnd.apple.mpegurl", "", "", null, false, url) - ) - } - - else -> { - throw NotImplementedError() - } + 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 @@ -183,7 +146,15 @@ class HLS { private fun parseStreamInfo(content: String): StreamInfo { val attributes = parseAttributes(content) return StreamInfo( - bandwidth = attributes["BANDWIDTH"]?.toIntOrNull(), resolution = attributes["RESOLUTION"], codecs = attributes["CODECS"], frameRate = attributes["FRAME-RATE"], videoRange = attributes["VIDEO-RANGE"], audio = attributes["AUDIO"], video = attributes["VIDEO"], subtitles = attributes["SUBTITLES"], closedCaptions = attributes["CLOSED-CAPTIONS"] + bandwidth = attributes["BANDWIDTH"]?.toIntOrNull(), + resolution = attributes["RESOLUTION"], + codecs = attributes["CODECS"], + frameRate = attributes["FRAME-RATE"], + videoRange = attributes["VIDEO-RANGE"], + audio = attributes["AUDIO"], + video = attributes["VIDEO"], + subtitles = attributes["SUBTITLES"], + closedCaptions = attributes["CLOSED-CAPTIONS"] ) } @@ -191,7 +162,14 @@ class HLS { val attributes = parseAttributes(line) val uri = attributes["URI"]?.let { resolveUrl(baseUrl, it) } return MediaRendition( - type = attributes["TYPE"], uri = uri, groupID = attributes["GROUP-ID"], language = attributes["LANGUAGE"], name = attributes["NAME"], isDefault = attributes["DEFAULT"]?.yesNoToBoolean(), isAutoSelect = attributes["AUTOSELECT"]?.yesNoToBoolean(), isForced = attributes["FORCED"]?.yesNoToBoolean() + type = attributes["TYPE"], + uri = uri, + groupID = attributes["GROUP-ID"], + language = attributes["LANGUAGE"], + name = attributes["NAME"], + isDefault = attributes["DEFAULT"]?.yesNoToBoolean(), + isAutoSelect = attributes["AUTOSELECT"]?.yesNoToBoolean(), + isForced = attributes["FORCED"]?.yesNoToBoolean() ) } @@ -224,55 +202,99 @@ class HLS { private val _quoteList = listOf("GROUP-ID", "NAME", "URI", "CODECS", "AUDIO", "VIDEO") private fun shouldQuote(key: String, value: String?): Boolean { - if (value == null) return false + if (value == null) + return false; - if (value.contains(',')) return true + if (value.contains(',')) + return true; return _quoteList.contains(key) } - - private fun appendAttributes( - stringBuilder: StringBuilder, vararg attributes: Pair - ) { - attributes.filter { it.second != null }.joinToString(",") { - val value = it.second - "${it.first}=${if (shouldQuote(it.first, it.second)) "\"$value\"" else value}" - }.let { if (it.isNotEmpty()) stringBuilder.append(it) } + private fun appendAttributes(stringBuilder: StringBuilder, vararg attributes: Pair) { + attributes.filter { it.second != null } + .joinToString(",") { + val value = it.second + "${it.first}=${if (shouldQuote(it.first, it.second)) "\"$value\"" else value}" + } + .let { if (it.isNotEmpty()) stringBuilder.append(it) } } } data class SessionData( - val dataId: String, val value: String + val dataId: String, + val value: String ) { fun toM3U8Line(): String = buildString { append("#EXT-X-SESSION-DATA:") - appendAttributes( - this, "DATA-ID" to dataId, "VALUE" to value + appendAttributes(this, + "DATA-ID" to dataId, + "VALUE" to value ) append("\n") } } - data class StreamInfo(val bandwidth: Int?, val resolution: String?, val codecs: String?, val frameRate: String?, val videoRange: String?, val audio: String?, val video: String?, val subtitles: String?, val closedCaptions: String?) { + data class StreamInfo( + val bandwidth: Int?, + val resolution: String?, + val codecs: String?, + val frameRate: String?, + val videoRange: String?, + val audio: String?, + val video: String?, + val subtitles: String?, + val closedCaptions: String? + ) { fun toM3U8Line(): String = buildString { append("#EXT-X-STREAM-INF:") - appendAttributes(this, "BANDWIDTH" to bandwidth?.toString(), "RESOLUTION" to resolution, "CODECS" to codecs, "FRAME-RATE" to frameRate, "VIDEO-RANGE" to videoRange, "AUDIO" to audio, "VIDEO" to video, "SUBTITLES" to subtitles, "CLOSED-CAPTIONS" to closedCaptions) + appendAttributes(this, + "BANDWIDTH" to bandwidth?.toString(), + "RESOLUTION" to resolution, + "CODECS" to codecs, + "FRAME-RATE" to frameRate, + "VIDEO-RANGE" to videoRange, + "AUDIO" to audio, + "VIDEO" to video, + "SUBTITLES" to subtitles, + "CLOSED-CAPTIONS" to closedCaptions + ) append("\n") } } data class MediaRendition( - val type: String?, val uri: String?, val groupID: String?, val language: String?, val name: String?, val isDefault: Boolean?, val isAutoSelect: Boolean?, val isForced: Boolean? + val type: String?, + val uri: String?, + val groupID: String?, + val language: String?, + val name: String?, + val isDefault: Boolean?, + val isAutoSelect: Boolean?, + val isForced: Boolean? ) { fun toM3U8Line(): String = buildString { append("#EXT-X-MEDIA:") - appendAttributes(this, "TYPE" to type, "URI" to uri, "GROUP-ID" to groupID, "LANGUAGE" to language, "NAME" to name, "DEFAULT" to isDefault.toYesNo(), "AUTOSELECT" to isAutoSelect.toYesNo(), "FORCED" to isForced.toYesNo()) + appendAttributes(this, + "TYPE" to type, + "URI" to uri, + "GROUP-ID" to groupID, + "LANGUAGE" to language, + "NAME" to name, + "DEFAULT" to isDefault.toYesNo(), + "AUTOSELECT" to isAutoSelect.toYesNo(), + "FORCED" to isForced.toYesNo() + ) append("\n") } } - data class MasterPlaylist(val variantPlaylistsRefs: List, val mediaRenditions: List, val sessionDataList: List, val independentSegments: Boolean) { + data class MasterPlaylist( + val variantPlaylistsRefs: List, + val mediaRenditions: List, + val sessionDataList: List, + val independentSegments: Boolean + ) { fun buildM3U8(): String { val builder = StringBuilder() builder.append("#EXTM3U\n") @@ -305,14 +327,8 @@ class HLS { 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 - ) + 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) } } @@ -322,13 +338,9 @@ class HLS { return@mapNotNull null } - val suffix = listOf(it.language, it.groupID).mapNotNull { x -> x?.ifEmpty { null } } - .joinToString(", ") + 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.language ?: "", null, false, it.uri) else -> null } } @@ -340,12 +352,9 @@ class HLS { return@mapNotNull null } - val suffix = listOf(it.language, it.groupID).mapNotNull { x -> x?.ifEmpty { null } } - .joinToString(", ") + 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") - + "SUBTITLE" -> HLSVariantSubtitleUrlSource(it.name?.ifEmpty { "Subtitle (${suffix})" } ?: "Subtitle (${suffix})", it.uri, "application/vnd.apple.mpegurl") else -> null } } @@ -360,7 +369,14 @@ class HLS { } data class VariantPlaylist( - val version: Int?, val targetDuration: Int?, val mediaSequence: Long?, val discontinuitySequence: Int?, val programDateTime: ZonedDateTime?, val playlistType: String?, val streamInfo: StreamInfo?, val segments: List + val version: Int?, + val targetDuration: Int?, + val mediaSequence: Long?, + val discontinuitySequence: Int?, + val programDateTime: ZonedDateTime?, + val playlistType: String?, + val streamInfo: StreamInfo?, + val segments: List ) { fun buildM3U8(): String = buildString { append("#EXTM3U\n") @@ -369,9 +385,7 @@ class HLS { mediaSequence?.let { append("#EXT-X-MEDIA-SEQUENCE:$it\n") } discontinuitySequence?.let { append("#EXT-X-DISCONTINUITY-SEQUENCE:$it\n") } playlistType?.let { append("#EXT-X-PLAYLIST-TYPE:$it\n") } - programDateTime?.let { - append("#EXT-X-PROGRAM-DATE-TIME:${it.format(DateTimeFormatter.ISO_DATE_TIME)}\n") - } + programDateTime?.let { append("#EXT-X-PROGRAM-DATE-TIME:${it.format(DateTimeFormatter.ISO_DATE_TIME)}\n") } streamInfo?.let { append(it.toM3U8Line()) } segments.forEach { segment -> @@ -384,8 +398,9 @@ class HLS { abstract fun toM3U8Line(): String } - data class MediaSegment( - val duration: Double, var uri: String = "" + data class MediaSegment ( + val duration: Double, + var uri: String = "" ) : Segment() { override fun toM3U8Line(): String = buildString { append("#EXTINF:${duration},\n")