More robust HLS downloading.

This commit is contained in:
Koen J
2025-11-19 14:48:29 +01:00
parent ee2af411aa
commit 25948dd296
2 changed files with 503 additions and 160 deletions
@@ -585,119 +585,266 @@ class VideoDownload {
return cipher.doFinal(encryptedSegment)
}
private suspend fun downloadHlsSource(context: Context, name: String, client: ManagedHttpClient, source: JSSource?, hlsUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
if(targetFile.exists())
targetFile.delete();
private fun remuxWithFfmpegInPlace(inputFile: File): Boolean {
val inputPath = inputFile.absolutePath
if (!inputFile.exists()) {
Logger.w(TAG, "remuxWithFfmpegInPlace: input does not exist: $inputPath")
return false
}
var downloadedTotalLength = 0L
val parent = inputFile.parentFile
if (parent == null) {
Logger.w(TAG, "remuxWithFfmpegInPlace: input has no parent: $inputPath")
return false
}
val modifier = if (source is JSSource && source.hasRequestModifier)
source.getRequestModifier();
else
null;
val tmpFile = File(parent, inputFile.nameWithoutExtension + "_fixed." + inputFile.extension)
val cmd = buildString {
append("-y ")
append("-i \"").append(inputFile.absolutePath).append("\" ")
append("-c copy ")
append("-movflags +faststart ")
append("\"").append(tmpFile.absolutePath).append("\"")
}
val segmentFiles = arrayListOf<File>()
try {
val modified = modifier?.modifyRequest(hlsUrl, mapOf());
Logger.i(TAG, "FFmpeg remux command: $cmd")
val response = client.get(modified?.url ?: hlsUrl, modified?.headers?.toMutableMap() ?: mutableMapOf())
val session = FFmpegKit.execute(cmd)
val returnCode = session.returnCode
check(response.isOk) { "Failed to get variant playlist: ${response.code}" }
if (ReturnCode.isSuccess(returnCode)) {
val newLen = tmpFile.length()
val vpContent = response.body?.string()
?: throw Exception("Variant playlist content is empty")
if (!inputFile.delete()) {
Logger.w(TAG, "remuxWithFfmpegInPlace: failed to delete original: ${inputFile.absolutePath}")
}
val variantPlaylist = HLS.parseVariantPlaylist(vpContent, hlsUrl)
val decryptionInfo: DecryptionInfo? = if (variantPlaylist.decryptionInfo != null) {
val modifiedDecryptionRequest = modifier?.modifyRequest(variantPlaylist.decryptionInfo.keyUrl, mapOf());
val keyResponse = client.get(modifiedDecryptionRequest?.url ?: variantPlaylist.decryptionInfo.keyUrl, modifiedDecryptionRequest?.headers?.toMutableMap() ?: mutableMapOf())
check(keyResponse.isOk) { "HLS request failed for decryption key: ${keyResponse.code}" }
DecryptionInfo(keyResponse.body!!.bytes(), variantPlaylist.decryptionInfo.iv?.hexStringToByteArray())
if (!tmpFile.renameTo(inputFile)) {
Logger.w(TAG, "remuxWithFfmpegInPlace: failed to move tmp: ${tmpFile.absolutePath}")
} else {
null
Logger.i(TAG, "remuxWithFfmpegInPlace: success for $inputPath (size=$newLen bytes)")
}
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()}")
val outputStream = segmentFile.outputStream()
try {
segmentFiles.add(segmentFile)
val segmentLength = downloadSource_Sequential(client, modifier, outputStream, segment.uri, if (index == 0) null else decryptionInfo, index) { 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
} finally {
outputStream.close()
}
}
Logger.i(TAG, "Combining segments into $targetFile");
combineSegments(context, segmentFiles, targetFile)
Logger.i(TAG, "${name} downloadSource Finished");
return true
} else {
Logger.e(TAG, "FFmpeg remux failed for $inputPath. rc=$returnCode, logs=${session.allLogsAsString}")
tmpFile.delete()
return false
}
catch(ioex: IOException) {
if(targetFile.exists())
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())
targetFile.delete();
throw ex;
}
finally {
for (segmentFile in segmentFiles) {
segmentFile.delete()
}
}
return downloadedTotalLength;
}
private suspend fun combineSegments(context: Context, segmentFiles: List<File>, targetFile: File) = withContext(Dispatchers.IO) {
suspendCancellableCoroutine { continuation ->
val cmd =
"-i \"concat:${segmentFiles.joinToString("|")}\" -c copy \"${targetFile.absolutePath}\""
private fun downloadHlsSource(context: Context, name: String, client: ManagedHttpClient, source: JSSource?, hlsUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
if (targetFile.exists())
targetFile.delete()
val statisticsCallback = StatisticsCallback { _ ->
//TODO: Show progress?
var downloadedTotalLength = 0L
val modifier = if (source is JSSource && source.hasRequestModifier)
source.getRequestModifier()
else
null
fun downloadBytes(url: String, rangeStart: Long? = null, rangeLength: Long? = null): ByteArray {
val headers = mutableMapOf<String, String>()
if (rangeStart != null) {
if (rangeLength != null && rangeLength > 0) {
val end = rangeStart + rangeLength - 1
headers["Range"] = "bytes=$rangeStart-$end"
} else {
headers["Range"] = "bytes=$rangeStart-"
}
}
val executorService = Executors.newSingleThreadExecutor()
val session = FFmpegKit.executeAsync(cmd,
{ session ->
if (ReturnCode.isSuccess(session.returnCode)) {
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}"
}
continuation.resumeWithException(RuntimeException(errorMessage))
}
},
{ Logger.v(TAG, it.message) },
statisticsCallback,
executorService
val modified = modifier?.modifyRequest(url, headers)
val finalUrl = modified?.url ?: url
val finalHeaders = modified?.headers?.toMutableMap() ?: headers
val resp = client.get(finalUrl, finalHeaders)
if (!resp.isOk) {
resp.body?.close()
throw IllegalStateException("Failed to download HLS resource ($finalUrl): HTTP ${resp.code}")
}
val body = resp.body ?: throw IllegalStateException("Failed to download HLS resource ($finalUrl): Empty body")
val bytes = body.bytes()
body.close()
return bytes
}
fun buildSequenceIv(sequenceNumber: Long): ByteArray {
return ByteBuffer.allocate(16)
.putLong(0L)
.putLong(sequenceNumber)
.array()
}
try {
val playlistHeaders = mutableMapOf<String, String>()
val modifiedPlaylistReq = modifier?.modifyRequest(hlsUrl, playlistHeaders)
val playlistResp = client.get(
modifiedPlaylistReq?.url ?: hlsUrl,
modifiedPlaylistReq?.headers?.toMutableMap() ?: playlistHeaders
)
continuation.invokeOnCancellation {
session.cancel()
check(playlistResp.isOk) { "Failed to get variant playlist: ${playlistResp.code}" }
val vpContent = playlistResp.body?.string()
?: throw IllegalStateException("Variant playlist content is empty")
val variantPlaylist = HLS.parseVariantPlaylist(vpContent, hlsUrl)
val hlsDec = variantPlaylist.decryptionInfo
val useDecryption = hlsDec != null && !hlsDec.method.equals("NONE", ignoreCase = true)
var keyBytes: ByteArray? = null
var staticIvBytes: ByteArray? = null
if (useDecryption) {
if (!hlsDec.method.equals("AES-128", ignoreCase = true)) {
throw UnsupportedOperationException("HLS decryption method '${hlsDec.method}' is not supported.")
}
val keyUrl = hlsDec.keyUrl ?: throw IllegalStateException("Encrypted HLS playlist without key URI is not supported.")
keyBytes = downloadBytes(keyUrl)
if (!hlsDec.iv.isNullOrEmpty()) {
staticIvBytes = hlsDec.iv.hexStringToByteArray()
}
}
val mediaSequence = variantPlaylist.mediaSequence ?: 0L
val rangeOffsets = mutableMapOf<String, Long>()
targetFile.outputStream().use { outStr ->
if (!variantPlaylist.mapUrl.isNullOrEmpty()) {
if (isCancelled) throw CancellationException("Cancelled")
Logger.i(TAG, "Downloading HLS initialization map")
var mapRangeStart: Long? = null
var mapRangeLength: Long? = null
if (variantPlaylist.mapBytesLength > 0) {
mapRangeLength = variantPlaylist.mapBytesLength
val mapUrl = variantPlaylist.mapUrl!!
if (variantPlaylist.mapBytesStart >= 0) {
mapRangeStart = variantPlaylist.mapBytesStart
rangeOffsets[mapUrl] =
variantPlaylist.mapBytesStart + variantPlaylist.mapBytesLength
} else {
val offset = rangeOffsets[mapUrl] ?: 0L
mapRangeStart = offset
rangeOffsets[mapUrl] = offset + variantPlaylist.mapBytesLength
}
}
var mapBytes = downloadBytes(variantPlaylist.mapUrl!!, mapRangeStart, mapRangeLength)
if (useDecryption) {
val kb = keyBytes ?: throw IllegalStateException("Decryption key bytes are missing.")
val iv = staticIvBytes
?: throw UnsupportedOperationException("Encrypted EXT-X-MAP without explicit IV is not supported.")
mapBytes = decryptSegment(mapBytes, kb, iv)
}
if (mapBytes.size.toLong() > Int.MAX_VALUE) {
throw IllegalStateException("HLS MAP segment too large to handle.")
}
outStr.write(mapBytes)
outStr.flush()
downloadedTotalLength += mapBytes.size
}
val totalSegments = variantPlaylist.segments.size
var mediaSegmentIndex = 0
var bytesSinceLastSpeedUpdate = 0L
var lastSpeedUpdateTime = System.currentTimeMillis()
var lastSpeed = 0L
variantPlaylist.segments.forEachIndexed { index, segment ->
if (segment !is HLS.MediaSegment) return@forEachIndexed
if (isCancelled) throw CancellationException("Cancelled")
Logger.i(TAG, "Download '$name' segment $index sequential")
var rangeStart: Long? = null
var rangeLength: Long? = null
if (segment.bytesLength > 0) {
rangeLength = segment.bytesLength
val urlKey = segment.uri
if (segment.bytesStart >= 0) {
rangeStart = segment.bytesStart
rangeOffsets[urlKey] = segment.bytesStart + segment.bytesLength
} else {
val offset = rangeOffsets[urlKey] ?: 0L
rangeStart = offset
rangeOffsets[urlKey] = offset + segment.bytesLength
}
}
var segmentBytes = downloadBytes(segment.uri, rangeStart, rangeLength)
if (useDecryption) {
val kb = keyBytes ?: throw IllegalStateException("Decryption key bytes are missing.")
val ivBytes = if (staticIvBytes != null) {
staticIvBytes!!
} else {
val sequenceNumber = mediaSequence + mediaSegmentIndex
buildSequenceIv(sequenceNumber)
}
segmentBytes = decryptSegment(segmentBytes, kb, ivBytes)
}
val segmentLength = segmentBytes.size.toLong()
if (segmentLength > Int.MAX_VALUE) {
throw IllegalStateException("HLS media segment too large to handle.")
}
val avgLen = if (index == 0) {
segmentLength
} else {
if (index > 0) downloadedTotalLength / index else segmentLength
}
val expectedTotal = avgLen * (totalSegments - 1) + segmentLength
outStr.write(segmentBytes)
downloadedTotalLength += segmentLength
bytesSinceLastSpeedUpdate += segmentLength
val now = System.currentTimeMillis()
val elapsed = now - lastSpeedUpdateTime
if (elapsed >= 500 && bytesSinceLastSpeedUpdate > 0) {
lastSpeed = (bytesSinceLastSpeedUpdate * 1000L / elapsed)
bytesSinceLastSpeedUpdate = 0
lastSpeedUpdateTime = now
}
onProgress(expectedTotal, downloadedTotalLength, lastSpeed)
mediaSegmentIndex++
}
}
remuxWithFfmpegInPlace(targetFile)
Logger.i(TAG, "Finished HLS Source for $name")
} catch (ioex: IOException) {
if (targetFile.exists())
targetFile.delete()
if (ioex.message?.contains("ENOSPC") == true)
throw Exception("Not enough space on device", ioex)
else
throw ioex
} catch (ex: Throwable) {
if (targetFile.exists())
targetFile.delete()
throw ex
}
return downloadedTotalLength
}
private fun downloadDashFileSource(name: String, client: ManagedHttpClient, source: IJSDashManifestRawSource, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
@@ -29,14 +29,25 @@ class HLS {
val mediaRenditions = mutableListOf<MediaRendition>()
val sessionDataList = mutableListOf<SessionData>()
var independentSegments = false
var version: Int? = null
var mediaSequence: Long? = null
val unhandled = mutableListOf<String>()
masterPlaylistContent.lines().forEachIndexed { index, line ->
val lines = masterPlaylistContent.lines()
lines.forEachIndexed { index, line ->
when {
line.startsWith("#EXT-X-VERSION:") -> {
version = line.substringAfter(":").toIntOrNull()
}
line.startsWith("#EXT-X-MEDIA-SEQUENCE:") -> {
mediaSequence = line.substringAfter(":").toLongOrNull()
}
line.startsWith("#EXT-X-STREAM-INF") -> {
val nextLine = masterPlaylistContent.lines().getOrNull(index + 1)
val nextLine = 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)))
}
@@ -52,10 +63,14 @@ class HLS {
val sessionData = parseSessionData(line)
sessionDataList.add(sessionData)
}
else -> {
unhandled.add(line)
}
}
}
return MasterPlaylist(variantPlaylists, mediaRenditions, sessionDataList, independentSegments)
return MasterPlaylist(variantPlaylists, mediaRenditions, sessionDataList, independentSegments, version = version, mediaSequence = mediaSequence, unhandled = unhandled)
}
fun mediaRenditionToVariant(rendition: MediaRendition): HLSVariantAudioUrlSource? {
@@ -83,62 +98,189 @@ class HLS {
return HLSVariantVideoUrlSource(suffix, width ?: 0, height ?: 0, "application/vnd.apple.mpegurl", reference.streamInfo.codecs ?: "", reference.streamInfo.bandwidth, 0, false, reference.url)
}
private fun parseByteRange(value: String): Pair<Long, Long> {
val trimmed = value.trim()
require(trimmed.isNotEmpty()) { "Empty BYTERANGE value" }
val parts = trimmed.split('@')
val length = parts[0].toLong()
require(length >= 0) { "Invalid BYTERANGE length '$value'" }
val start = if (parts.size > 1) {
val s = parts[1].toLong()
require(s >= 0) { "Invalid BYTERANGE offset '$value'" }
s
} else {
-1L
}
return length to start
}
private fun parseAttributes(content: String): Map<String, String> {
val index = content.indexOf(':')
if (index < 0 || index == content.length - 1) return emptyMap()
val attributes = mutableMapOf<String, String>()
val maybeAttributePairs = content.substring(index + 1).splitToSequence(',')
var currentPair = StringBuilder()
for (pair in maybeAttributePairs) {
currentPair.append(pair)
if (currentPair.count { it == '\"' } % 2 == 0) {
val full = currentPair.toString()
val key = full.substringBefore("=")
val value = full.substringAfter("=")
attributes[key.trim()] = value.trim().removeSurrounding("\"")
currentPair = StringBuilder()
} else {
currentPair.append(',')
}
}
return attributes
}
fun parseVariantPlaylist(content: String, sourceUrl: String): VariantPlaylist {
val baseUrl = URI(sourceUrl).resolve("./").toString()
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 keyInfo =
lines.find { it.startsWith("#EXT-X-KEY:") }?.substringAfter(":")?.split(",")
val key = keyInfo?.find { it.startsWith("URI=") }?.substringAfter("=")?.trim('"')
val iv =
keyInfo?.find { it.startsWith("IV=") }?.substringAfter("=")?.substringAfter("x")
val decryptionInfo: DecryptionInfo? = key?.let { k ->
DecryptionInfo(k, iv)
}
val initSegment =
lines.find { it.startsWith("#EXT-X-MAP:") }?.substringAfter(":")?.split(",")?.get(0)
?.substringAfter("=")?.trim('"')
var version: Int? = null
var targetDuration: Int? = null
var mediaSequence: Long? = null
var discontinuitySequence: Int? = null
var programDateTime: ZonedDateTime? = null
var playlistType: String? = null
var streamInfo: StreamInfo? = null
var decryptionInfo: DecryptionInfo? = null
var mapUrl: String? = null
var mapBytesStart: Long = -1
var mapBytesLength: Long = -1
val segments = mutableListOf<Segment>()
if (initSegment != null) {
segments.add(MediaSegment(0.0, resolveUrl(sourceUrl, initSegment)))
}
val unhandled = mutableListOf<String>()
var currentSegment: MediaSegment? = null
lines.forEach { line ->
for (rawLine in lines) {
val line = rawLine.trim()
if (line.isEmpty()) continue
when {
line.startsWith("#EXT-X-VERSION:") -> {
version = line.substringAfter(":").toIntOrNull()
}
line.startsWith("#EXT-X-TARGETDURATION:") -> {
targetDuration = line.substringAfter(":").toIntOrNull()
}
line.startsWith("#EXT-X-MEDIA-SEQUENCE:") -> {
mediaSequence = line.substringAfter(":").toLongOrNull()
}
line.startsWith("#EXT-X-DISCONTINUITY-SEQUENCE:") -> {
discontinuitySequence = line.substringAfter(":").toIntOrNull()
}
line.startsWith("#EXT-X-PROGRAM-DATE-TIME:") -> {
programDateTime = ZonedDateTime.parse(
line.substringAfter(":"),
DateTimeFormatter.ISO_DATE_TIME
)
}
line.startsWith("#EXT-X-PLAYLIST-TYPE:") -> {
playlistType = line.substringAfter(":")
}
line.startsWith("#EXT-X-STREAM-INF:") -> {
streamInfo = parseStreamInfo(line)
}
line.startsWith("#EXT-X-KEY:") -> {
val attrs = parseAttributes(line)
val method = attrs["METHOD"]?.ifEmpty { "AES-128" } ?: "AES-128"
val keyUri = attrs["URI"]?.removeSurrounding("\"")
val keyUrl = keyUri?.let { resolveUrl(baseUrl, it) }
val ivRaw = attrs["IV"]
val iv = ivRaw
?.removePrefix("0x")
?.removePrefix("0X")
val keyFormat = attrs["KEYFORMAT"]
val keyFormatVersions = attrs["KEYFORMATVERSIONS"]
decryptionInfo = DecryptionInfo(method, keyUrl, iv, keyFormat, keyFormatVersions)
}
line.startsWith("#EXT-X-MAP:") -> {
val attrs = parseAttributes(line)
attrs["URI"]?.let { uri ->
mapUrl = resolveUrl(baseUrl, uri)
}
attrs["BYTERANGE"]?.let { br ->
val (len, start) = parseByteRange(br)
mapBytesLength = len
mapBytesStart = start
}
}
line.startsWith("#EXTINF:") -> {
val duration = line.substringAfter(":").substringBefore(",").toDoubleOrNull()
?: throw Exception("Invalid segment duration format")
val durationText = line.substringAfter(":").substringBefore(",")
val duration = durationText.toDoubleOrNull()
?: throw IllegalArgumentException("Invalid segment duration: '$line'")
currentSegment = MediaSegment(duration = duration)
}
line == "#EXT-X-DISCONTINUITY" -> {
segments.add(DiscontinuitySegment())
}
line =="#EXT-X-ENDLIST" -> {
line == "#EXT-X-ENDLIST" -> {
segments.add(EndListSegment())
}
else -> {
currentSegment != null && line.startsWith("#EXT-X-BYTERANGE:") -> {
val br = line.substringAfter(":").trim()
val (len, start) = parseByteRange(br)
currentSegment!!.bytesLength = len
currentSegment!!.bytesStart = start
}
currentSegment != null && line.startsWith("#") -> {
currentSegment!!.unhandled.add(line)
}
!line.startsWith("#") -> {
currentSegment?.let {
it.uri = resolveUrl(sourceUrl, line)
it.uri = resolveUrl(baseUrl, line)
segments.add(it)
currentSegment = null
} ?: run {
unhandled.add(line)
}
currentSegment = null
}
else -> {
unhandled.add(line)
}
}
}
return VariantPlaylist(version, targetDuration, mediaSequence, discontinuitySequence, programDateTime, playlistType, streamInfo, segments, decryptionInfo)
return VariantPlaylist(
version = version,
targetDuration = targetDuration,
mediaSequence = mediaSequence,
discontinuitySequence = discontinuitySequence,
programDateTime = programDateTime,
playlistType = playlistType,
streamInfo = streamInfo,
segments = segments,
decryptionInfo = decryptionInfo,
mapUrl = mapUrl,
mapBytesStart = mapBytesStart,
mapBytesLength = mapBytesLength,
unhandled = unhandled
)
}
fun parseAndGetVideoSources(source: Any, content: String, url: String): List<HLSVariantVideoUrlSource> {
@@ -232,26 +374,6 @@ class HLS {
return SessionData(dataId, value)
}
private fun parseAttributes(content: String): Map<String, String> {
val attributes = mutableMapOf<String, String>()
val maybeAttributePairs = content.substringAfter(":").splitToSequence(',')
var currentPair = StringBuilder()
for (pair in maybeAttributePairs) {
currentPair.append(pair)
if (currentPair.count { it == '\"' } % 2 == 0) { // Check if the number of quotes is even
val key = currentPair.toString().substringBefore("=")
val value = currentPair.toString().substringAfter("=")
attributes[key.trim()] = value.trim().removeSurrounding("\"")
currentPair = StringBuilder() // Reset for the next attribute
} else {
currentPair.append(',') // Continue building the current attribute pair
}
}
return attributes
}
private val _quoteList = listOf("GROUP-ID", "NAME", "URI", "CODECS", "AUDIO", "VIDEO")
private fun shouldQuote(key: String, value: String?): Boolean {
if (value == null)
@@ -345,11 +467,22 @@ class HLS {
val variantPlaylistsRefs: List<VariantPlaylistReference>,
val mediaRenditions: List<MediaRendition>,
val sessionDataList: List<SessionData>,
val independentSegments: Boolean
val independentSegments: Boolean,
val version: Int? = null,
val mediaSequence: Long? = null,
val unhandled: List<String> = emptyList()
) {
fun buildM3U8(): String {
val builder = StringBuilder()
builder.append("#EXTM3U\n")
version?.let {
builder.append("#EXT-X-VERSION:$it\n")
}
mediaSequence?.let {
builder.append("#EXT-X-MEDIA-SEQUENCE:$it\n")
}
if (independentSegments) {
builder.append("#EXT-X-INDEPENDENT-SEGMENTS\n")
}
@@ -404,9 +537,15 @@ class HLS {
}
data class DecryptionInfo(
val keyUrl: String,
val iv: String?
)
val method: String,
val keyUrl: String?,
val iv: String?,
val keyFormat: String?,
val keyFormatVersions: String?
) {
val isEncrypted: Boolean
get() = !method.equals("NONE", ignoreCase = true)
}
data class VariantPlaylist(
val version: Int?,
@@ -417,7 +556,11 @@ class HLS {
val playlistType: String?,
val streamInfo: StreamInfo?,
val segments: List<Segment>,
val decryptionInfo: DecryptionInfo? = null
val decryptionInfo: DecryptionInfo? = null,
val mapUrl: String? = null,
val mapBytesStart: Long = -1,
val mapBytesLength: Long = -1,
val unhandled: List<String> = emptyList()
) {
fun buildM3U8(): String = buildString {
append("#EXTM3U\n")
@@ -426,9 +569,50 @@ 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.withZoneSameInstant(java.time.ZoneOffset.UTC)
.format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'"))
}\n"
)
}
streamInfo?.let { append(it.toM3U8Line()) }
decryptionInfo?.let { dec ->
val sb = StringBuilder()
sb.append("#EXT-X-KEY:METHOD=").append(dec.method)
if (!dec.method.equals("NONE", ignoreCase = true)) {
dec.keyUrl?.let { url ->
sb.append(",URI=\"").append(url).append("\"")
}
dec.iv?.let { iv ->
sb.append(",IV=0x").append(iv)
}
dec.keyFormat?.let { kf ->
sb.append(",KEYFORMAT=\"").append(kf).append("\"")
}
dec.keyFormatVersions?.let { kfv ->
sb.append(",KEYFORMATVERSIONS=\"").append(kfv).append("\"")
}
}
append(sb.append("\n").toString())
}
if (!mapUrl.isNullOrEmpty()) {
val sb = StringBuilder()
sb.append("#EXT-X-MAP:URI=\"").append(mapUrl).append("\"")
if (mapBytesLength > 0) {
if (mapBytesStart >= 0) {
sb.append(",BYTERANGE=\"").append(mapBytesLength)
.append("@").append(mapBytesStart).append("\"")
} else {
sb.append(",BYTERANGE=\"").append(mapBytesLength).append("\"")
}
}
append(sb.append("\n").toString())
}
segments.forEach { segment ->
append(segment.toM3U8Line())
}
@@ -439,13 +623,25 @@ class HLS {
abstract fun toM3U8Line(): String
}
data class MediaSegment (
data class MediaSegment(
val duration: Double,
var uri: String = ""
var uri: String = "",
var bytesStart: Long = -1,
var bytesLength: Long = -1,
val unhandled: MutableList<String> = mutableListOf()
) : Segment() {
override fun toM3U8Line(): String = buildString {
append("#EXTINF:${duration},\n")
append(uri + "\n")
if (bytesLength > 0) {
if (bytesStart >= 0) {
append("#EXT-X-BYTERANGE:${bytesLength}@${bytesStart}\n")
} else {
append("#EXT-X-BYTERANGE:${bytesLength}\n")
}
}
append(uri).append("\n")
}
}