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 b08ca38e..797ed8fc 100644 --- a/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt +++ b/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt @@ -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() - 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, 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() + + 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() + 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() + + 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 { 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 ba6cdaf4..93f04579 100644 --- a/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt +++ b/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt @@ -29,14 +29,25 @@ class HLS { val mediaRenditions = mutableListOf() val sessionDataList = mutableListOf() var independentSegments = false + var version: Int? = null + var mediaSequence: Long? = null + val unhandled = mutableListOf() - 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 { + 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 { + val index = content.indexOf(':') + if (index < 0 || index == content.length - 1) return emptyMap() + + val attributes = mutableMapOf() + 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() - if (initSegment != null) { - segments.add(MediaSegment(0.0, resolveUrl(sourceUrl, initSegment))) - } + val unhandled = mutableListOf() 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 { @@ -232,26 +374,6 @@ class HLS { return SessionData(dataId, value) } - private fun parseAttributes(content: String): Map { - val attributes = mutableMapOf() - 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, val mediaRenditions: List, val sessionDataList: List, - val independentSegments: Boolean + val independentSegments: Boolean, + val version: Int? = null, + val mediaSequence: Long? = null, + val unhandled: List = 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, - val decryptionInfo: DecryptionInfo? = null + val decryptionInfo: DecryptionInfo? = null, + val mapUrl: String? = null, + val mapBytesStart: Long = -1, + val mapBytesLength: Long = -1, + val unhandled: List = 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 = 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") } }