fixed m3u8 parsing bug that caused Patreon video downloads to crash Grayjay

This commit is contained in:
Kai 2024-11-04 10:40:48 -06:00
parent 2bcd59cbfa
commit f4610d0df5
No known key found for this signature in database

View File

@ -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 { fun parseVariantPlaylist(content: String, sourceUrl: String): VariantPlaylist {
val lines = content.lines() val lines = content.lines()
val version = lines.find { it.startsWith("#EXT-X-VERSION:") }?.substringAfter(":")?.toIntOrNull() val version =
val targetDuration = lines.find { it.startsWith("#EXT-X-TARGETDURATION:") }?.substringAfter(":")?.toIntOrNull() lines.find { it.startsWith("#EXT-X-VERSION:") }?.substringAfter(":")?.toIntOrNull()
val mediaSequence = lines.find { it.startsWith("#EXT-X-MEDIA-SEQUENCE:") }?.substringAfter(":")?.toLongOrNull() val targetDuration =
val discontinuitySequence = lines.find { it.startsWith("#EXT-X-DISCONTINUITY-SEQUENCE:") }?.substringAfter(":")?.toIntOrNull() lines.find { it.startsWith("#EXT-X-TARGETDURATION:") }?.substringAfter(":")
val programDateTime = lines.find { it.startsWith("#EXT-X-PROGRAM-DATE-TIME:") }?.substringAfter(":")?.let { ?.toIntOrNull()
ZonedDateTime.parse(it, DateTimeFormatter.ISO_DATE_TIME) val mediaSequence =
} lines.find { it.startsWith("#EXT-X-MEDIA-SEQUENCE:") }?.substringAfter(":")
val playlistType = lines.find { it.startsWith("#EXT-X-PLAYLIST-TYPE:") }?.substringAfter(":") ?.toLongOrNull()
val streamInfo = lines.find { it.startsWith("#EXT-X-STREAM-INF:") }?.let { parseStreamInfo(it) } 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<Segment>() val segments = mutableListOf<Segment>()
var currentSegment: MediaSegment? = null var currentSegment: MediaSegment? = null
lines.forEach { line -> lines.forEach { line ->
when { when {
line.startsWith("#EXTINF:") -> { line.startsWith("#EXTINF:") -> {
val duration = line.substringAfter(":").substringBefore(",").toDoubleOrNull() val duration =
?: throw Exception("Invalid segment duration format") line.substringAfter(":").substringBefore(",").toDoubleOrNull()
?: throw Exception("Invalid segment duration format")
currentSegment = MediaSegment(duration = duration) currentSegment = MediaSegment(duration = duration)
} }
line == "#EXT-X-DISCONTINUITY" -> { line == "#EXT-X-DISCONTINUITY" -> {
segments.add(DiscontinuitySegment()) segments.add(DiscontinuitySegment())
} }
line =="#EXT-X-ENDLIST" -> {
line == "#EXT-X-ENDLIST" -> {
segments.add(EndListSegment()) segments.add(EndListSegment())
} }
else -> { else -> {
currentSegment?.let { currentSegment?.let {
it.uri = resolveUrl(sourceUrl, line) 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<HLSVariantVideoUrlSource> { fun parseAndGetVideoSources(
source: Any, content: String, url: String
): List<HLSVariantVideoUrlSource> {
val masterPlaylist: MasterPlaylist val masterPlaylist: MasterPlaylist
try { try {
masterPlaylist = parseMasterPlaylist(content, url) masterPlaylist = parseMasterPlaylist(content, url)
return masterPlaylist.getVideoSources() return masterPlaylist.getVideoSources()
} catch (e: Throwable) { } catch (e: Throwable) {
if (content.lines().any { it.startsWith("#EXTINF:") }) { if (content.lines().any { it.startsWith("#EXTINF:") }) {
return if (source is IHLSManifestSource) { return when (source) {
listOf(HLSVariantVideoUrlSource("variant", 0, 0, "application/vnd.apple.mpegurl", "", null, 0, false, url)) is IHLSManifestSource -> {
} else if (source is IHLSManifestAudioSource) { listOf(
listOf() HLSVariantVideoUrlSource(
} else { "variant",
throw NotImplementedError() 0,
0,
"application/vnd.apple.mpegurl",
"",
null,
0,
false,
url
)
)
}
is IHLSManifestAudioSource -> {
listOf()
}
else -> {
throw NotImplementedError()
}
} }
} else { } else {
throw e throw e
@ -109,19 +155,38 @@ class HLS {
} }
} }
fun parseAndGetAudioSources(source: Any, content: String, url: String): List<HLSVariantAudioUrlSource> { fun parseAndGetAudioSources(
source: Any, content: String, url: String
): List<HLSVariantAudioUrlSource> {
val masterPlaylist: MasterPlaylist val masterPlaylist: MasterPlaylist
try { try {
masterPlaylist = parseMasterPlaylist(content, url) masterPlaylist = parseMasterPlaylist(content, url)
return masterPlaylist.getAudioSources() return masterPlaylist.getAudioSources()
} catch (e: Throwable) { } catch (e: Throwable) {
if (content.lines().any { it.startsWith("#EXTINF:") }) { if (content.lines().any { it.startsWith("#EXTINF:") }) {
return if (source is IHLSManifestSource) { return when (source) {
listOf() is IHLSManifestSource -> {
} else if (source is IHLSManifestAudioSource) { listOf()
listOf(HLSVariantAudioUrlSource("variant", 0, "application/vnd.apple.mpegurl", "", "", null, false, url)) }
} else {
throw NotImplementedError() is IHLSManifestAudioSource -> {
listOf(
HLSVariantAudioUrlSource(
"variant",
0,
"application/vnd.apple.mpegurl",
"",
"",
null,
false,
url
)
)
}
else -> {
throw NotImplementedError()
}
} }
} else { } else {
throw e throw e
@ -182,13 +247,14 @@ class HLS {
private fun parseAttributes(content: String): Map<String, String> { private fun parseAttributes(content: String): Map<String, String> {
val attributes = mutableMapOf<String, String>() val attributes = mutableMapOf<String, String>()
val attributePairs = content.substringAfter(":").splitToSequence(',') val maybeAttributePairs = content.substringAfter(":").splitToSequence(',')
var currentPair = StringBuilder() var currentPair = StringBuilder()
for (pair in attributePairs) { for (pair in maybeAttributePairs) {
currentPair.append(pair) currentPair.append(pair)
if (currentPair.count { it == '\"' } % 2 == 0) { // Check if the number of quotes is even 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("\"") attributes[key.trim()] = value.trim().removeSurrounding("\"")
currentPair = StringBuilder() // Reset for the next attribute currentPair = StringBuilder() // Reset for the next attribute
} else { } else {
@ -201,33 +267,30 @@ class HLS {
private val _quoteList = listOf("GROUP-ID", "NAME", "URI", "CODECS", "AUDIO", "VIDEO") private val _quoteList = listOf("GROUP-ID", "NAME", "URI", "CODECS", "AUDIO", "VIDEO")
private fun shouldQuote(key: String, value: String?): Boolean { private fun shouldQuote(key: String, value: String?): Boolean {
if (value == null) if (value == null) return false
return false;
if (value.contains(',')) if (value.contains(',')) return true
return true;
return _quoteList.contains(key) return _quoteList.contains(key)
} }
private fun appendAttributes(stringBuilder: StringBuilder, vararg attributes: Pair<String, String?>) {
attributes.filter { it.second != null } private fun appendAttributes(
.joinToString(",") { stringBuilder: StringBuilder, vararg attributes: Pair<String, String?>
) {
attributes.filter { it.second != null }.joinToString(",") {
val value = it.second val value = it.second
"${it.first}=${if (shouldQuote(it.first, it.second)) "\"$value\"" else value}" "${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( data class SessionData(
val dataId: String, val dataId: String, val value: String
val value: String
) { ) {
fun toM3U8Line(): String = buildString { fun toM3U8Line(): String = buildString {
append("#EXT-X-SESSION-DATA:") append("#EXT-X-SESSION-DATA:")
appendAttributes(this, appendAttributes(
"DATA-ID" to dataId, this, "DATA-ID" to dataId, "VALUE" to value
"VALUE" to value
) )
append("\n") append("\n")
} }
@ -246,7 +309,8 @@ class HLS {
) { ) {
fun toM3U8Line(): String = buildString { fun toM3U8Line(): String = buildString {
append("#EXT-X-STREAM-INF:") append("#EXT-X-STREAM-INF:")
appendAttributes(this, appendAttributes(
this,
"BANDWIDTH" to bandwidth?.toString(), "BANDWIDTH" to bandwidth?.toString(),
"RESOLUTION" to resolution, "RESOLUTION" to resolution,
"CODECS" to codecs, "CODECS" to codecs,
@ -273,7 +337,8 @@ class HLS {
) { ) {
fun toM3U8Line(): String = buildString { fun toM3U8Line(): String = buildString {
append("#EXT-X-MEDIA:") append("#EXT-X-MEDIA:")
appendAttributes(this, appendAttributes(
this,
"TYPE" to type, "TYPE" to type,
"URI" to uri, "URI" to uri,
"GROUP-ID" to groupID, "GROUP-ID" to groupID,
@ -326,8 +391,20 @@ class HLS {
height = resolutionTokens[1].toIntOrNull() height = resolutionTokens[1].toIntOrNull()
} }
val suffix = listOf(it.streamInfo.video, it.streamInfo.codecs).mapNotNull { x -> x?.ifEmpty { null } }.joinToString(", ") val suffix = listOf(
HLSVariantVideoUrlSource(suffix, width ?: 0, height ?: 0, "application/vnd.apple.mpegurl", it.streamInfo.codecs ?: "", it.streamInfo.bandwidth, 0, false, it.url) 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 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) { 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 else -> null
} }
} }
@ -351,9 +438,12 @@ class HLS {
return@mapNotNull null 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) { 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 else -> null
} }
} }
@ -397,9 +487,8 @@ class HLS {
abstract fun toM3U8Line(): String abstract fun toM3U8Line(): String
} }
data class MediaSegment ( data class MediaSegment(
val duration: Double, val duration: Double, var uri: String = ""
var uri: String = ""
) : Segment() { ) : Segment() {
override fun toM3U8Line(): String = buildString { override fun toM3U8Line(): String = buildString {
append("#EXTINF:${duration},\n") append("#EXTINF:${duration},\n")