refactor(hls): upgrade to lightcompressor-enhanced 2.1.1-hls-SNAPSHOT

Collapses Amethyst's hand-rolled HLS orchestration onto the library's
HlsUploadHelper.run. The library now ships everything the prior session
had to reimplement: per-rendition width/height/codec metadata on the
onRenditionComplete callback, a public PlaylistRewriter, canonical
HlsContentTypes constants, and the transcode -> upload -> rewrite loop
itself.

- bump libs.versions.toml to 2.1.1-hls-SNAPSHOT
- delete HlsUploadPipeline, HlsBundle, HlsTranscoder, HlsTranscodingSession,
  HlsPlaylistRewriter and their tests; HlsUploadHelper.run + the library
  rewriter cover everything they did
- rewrite HlsVideoEventBuilder to consume HlsRenditionSummary width/height
  directly; drops the master-playlist streamInfRegex entirely
- HlsPublishOrchestrator now wraps HlsUploadHelper.run: a SimpleHlsListener
  drives Transcoding progress while the uploader lambda captures each
  MediaUploadResult in a side-channel map keyed by the library's
  suggestedFilename, so per-rendition sha256/size still flow into the
  NIP-71 imeta tags
- extract HlsBlobUploader into its own file (was inline in the deleted
  HlsUploadPipeline.kt)

Net delta on HLS code: 3194 -> 2182 lines (-1012, ~32%).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
davotoula
2026-04-15 09:07:11 +02:00
parent d58dd4bcce
commit 90235e91f2
14 changed files with 374 additions and 1386 deletions
@@ -20,17 +20,22 @@
*/ */
package com.vitorpamplona.amethyst.service.uploads.hls package com.vitorpamplona.amethyst.service.uploads.hls
import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult
import java.io.File import java.io.File
data class HlsBundle( /**
val workDir: File, * Abstraction over a blob upload transport so the HLS publish orchestrator can stay
val masterPlaylist: String, * unit-testable. Production wiring adapts this to either
val renditions: List<HlsBundleRendition>, * [com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader] or
) * [com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader]. The HLS orchestrator
* wraps this in a String-returning lambda for
data class HlsBundleRendition( * [com.davotoula.lightcompressor.hls.HlsUploadHelper.run] and captures each
val label: String, * [MediaUploadResult] in a side-channel map keyed by the library's suggested filename so the
val combinedFile: File, * per-rendition sha256/size can flow into the NIP-71 event's imeta tags.
val mediaPlaylist: String, */
val bitrateKbps: Int, fun interface HlsBlobUploader {
) suspend fun upload(
file: File,
contentType: String,
): MediaUploadResult
}
@@ -1,49 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.uploads.hls
object HlsPlaylistRewriter {
private val uriRegex = Regex("""URI="([^"]+)"""")
fun rewrite(
playlist: String,
urlMap: Map<String, String>,
): String =
playlist.lines().joinToString("\n") { line ->
when {
line.isBlank() -> line
line.startsWith("#") -> rewriteUriInDirective(line, urlMap)
else -> urlMap[line] ?: missing(line)
}
}
private fun rewriteUriInDirective(
line: String,
urlMap: Map<String, String>,
): String =
uriRegex.replace(line) { match ->
val original = match.groupValues[1]
val rewritten = urlMap[original] ?: missing(original)
"""URI="$rewritten""""
}
private fun missing(reference: String): Nothing = throw IllegalArgumentException("No uploaded URL for playlist reference: $reference")
}
@@ -1,74 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.uploads.hls
import android.content.Context
import android.net.Uri
import com.davotoula.lightcompressor.HlsPreparer
import com.davotoula.lightcompressor.VideoCodec
import com.davotoula.lightcompressor.hls.HlsConfig
import com.davotoula.lightcompressor.hls.HlsLadder
import kotlinx.coroutines.CancellationException
import java.io.File
/**
* Runs a full HLS preparation over the given source URI and returns the resulting [HlsBundle] once
* every rendition has been emitted, combined files moved into [workDir], and the master playlist
* received. Defaults to the library-provided [HlsConfig] (all five renditions of
* [com.davotoula.lightcompressor.hls.HlsLadder.default], single-file-per-rendition, 6s segments);
* the caller picks the video codec.
*
* Cancellation: if the caller's coroutine is cancelled while awaiting the bundle, we forward that
* cancellation to [HlsPreparer.cancel] so MediaCodec work stops. The underlying temp dir created by
* HlsPreparer is cleaned up by the library; the caller is responsible for cleaning up [workDir]
* after uploading is done.
*
* Not concurrent-safe: [HlsPreparer] is a process-wide singleton and only supports one preparation
* at a time. Overlapping calls will cancel the previous preparation.
*/
object HlsTranscoder {
suspend fun transcode(
context: Context,
uri: Uri,
workDir: File,
codec: VideoCodec,
ladder: HlsLadder = HlsLadder.default(),
onRenditionProgress: (label: String, percent: Int) -> Unit = { _, _ -> },
): HlsBundle {
workDir.mkdirs()
val session = HlsTranscodingSession(workDir, onRenditionProgress)
val config = HlsConfig(codec = codec, ladder = ladder)
HlsPreparer.start(
context = context,
uri = uri,
config = config,
listener = session,
)
return try {
session.terminal.await()
} catch (e: CancellationException) {
HlsPreparer.cancel()
throw e
}
}
}
@@ -1,113 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.uploads.hls
import com.davotoula.lightcompressor.hls.HlsError
import com.davotoula.lightcompressor.hls.HlsListener
import com.davotoula.lightcompressor.hls.HlsSegment
import com.davotoula.lightcompressor.hls.Rendition
import kotlinx.coroutines.CompletableDeferred
import java.io.File
import java.io.IOException
/**
* Accumulates HlsListener callbacks from a single-file-per-rendition HLS preparation into an
* [HlsBundle] that the upload pipeline can consume. The combined fMP4 files emitted by
* `onSegmentReady` are moved (renameTo, with copyTo fallback) to [workDir]/{label}.mp4 so the
* library's temp dir can be cleaned up and the bundle is self-contained.
*
* Terminal states are exposed via [terminal]: completes with [HlsBundle] on success, completes
* exceptionally on failure, is cancelled on user cancel.
*/
class HlsTranscodingSession(
private val workDir: File,
private val onRenditionProgress: (label: String, percent: Int) -> Unit = { _, _ -> },
) : HlsListener {
val terminal: CompletableDeferred<HlsBundle> = CompletableDeferred()
private val combinedByLabel = mutableMapOf<String, File>()
private val completed = mutableListOf<HlsBundleRendition>()
override fun onStart(renditionCount: Int) = Unit
override fun onRenditionStart(rendition: Rendition) = Unit
override fun onSegmentReady(
rendition: Rendition,
segment: HlsSegment,
) {
if (!segment.isCombinedRendition) return
val target = File(workDir, "${rendition.resolution.label}.mp4")
if (target.exists() && !target.delete()) {
throw IOException("Could not replace existing $target")
}
if (!segment.file.renameTo(target)) {
segment.file.copyTo(target, overwrite = true)
}
combinedByLabel[rendition.resolution.label] = target
}
override fun onRenditionComplete(
rendition: Rendition,
playlist: String,
) {
val combined =
combinedByLabel[rendition.resolution.label]
?: error("onRenditionComplete without prior onSegmentReady for ${rendition.resolution.label}")
completed +=
HlsBundleRendition(
label = rendition.resolution.label,
combinedFile = combined,
mediaPlaylist = playlist,
bitrateKbps = rendition.bitrateKbps,
)
}
override fun onComplete(masterPlaylist: String) {
terminal.complete(
HlsBundle(
workDir = workDir,
masterPlaylist = masterPlaylist,
renditions = completed.toList(),
),
)
}
override fun onFailure(error: HlsError) {
terminal.completeExceptionally(HlsTranscodingException(error.message))
}
override fun onCancelled() {
terminal.cancel()
}
override fun onProgress(
rendition: Rendition,
percent: Float,
) {
onRenditionProgress(rendition.resolution.label, percent.toInt())
}
}
class HlsTranscodingException(
message: String,
) : RuntimeException(message)
@@ -1,133 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.uploads.hls
import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult
import java.io.File
/**
* Abstraction over a blob upload transport so [HlsUploadPipeline] can stay unit-testable.
* Production wiring adapts this to either [com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader]
* or [com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader].
*/
fun interface HlsBlobUploader {
suspend fun upload(
file: File,
contentType: String,
): MediaUploadResult
}
data class HlsUploadResult(
val masterUrl: String,
val masterSha256: String?,
val renditions: List<HlsUploadedRendition>,
)
data class HlsUploadedRendition(
val label: String,
val combinedUrl: String,
val combinedSha256: String?,
val combinedSize: Long?,
val playlistUrl: String,
val bitrateKbps: Int,
)
/**
* Orchestrates the upload half of the HLS publish pipeline. For each rendition:
* 1. uploads the combined fMP4 file,
* 2. rewrites the media playlist so its byterange entries point at the uploaded blob URL,
* 3. uploads the rewritten playlist.
* Finally rewrites the master playlist to reference the per-rendition playlist URLs and uploads
* the master. The resulting [HlsUploadResult] is what the publisher uses to build the NIP-71
* event.
*
* URL handling policy: the pipeline uses the URL the server returned verbatim. No extension is
* appended, no trailing dot stripped, no bare-hash rewriting. The server is responsible for
* returning a URL that the player can fetch as-is — that way we get the best cache coherence,
* correct Content-Type, clean range requests, and no double round trips. If a server returns an
* unplayable URL, the fix is server-side.
*/
class HlsUploadPipeline(
private val uploader: HlsBlobUploader,
) {
suspend fun upload(
bundle: HlsBundle,
onProgress: (done: Int, total: Int, currentLabel: String) -> Unit = { _, _, _ -> },
): HlsUploadResult {
val playlistDir = File(bundle.workDir, "playlists").apply { mkdirs() }
val total = bundle.renditions.size * 2 + 1
var done = 0
val uploadedRenditions =
bundle.renditions.map { rendition ->
onProgress(done, total, "${rendition.label} video")
val combined = uploader.upload(rendition.combinedFile, CONTENT_TYPE_VIDEO_MP4)
done++
val combinedUrl =
combined.url ?: error("Uploader returned null URL for ${rendition.combinedFile.name}")
val rewrittenMedia =
HlsPlaylistRewriter.rewrite(
rendition.mediaPlaylist,
mapOf("${rendition.label}.mp4" to combinedUrl),
)
val mediaPlaylistFile =
File(playlistDir, "${rendition.label}-media.m3u8").apply { writeText(rewrittenMedia) }
onProgress(done, total, "${rendition.label} playlist")
val mediaPlaylist = uploader.upload(mediaPlaylistFile, CONTENT_TYPE_HLS)
done++
val mediaPlaylistUrl =
mediaPlaylist.url ?: error("Uploader returned null URL for media playlist ${rendition.label}")
HlsUploadedRendition(
label = rendition.label,
combinedUrl = combinedUrl,
combinedSha256 = combined.sha256,
combinedSize = combined.size,
playlistUrl = mediaPlaylistUrl,
bitrateKbps = rendition.bitrateKbps,
)
}
val masterUrlMap =
uploadedRenditions.associate { "${it.label}/media.m3u8" to it.playlistUrl }
val rewrittenMaster = HlsPlaylistRewriter.rewrite(bundle.masterPlaylist, masterUrlMap)
val masterFile = File(playlistDir, "master.m3u8").apply { writeText(rewrittenMaster) }
onProgress(done, total, "master playlist")
val master = uploader.upload(masterFile, CONTENT_TYPE_HLS)
done++
val masterUrl =
master.url ?: error("Uploader returned null URL for master playlist")
onProgress(done, total, "")
return HlsUploadResult(
masterUrl = masterUrl,
masterSha256 = master.sha256,
renditions = uploadedRenditions,
)
}
companion object {
const val CONTENT_TYPE_VIDEO_MP4 = "video/mp4"
const val CONTENT_TYPE_HLS = "application/vnd.apple.mpegurl"
}
}
@@ -20,7 +20,9 @@
*/ */
package com.vitorpamplona.amethyst.service.uploads.hls package com.vitorpamplona.amethyst.service.uploads.hls
import com.vitorpamplona.amethyst.service.uploads.hls.HlsUploadPipeline.Companion.CONTENT_TYPE_HLS import com.davotoula.lightcompressor.hls.HlsContentTypes
import com.davotoula.lightcompressor.hls.HlsRenditionSummary
import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning
import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent
@@ -35,8 +37,10 @@ import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid import kotlin.uuid.Uuid
data class HlsVideoPublishInput( data class HlsVideoPublishInput(
val bundle: HlsBundle, val renditions: List<HlsRenditionSummary>,
val uploadResult: HlsUploadResult, val segmentUploads: Map<String, MediaUploadResult>,
val masterUrl: String,
val masterSha256: String?,
val title: String, val title: String,
val description: String, val description: String,
val alt: String? = null, val alt: String? = null,
@@ -58,42 +62,52 @@ sealed class HlsVideoEventTemplate {
/** /**
* Assembles a NIP-71 VideoHorizontalEvent / VideoVerticalEvent template from an HLS upload * Assembles a NIP-71 VideoHorizontalEvent / VideoVerticalEvent template from an HLS upload
* result. Orientation is decided from the first `#EXT-X-STREAM-INF RESOLUTION` in the bundle's * result. Orientation is decided from the first rendition's width/height: portrait
* master playlist: portrait (height > width) selects kind 34236, otherwise 34235. * (height > width) selects kind 34236, otherwise 34235.
* *
* The template carries one `imeta` tag for the master playlist (primary) plus one per rendition * The template carries one `imeta` tag for the master playlist (primary) plus one per rendition
* so HLS-unaware clients can still pick a specific variant. Every imeta is marked * so HLS-unaware clients can still pick a specific variant. Every imeta is marked
* `m application/vnd.apple.mpegurl`. * `m application/vnd.apple.mpegurl`. The rendition imeta's `x`/`size` come from the combined
* fMP4 upload (single-file layout) while the `url` points at the rewritten media playlist.
* *
* Returns the unsigned template wrapped in a sealed [HlsVideoEventTemplate]; the caller signs * Returns the unsigned template wrapped in a sealed [HlsVideoEventTemplate]; the caller signs
* via the account's signer and publishes via the relay client. * via the account's signer and publishes via the relay client.
*/ */
@OptIn(ExperimentalUuidApi::class) @OptIn(ExperimentalUuidApi::class)
object HlsVideoEventBuilder { object HlsVideoEventBuilder {
private val streamInfRegex = Regex("""#EXT-X-STREAM-INF:[^\n]*RESOLUTION=(\d+)x(\d+)""")
fun build(input: HlsVideoPublishInput): HlsVideoEventTemplate { fun build(input: HlsVideoPublishInput): HlsVideoEventTemplate {
val renditionDimensions = parseRenditionDimensions(input.bundle.masterPlaylist) val firstRendition = input.renditions.firstOrNull()
val isVertical = renditionDimensions.firstOrNull()?.let { it.height > it.width } ?: false val isVertical = firstRendition != null && firstRendition.height > firstRendition.width
val masterDimension = renditionDimensions.maxByOrNull { it.width * it.height }?.toDimensionTag() val largest = input.renditions.maxByOrNull { it.width * it.height }
val masterDimension = largest?.let { DimensionTag(it.width, it.height) }
val masterVideoMeta = val masterVideoMeta =
VideoMeta( VideoMeta(
url = input.uploadResult.masterUrl, url = input.masterUrl,
mimeType = CONTENT_TYPE_HLS, mimeType = HlsContentTypes.HLS_PLAYLIST,
hash = input.uploadResult.masterSha256, hash = input.masterSha256,
dimension = masterDimension, dimension = masterDimension,
alt = input.alt, alt = input.alt,
) )
val renditionMetas = val renditionMetas =
input.uploadResult.renditions.mapIndexed { index, uploaded -> input.renditions.map { summary ->
val combinedFilename =
summary.combinedFilename
?: "${summary.rendition.resolution.label}.mp4"
val combinedUpload = input.segmentUploads[combinedFilename]
val playlistUpload =
input.segmentUploads[summary.playlistFilename]
?: error("No upload recorded for media playlist ${summary.playlistFilename}")
VideoMeta( VideoMeta(
url = uploaded.playlistUrl, url =
mimeType = CONTENT_TYPE_HLS, playlistUpload.url
hash = uploaded.combinedSha256, ?: error("Uploader returned null URL for media playlist ${summary.playlistFilename}"),
size = uploaded.combinedSize?.toInt(), mimeType = HlsContentTypes.HLS_PLAYLIST,
dimension = renditionDimensions.getOrNull(index)?.toDimensionTag(), hash = combinedUpload?.sha256,
size = combinedUpload?.size?.toInt(),
dimension = DimensionTag(summary.width, summary.height),
) )
} }
@@ -121,17 +135,4 @@ object HlsVideoEventBuilder {
) )
} }
} }
private data class RenditionDimension(
val width: Int,
val height: Int,
) {
fun toDimensionTag(): DimensionTag = DimensionTag(width, height)
}
private fun parseRenditionDimensions(masterPlaylist: String): List<RenditionDimension> =
streamInfRegex
.findAll(masterPlaylist)
.map { RenditionDimension(it.groupValues[1].toInt(), it.groupValues[2].toInt()) }
.toList()
} }
@@ -21,10 +21,17 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.video.hls package com.vitorpamplona.amethyst.ui.screen.loggedIn.video.hls
import com.davotoula.lightcompressor.VideoCodec import com.davotoula.lightcompressor.VideoCodec
import com.davotoula.lightcompressor.hls.HlsConfig
import com.davotoula.lightcompressor.hls.HlsContentTypes
import com.davotoula.lightcompressor.hls.HlsLadder import com.davotoula.lightcompressor.hls.HlsLadder
import com.davotoula.lightcompressor.hls.HlsListener
import com.davotoula.lightcompressor.hls.HlsRenditionSummary
import com.davotoula.lightcompressor.hls.HlsSegment
import com.davotoula.lightcompressor.hls.HlsUploadResult
import com.davotoula.lightcompressor.hls.Rendition
import com.davotoula.lightcompressor.hls.SimpleHlsListener
import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult
import com.vitorpamplona.amethyst.service.uploads.hls.HlsBlobUploader import com.vitorpamplona.amethyst.service.uploads.hls.HlsBlobUploader
import com.vitorpamplona.amethyst.service.uploads.hls.HlsBundle
import com.vitorpamplona.amethyst.service.uploads.hls.HlsUploadPipeline
import com.vitorpamplona.amethyst.service.uploads.hls.HlsVideoEventBuilder import com.vitorpamplona.amethyst.service.uploads.hls.HlsVideoEventBuilder
import com.vitorpamplona.amethyst.service.uploads.hls.HlsVideoEventTemplate import com.vitorpamplona.amethyst.service.uploads.hls.HlsVideoEventTemplate
import com.vitorpamplona.amethyst.service.uploads.hls.HlsVideoPublishInput import com.vitorpamplona.amethyst.service.uploads.hls.HlsVideoPublishInput
@@ -47,50 +54,115 @@ data class HlsPublishRequest(
/** /**
* Orchestrates the transcode → upload → build → publish pipeline for a single HLS video publish. * Orchestrates the transcode → upload → build → publish pipeline for a single HLS video publish.
* Delegates transcoding and segment/media-playlist upload plumbing to the library's
* [com.davotoula.lightcompressor.hls.HlsUploadHelper] via the injected [runUpload] closure, then
* uploads the rewritten master playlist itself, builds the NIP-71 event, signs, and publishes.
* All Android/account-specific concerns are injected as suspending callbacks so the whole state * All Android/account-specific concerns are injected as suspending callbacks so the whole state
* machine is unit-testable. * machine is unit-testable.
* *
* State transitions: Idle → Transcoding → Uploading → Publishing → Success, or → Failure on any * State transitions: Idle → Transcoding (per rendition, driven by listener) → Uploading (per
* exception. The [state] flow emits each transition as it happens so the UI can reflect progress. * segment/playlist upload, driven by the uploader lambda) → Publishing → Success, or → Failure
* on any exception.
*/ */
class HlsPublishOrchestrator( class HlsPublishOrchestrator(
private val _state: MutableStateFlow<HlsPublishState>, private val _state: MutableStateFlow<HlsPublishState>,
private val runTranscode: suspend ( private val runUpload: suspend (
workDir: File, config: HlsConfig,
codec: VideoCodec, listener: HlsListener,
ladder: HlsLadder, uploadFile: suspend (File, String) -> String,
onProgress: (label: String, percent: Int) -> Unit, ) -> HlsUploadResult,
) -> HlsBundle,
private val buildUploader: (ServerName) -> HlsBlobUploader, private val buildUploader: (ServerName) -> HlsBlobUploader,
private val uploadMaster: suspend (HlsBlobUploader, String) -> MediaUploadResult,
private val signAndPublish: suspend (HlsVideoEventTemplate) -> String, private val signAndPublish: suspend (HlsVideoEventTemplate) -> String,
private val workDirFactory: () -> File,
) { ) {
val state: StateFlow<HlsPublishState> = _state val state: StateFlow<HlsPublishState> = _state
suspend fun publish(request: HlsPublishRequest) { suspend fun publish(request: HlsPublishRequest) {
val workDir = workDirFactory()
try { try {
_state.value = HlsPublishState.Transcoding(currentLabel = "", percent = 0) _state.value = HlsPublishState.Transcoding(currentLabel = "", percent = 0)
val bundle =
runTranscode(workDir, request.codec, request.ladder) { label, percent -> val uploader = buildUploader(request.server)
_state.value = HlsPublishState.Transcoding(label, percent) val config =
HlsConfig(
codec = request.codec,
ladder = request.ladder,
)
val totalSegmentUploads = request.ladder.renditions.size * 2
val totalUploads = totalSegmentUploads + 1
var uploadsDone = 0
val segmentUploads = mutableMapOf<String, MediaUploadResult>()
val listener =
object : SimpleHlsListener() {
override fun onRenditionStart(rendition: Rendition) {
if (_state.value !is HlsPublishState.Uploading) {
_state.value = HlsPublishState.Transcoding(rendition.resolution.label, 0)
}
}
override fun onProgress(
rendition: Rendition,
percent: Float,
) {
_state.value = HlsPublishState.Transcoding(rendition.resolution.label, percent.toInt())
}
override fun onSegmentReady(
rendition: Rendition,
segment: HlsSegment,
) {
// The uploader lambda runs synchronously inside onSegmentReady; keep the
// progress overlay on "transcoding" here because the state update from
// the lambda itself will flip us into Uploading at upload time.
}
override fun onRenditionComplete(
rendition: Rendition,
summary: HlsRenditionSummary,
) = Unit
} }
val uploadTotal = bundle.renditions.size * 2 + 1
_state.value = HlsPublishState.Uploading(done = 0, total = uploadTotal)
val uploader = buildUploader(request.server)
val pipeline = HlsUploadPipeline(uploader)
val uploadResult = val uploadResult =
pipeline.upload(bundle) { done, total, label -> runUpload(config, listener) { file, suggestedFilename ->
_state.value = HlsPublishState.Uploading(done, total, label) _state.value =
HlsPublishState.Uploading(
done = uploadsDone,
total = totalUploads,
currentLabel = suggestedFilename,
)
val contentType =
if (suggestedFilename.endsWith(".m3u8")) {
HlsContentTypes.forPlaylist()
} else {
HlsContentTypes.FMP4_SEGMENT
}
val result = uploader.upload(file, contentType)
uploadsDone++
segmentUploads[suggestedFilename] = result
result.url
?: error("Uploader returned null URL for $suggestedFilename")
} }
_state.value =
HlsPublishState.Uploading(
done = uploadsDone,
total = totalUploads,
currentLabel = "master.m3u8",
)
val masterUpload = uploadMaster(uploader, uploadResult.masterPlaylist)
uploadsDone++
val masterUrl =
masterUpload.url ?: error("Uploader returned null URL for master playlist")
_state.value = HlsPublishState.Publishing _state.value = HlsPublishState.Publishing
val template = val template =
HlsVideoEventBuilder.build( HlsVideoEventBuilder.build(
HlsVideoPublishInput( HlsVideoPublishInput(
bundle = bundle, renditions = uploadResult.renditions,
uploadResult = uploadResult, segmentUploads = segmentUploads,
masterUrl = masterUrl,
masterSha256 = masterUpload.sha256,
title = request.title, title = request.title,
description = request.description, description = request.description,
durationSeconds = request.durationSeconds, durationSeconds = request.durationSeconds,
@@ -102,7 +174,7 @@ class HlsPublishOrchestrator(
_state.value = _state.value =
HlsPublishState.Success( HlsPublishState.Success(
eventId = eventId, eventId = eventId,
masterUrl = uploadResult.masterUrl, masterUrl = masterUrl,
) )
} catch (e: CancellationException) { } catch (e: CancellationException) {
_state.value = HlsPublishState.Failure(message = "Cancelled") _state.value = HlsPublishState.Failure(message = "Cancelled")
@@ -22,19 +22,20 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.video.hls
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import com.davotoula.lightcompressor.hls.HlsContentTypes
import com.davotoula.lightcompressor.hls.HlsUploadHelper
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.uploads.hls.HlsBlobUploaderFactory import com.vitorpamplona.amethyst.service.uploads.hls.HlsBlobUploaderFactory
import com.vitorpamplona.amethyst.service.uploads.hls.HlsTranscoder
import com.vitorpamplona.amethyst.service.uploads.hls.HlsVideoEventTemplate import com.vitorpamplona.amethyst.service.uploads.hls.HlsVideoEventTemplate
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import java.io.File import java.io.File
/** /**
* Production wiring for [HlsPublishOrchestrator]. Binds the transcode to [HlsTranscoder], the * Production wiring for [HlsPublishOrchestrator]. Binds the upload closure to
* uploader factory to [HlsBlobUploaderFactory], and the signAndPublish closure to the account's * [HlsUploadHelper.run], the uploader factory to [HlsBlobUploaderFactory], and the
* signer + outbox publish path. * signAndPublish closure to the account's signer + outbox publish path.
* *
* The Uri is read via [uriProvider] on each transcode invocation so the orchestrator can be built * The Uri is read via [uriProvider] on each publish invocation so the orchestrator can be built
* once (at VM load) before the user actually picks a video. * once (at VM load) before the user actually picks a video.
*/ */
fun createProductionHlsPublishOrchestrator( fun createProductionHlsPublishOrchestrator(
@@ -45,20 +46,29 @@ fun createProductionHlsPublishOrchestrator(
): HlsPublishOrchestrator = ): HlsPublishOrchestrator =
HlsPublishOrchestrator( HlsPublishOrchestrator(
_state = state, _state = state,
runTranscode = { workDir, codec, ladder, onProgress -> runUpload = { config, listener, uploadFile ->
val uri = uriProvider() ?: error("No video picked") val uri = uriProvider() ?: error("No video picked")
HlsTranscoder.transcode( HlsUploadHelper.run(
context = context, context = context,
uri = uri, uri = uri,
workDir = workDir, config = config,
codec = codec, listener = listener,
ladder = ladder, uploader = uploadFile,
onRenditionProgress = onProgress,
) )
}, },
buildUploader = { server -> buildUploader = { server ->
HlsBlobUploaderFactory.create(server, account, context) HlsBlobUploaderFactory.create(server, account, context)
}, },
uploadMaster = { uploader, masterPlaylist ->
val masterFile =
File(context.cacheDir, "hls-master-${System.currentTimeMillis()}.m3u8")
try {
masterFile.writeText(masterPlaylist)
uploader.upload(masterFile, HlsContentTypes.forPlaylist())
} finally {
masterFile.delete()
}
},
signAndPublish = { template -> signAndPublish = { template ->
val signed = val signed =
when (template) { when (template) {
@@ -68,7 +78,4 @@ fun createProductionHlsPublishOrchestrator(
account.sendAutomatic(signed) account.sendAutomatic(signed)
signed.id signed.id
}, },
workDirFactory = {
File(context.cacheDir, "hls-${System.currentTimeMillis()}").apply { mkdirs() }
},
) )
@@ -1,284 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.uploads.hls
import org.junit.Assert.assertEquals
import org.junit.Assert.assertThrows
import org.junit.Test
class HlsPlaylistRewriterTest {
@Test
fun rewritesSegmentReferencesInMediaPlaylist() {
val playlist =
"""
#EXTM3U
#EXT-X-VERSION:7
#EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:4.000,
segment_000.m4s
#EXTINF:4.000,
segment_001.m4s
#EXT-X-ENDLIST
""".trimIndent()
val urlMap =
mapOf(
"segment_000.m4s" to "https://cdn.example.com/abc.m4s",
"segment_001.m4s" to "https://cdn.example.com/def.m4s",
)
val rewritten = HlsPlaylistRewriter.rewrite(playlist, urlMap)
val expected =
"""
#EXTM3U
#EXT-X-VERSION:7
#EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:4.000,
https://cdn.example.com/abc.m4s
#EXTINF:4.000,
https://cdn.example.com/def.m4s
#EXT-X-ENDLIST
""".trimIndent()
assertEquals(expected, rewritten)
}
@Test
fun preservesExtInfLinesExactly() {
val playlist =
"""
#EXTINF:3.9836,
segment_000.m4s
""".trimIndent()
val rewritten =
HlsPlaylistRewriter.rewrite(
playlist,
mapOf("segment_000.m4s" to "https://cdn/x.m4s"),
)
assertEquals(
"#EXTINF:3.9836,\nhttps://cdn/x.m4s",
rewritten,
)
}
@Test
fun rewritesExtXMapUri() {
val playlist =
"""
#EXTM3U
#EXT-X-MAP:URI="init.mp4"
#EXTINF:4.000,
segment_000.m4s
""".trimIndent()
val urlMap =
mapOf(
"init.mp4" to "https://cdn/init-abc.mp4",
"segment_000.m4s" to "https://cdn/seg-def.m4s",
)
val rewritten = HlsPlaylistRewriter.rewrite(playlist, urlMap)
val expected =
"""
#EXTM3U
#EXT-X-MAP:URI="https://cdn/init-abc.mp4"
#EXTINF:4.000,
https://cdn/seg-def.m4s
""".trimIndent()
assertEquals(expected, rewritten)
}
@Test
fun extXMapPreservesAdditionalAttributes() {
val playlist = """#EXT-X-MAP:URI="init.mp4",BYTERANGE="718@0""""
val rewritten =
HlsPlaylistRewriter.rewrite(
playlist,
mapOf("init.mp4" to "https://cdn/abc.mp4"),
)
assertEquals(
"""#EXT-X-MAP:URI="https://cdn/abc.mp4",BYTERANGE="718@0"""",
rewritten,
)
}
@Test
fun rewritesVariantsInMasterPlaylist() {
val playlist =
"""
#EXTM3U
#EXT-X-VERSION:7
#EXT-X-STREAM-INF:BANDWIDTH=800000,RESOLUTION=640x360,CODECS="avc1.64001e,mp4a.40.2"
360p/media.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=2400000,RESOLUTION=1280x720,CODECS="avc1.64001f,mp4a.40.2"
720p/media.m3u8
""".trimIndent()
val urlMap =
mapOf(
"360p/media.m3u8" to "https://cdn/360.m3u8",
"720p/media.m3u8" to "https://cdn/720.m3u8",
)
val rewritten = HlsPlaylistRewriter.rewrite(playlist, urlMap)
val expected =
"""
#EXTM3U
#EXT-X-VERSION:7
#EXT-X-STREAM-INF:BANDWIDTH=800000,RESOLUTION=640x360,CODECS="avc1.64001e,mp4a.40.2"
https://cdn/360.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=2400000,RESOLUTION=1280x720,CODECS="avc1.64001f,mp4a.40.2"
https://cdn/720.m3u8
""".trimIndent()
assertEquals(expected, rewritten)
}
@Test
fun preservesExtXStreamInfLinesExactly() {
val playlist =
"""
#EXT-X-STREAM-INF:BANDWIDTH=800000,RESOLUTION=640x360,CODECS="avc1.64001e,mp4a.40.2"
360p/media.m3u8
""".trimIndent()
val rewritten =
HlsPlaylistRewriter.rewrite(
playlist,
mapOf("360p/media.m3u8" to "https://cdn/360.m3u8"),
)
val expected =
"""
#EXT-X-STREAM-INF:BANDWIDTH=800000,RESOLUTION=640x360,CODECS="avc1.64001e,mp4a.40.2"
https://cdn/360.m3u8
""".trimIndent()
assertEquals(expected, rewritten)
}
@Test
fun leavesBlankLinesAndCommentsUnchanged() {
val playlist =
"""
#EXTM3U
# this is a comment
#EXT-X-VERSION:7
#EXTINF:4.000,
segment_000.m4s
""".trimIndent()
val rewritten =
HlsPlaylistRewriter.rewrite(
playlist,
mapOf("segment_000.m4s" to "https://cdn/x.m4s"),
)
val expected =
"""
#EXTM3U
# this is a comment
#EXT-X-VERSION:7
#EXTINF:4.000,
https://cdn/x.m4s
""".trimIndent()
assertEquals(expected, rewritten)
}
@Test
fun rewritesSingleFileByterangePlaylist() {
// Matches PlaylistGenerator.buildByteRangeMediaPlaylist output when HlsConfig
// singleFilePerRendition=true (the default). All segment references point to
// the same combined fMP4 file; #EXT-X-BYTERANGE lines must be preserved.
val playlist =
"""
#EXTM3U
#EXT-X-VERSION:7
#EXT-X-TARGETDURATION:6
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-PLAYLIST-TYPE:VOD
#EXT-X-MAP:URI="360p.mp4",BYTERANGE="1234@0"
#EXTINF:6.000,
#EXT-X-BYTERANGE:500000@1234
360p.mp4
#EXTINF:6.000,
#EXT-X-BYTERANGE:480000@501234
360p.mp4
#EXT-X-ENDLIST
""".trimIndent()
val rewritten =
HlsPlaylistRewriter.rewrite(
playlist,
mapOf("360p.mp4" to "https://cdn/abc.mp4"),
)
val expected =
"""
#EXTM3U
#EXT-X-VERSION:7
#EXT-X-TARGETDURATION:6
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-PLAYLIST-TYPE:VOD
#EXT-X-MAP:URI="https://cdn/abc.mp4",BYTERANGE="1234@0"
#EXTINF:6.000,
#EXT-X-BYTERANGE:500000@1234
https://cdn/abc.mp4
#EXTINF:6.000,
#EXT-X-BYTERANGE:480000@501234
https://cdn/abc.mp4
#EXT-X-ENDLIST
""".trimIndent()
assertEquals(expected, rewritten)
}
@Test
fun throwsWhenSegmentReferenceIsMissingFromUrlMap() {
val playlist =
"""
#EXTINF:4.000,
segment_000.m4s
""".trimIndent()
val ex =
assertThrows(IllegalArgumentException::class.java) {
HlsPlaylistRewriter.rewrite(playlist, emptyMap())
}
assertEquals("No uploaded URL for playlist reference: segment_000.m4s", ex.message)
}
}
@@ -20,7 +20,12 @@
*/ */
package com.vitorpamplona.amethyst.service.uploads.hls package com.vitorpamplona.amethyst.service.uploads.hls
import com.davotoula.lightcompressor.Resolution
import com.davotoula.lightcompressor.VideoCodec import com.davotoula.lightcompressor.VideoCodec
import com.davotoula.lightcompressor.hls.HlsLadder
import com.davotoula.lightcompressor.hls.HlsRenditionSummary
import com.davotoula.lightcompressor.hls.HlsUploadResult
import com.davotoula.lightcompressor.hls.Rendition
import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
@@ -53,33 +58,48 @@ class HlsPublishOrchestratorTest {
workDir.deleteRecursively() workDir.deleteRecursively()
} }
private fun fakeBundle(labels: List<String> = listOf("360p")): HlsBundle { private fun landscapeSummary(
val renditions = resolution: Resolution = Resolution.SD_360,
labels.map { label -> width: Int = 640,
val file = File(workDir, "$label.mp4").apply { writeText("bytes-$label") } height: Int = 360,
HlsBundleRendition( ): HlsRenditionSummary =
label = label, HlsRenditionSummary(
combinedFile = file, rendition = Rendition(resolution, bitrateKbps = 500),
mediaPlaylist = mediaPlaylist = "",
""" playlistFilename = "${resolution.label}/media.m3u8",
#EXTM3U width = width,
#EXT-X-MAP:URI="$label.mp4",BYTERANGE="100@0" height = height,
#EXTINF:6.0, codecString = "avc1.64001f",
$label.mp4 combinedFilename = "${resolution.label}.mp4",
""".trimIndent(), )
bitrateKbps = 500,
) /**
* Simulates an HlsUploadHelper run: it calls [uploadFile] once per segment (combined .mp4)
* and once per media playlist, in the same order the real helper would, then returns a
* fake [HlsUploadResult] with the supplied rendition summaries.
*/
private fun fakeRunUpload(renditions: List<HlsRenditionSummary> = listOf(landscapeSummary())): suspend (
config: com.davotoula.lightcompressor.hls.HlsConfig,
listener: com.davotoula.lightcompressor.hls.HlsListener,
uploadFile: suspend (File, String) -> String,
) -> HlsUploadResult =
{ _, listener, uploadFile ->
listener.onStart(renditions.size)
for (summary in renditions) {
listener.onRenditionStart(summary.rendition)
listener.onProgress(summary.rendition, 50f)
val combinedFile = File(workDir, summary.combinedFilename!!).apply { writeText("combined-${summary.rendition.resolution.label}") }
uploadFile(combinedFile, summary.combinedFilename!!)
val playlistFile =
File(workDir, "${summary.rendition.resolution.label}-media.m3u8").apply { writeText("playlist-${summary.rendition.resolution.label}") }
uploadFile(playlistFile, summary.playlistFilename)
} }
val master = listener.onComplete("#EXTM3U\nfake-master-playlist")
buildString { HlsUploadResult(
appendLine("#EXTM3U") masterPlaylist = "#EXTM3U\nrewritten-master-playlist",
labels.forEachIndexed { i, label -> renditions = renditions,
appendLine("#EXT-X-STREAM-INF:BANDWIDTH=${(i + 1) * 500000},RESOLUTION=${640 + i * 320}x${360 + i * 180}") )
appendLine("$label/media.m3u8") }
}
}
return HlsBundle(workDir, master, renditions)
}
private class CannedUploader : HlsBlobUploader { private class CannedUploader : HlsBlobUploader {
var count = 0 var count = 0
@@ -93,11 +113,22 @@ class HlsPublishOrchestratorTest {
} }
} }
private fun fakeUploadMaster(uploader: HlsBlobUploader): suspend (HlsBlobUploader, String) -> MediaUploadResult =
{ _, masterPlaylist ->
val tmp = File(workDir, "master-${System.nanoTime()}.m3u8").apply { writeText(masterPlaylist) }
try {
uploader.upload(tmp, "application/vnd.apple.mpegurl")
} finally {
tmp.delete()
}
}
private fun newRequest( private fun newRequest(
title: String = "My HD Clip", title: String = "My HD Clip",
description: String = "A test clip", description: String = "A test clip",
sensitive: Boolean = false, sensitive: Boolean = false,
warningReason: String = "", warningReason: String = "",
ladder: HlsLadder = HlsLadder(listOf(Rendition(Resolution.SD_360, 500))),
) = HlsPublishRequest( ) = HlsPublishRequest(
title = title, title = title,
description = description, description = description,
@@ -105,21 +136,23 @@ class HlsPublishOrchestratorTest {
contentWarningReason = warningReason, contentWarningReason = warningReason,
codec = VideoCodec.H265, codec = VideoCodec.H265,
server = server, server = server,
ladder = ladder,
) )
@Test @Test
fun happyPathEndsInSuccessWithMasterUrlAndEventId() { fun happyPathEndsInSuccessWithMasterUrlAndEventId() {
val publishedTemplates = mutableListOf<HlsVideoEventTemplate>() val publishedTemplates = mutableListOf<HlsVideoEventTemplate>()
val canned = CannedUploader()
val orchestrator = val orchestrator =
HlsPublishOrchestrator( HlsPublishOrchestrator(
_state = MutableStateFlow(HlsPublishState.Idle), _state = MutableStateFlow(HlsPublishState.Idle),
runTranscode = { _, _, _, _ -> fakeBundle() }, runUpload = fakeRunUpload(),
buildUploader = { CannedUploader() }, buildUploader = { canned },
uploadMaster = fakeUploadMaster(canned),
signAndPublish = { tpl -> signAndPublish = { tpl ->
publishedTemplates += tpl publishedTemplates += tpl
"signed-event-id" "signed-event-id"
}, },
workDirFactory = { File(workDir, "work").apply { mkdirs() } },
) )
runBlocking { orchestrator.publish(newRequest()) } runBlocking { orchestrator.publish(newRequest()) }
@@ -144,24 +177,40 @@ class HlsPublishOrchestratorTest {
val capturedDuringUpload = mutableListOf<HlsPublishState>() val capturedDuringUpload = mutableListOf<HlsPublishState>()
val capturedDuringPublish = mutableListOf<HlsPublishState>() val capturedDuringPublish = mutableListOf<HlsPublishState>()
val canned = CannedUploader()
val capturingRunUpload: suspend (
com.davotoula.lightcompressor.hls.HlsConfig,
com.davotoula.lightcompressor.hls.HlsListener,
suspend (File, String) -> String,
) -> HlsUploadResult = { _, listener, uploadFile ->
val summary = landscapeSummary()
listener.onStart(1)
listener.onRenditionStart(summary.rendition)
capturedDuringTranscode += orchestrator.state.value
listener.onProgress(summary.rendition, 42f)
capturedDuringTranscode += orchestrator.state.value
val combinedFile = File(workDir, "360p.mp4").apply { writeText("bytes") }
uploadFile(combinedFile, "360p.mp4")
capturedDuringUpload += orchestrator.state.value
val playlistFile = File(workDir, "360p-media.m3u8").apply { writeText("bytes") }
uploadFile(playlistFile, "360p/media.m3u8")
listener.onComplete("#EXTM3U\nmaster")
HlsUploadResult(
masterPlaylist = "#EXTM3U\nrewritten",
renditions = listOf(summary),
)
}
orchestrator = orchestrator =
HlsPublishOrchestrator( HlsPublishOrchestrator(
_state = MutableStateFlow(HlsPublishState.Idle), _state = MutableStateFlow(HlsPublishState.Idle),
runTranscode = { _, _, _, onProgress -> runUpload = capturingRunUpload,
capturedDuringTranscode += orchestrator.state.value buildUploader = { canned },
onProgress("360p", 42) uploadMaster = fakeUploadMaster(canned),
capturedDuringTranscode += orchestrator.state.value
fakeBundle()
},
buildUploader = {
capturedDuringUpload += orchestrator.state.value
CannedUploader()
},
signAndPublish = { signAndPublish = {
capturedDuringPublish += orchestrator.state.value capturedDuringPublish += orchestrator.state.value
"event-id" "event-id"
}, },
workDirFactory = { File(workDir, "work").apply { mkdirs() } },
) )
runBlocking { orchestrator.publish(newRequest()) } runBlocking { orchestrator.publish(newRequest()) }
@@ -181,10 +230,10 @@ class HlsPublishOrchestratorTest {
val orchestrator = val orchestrator =
HlsPublishOrchestrator( HlsPublishOrchestrator(
_state = MutableStateFlow(HlsPublishState.Idle), _state = MutableStateFlow(HlsPublishState.Idle),
runTranscode = { _, _, _, _ -> throw RuntimeException("decode failed") }, runUpload = { _, _, _ -> throw RuntimeException("decode failed") },
buildUploader = { CannedUploader() }, buildUploader = { CannedUploader() },
uploadMaster = { _, _ -> MediaUploadResult(url = "never") },
signAndPublish = { "never" }, signAndPublish = { "never" },
workDirFactory = { File(workDir, "work").apply { mkdirs() } },
) )
runBlocking { orchestrator.publish(newRequest()) } runBlocking { orchestrator.publish(newRequest()) }
@@ -199,12 +248,12 @@ class HlsPublishOrchestratorTest {
val orchestrator = val orchestrator =
HlsPublishOrchestrator( HlsPublishOrchestrator(
_state = MutableStateFlow(HlsPublishState.Idle), _state = MutableStateFlow(HlsPublishState.Idle),
runTranscode = { _, _, _, _ -> fakeBundle() }, runUpload = fakeRunUpload(),
buildUploader = { buildUploader = {
HlsBlobUploader { _, _ -> throw RuntimeException("server 500") } HlsBlobUploader { _, _ -> throw RuntimeException("server 500") }
}, },
uploadMaster = { _, _ -> MediaUploadResult(url = "never") },
signAndPublish = { "never" }, signAndPublish = { "never" },
workDirFactory = { File(workDir, "work").apply { mkdirs() } },
) )
runBlocking { orchestrator.publish(newRequest()) } runBlocking { orchestrator.publish(newRequest()) }
@@ -215,14 +264,34 @@ class HlsPublishOrchestratorTest {
} }
@Test @Test
fun publishExceptionTransitionsToFailure() { fun masterUploadExceptionTransitionsToFailure() {
val canned = CannedUploader()
val orchestrator = val orchestrator =
HlsPublishOrchestrator( HlsPublishOrchestrator(
_state = MutableStateFlow(HlsPublishState.Idle), _state = MutableStateFlow(HlsPublishState.Idle),
runTranscode = { _, _, _, _ -> fakeBundle() }, runUpload = fakeRunUpload(),
buildUploader = { CannedUploader() }, buildUploader = { canned },
uploadMaster = { _, _ -> throw RuntimeException("master upload failed") },
signAndPublish = { "never" },
)
runBlocking { orchestrator.publish(newRequest()) }
val final = orchestrator.state.value
assertTrue(final is HlsPublishState.Failure)
assertEquals("master upload failed", (final as HlsPublishState.Failure).message)
}
@Test
fun publishExceptionTransitionsToFailure() {
val canned = CannedUploader()
val orchestrator =
HlsPublishOrchestrator(
_state = MutableStateFlow(HlsPublishState.Idle),
runUpload = fakeRunUpload(),
buildUploader = { canned },
uploadMaster = fakeUploadMaster(canned),
signAndPublish = { throw RuntimeException("relay rejected") }, signAndPublish = { throw RuntimeException("relay rejected") },
workDirFactory = { File(workDir, "work").apply { mkdirs() } },
) )
runBlocking { orchestrator.publish(newRequest()) } runBlocking { orchestrator.publish(newRequest()) }
@@ -235,16 +304,17 @@ class HlsPublishOrchestratorTest {
@Test @Test
fun sensitiveContentPassesContentWarningIntoTemplate() { fun sensitiveContentPassesContentWarningIntoTemplate() {
val captured = mutableListOf<HlsVideoEventTemplate>() val captured = mutableListOf<HlsVideoEventTemplate>()
val canned = CannedUploader()
val orchestrator = val orchestrator =
HlsPublishOrchestrator( HlsPublishOrchestrator(
_state = MutableStateFlow(HlsPublishState.Idle), _state = MutableStateFlow(HlsPublishState.Idle),
runTranscode = { _, _, _, _ -> fakeBundle() }, runUpload = fakeRunUpload(),
buildUploader = { CannedUploader() }, buildUploader = { canned },
uploadMaster = fakeUploadMaster(canned),
signAndPublish = { tpl -> signAndPublish = { tpl ->
captured += tpl captured += tpl
"event-id" "event-id"
}, },
workDirFactory = { File(workDir, "work").apply { mkdirs() } },
) )
runBlocking { runBlocking {
@@ -258,31 +328,31 @@ class HlsPublishOrchestratorTest {
} }
@Test @Test
fun portraitBundleProducesVerticalTemplate() { fun portraitRenditionsProduceVerticalTemplate() {
val portraitMaster = val portrait =
""" listOf(
#EXTM3U HlsRenditionSummary(
#EXT-X-STREAM-INF:BANDWIDTH=500000,RESOLUTION=360x640 rendition = Rendition(Resolution.SD_360, 500),
360p/media.m3u8 mediaPlaylist = "",
""".trimIndent() playlistFilename = "360p/media.m3u8",
val rendition = width = 360,
HlsBundleRendition( height = 640,
label = "360p", codecString = "avc1.64001f",
combinedFile = File(workDir, "360p.mp4").apply { writeText("bytes") }, combinedFilename = "360p.mp4",
mediaPlaylist = "#EXTM3U\n#EXT-X-MAP:URI=\"360p.mp4\"\n#EXTINF:6.0,\n360p.mp4\n", ),
bitrateKbps = 500,
) )
val captured = mutableListOf<HlsVideoEventTemplate>() val captured = mutableListOf<HlsVideoEventTemplate>()
val canned = CannedUploader()
val orchestrator = val orchestrator =
HlsPublishOrchestrator( HlsPublishOrchestrator(
_state = MutableStateFlow(HlsPublishState.Idle), _state = MutableStateFlow(HlsPublishState.Idle),
runTranscode = { _, _, _, _ -> HlsBundle(workDir, portraitMaster, listOf(rendition)) }, runUpload = fakeRunUpload(portrait),
buildUploader = { CannedUploader() }, buildUploader = { canned },
uploadMaster = fakeUploadMaster(canned),
signAndPublish = { tpl -> signAndPublish = { tpl ->
captured += tpl captured += tpl
"event-id" "event-id"
}, },
workDirFactory = { File(workDir, "work").apply { mkdirs() } },
) )
runBlocking { orchestrator.publish(newRequest()) } runBlocking { orchestrator.publish(newRequest()) }
@@ -295,10 +365,10 @@ class HlsPublishOrchestratorTest {
val orchestrator = val orchestrator =
HlsPublishOrchestrator( HlsPublishOrchestrator(
_state = MutableStateFlow(HlsPublishState.Idle), _state = MutableStateFlow(HlsPublishState.Idle),
runTranscode = { _, _, _, _ -> throw RuntimeException("boom") }, runUpload = { _, _, _ -> throw RuntimeException("boom") },
buildUploader = { CannedUploader() }, buildUploader = { CannedUploader() },
uploadMaster = { _, _ -> MediaUploadResult(url = "never") },
signAndPublish = { "never" }, signAndPublish = { "never" },
workDirFactory = { File(workDir, "work").apply { mkdirs() } },
) )
runBlocking { orchestrator.publish(newRequest()) } runBlocking { orchestrator.publish(newRequest()) }
@@ -1,211 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.uploads.hls
import com.davotoula.lightcompressor.Resolution
import com.davotoula.lightcompressor.hls.HlsError
import com.davotoula.lightcompressor.hls.HlsSegment
import com.davotoula.lightcompressor.hls.Rendition
import kotlinx.coroutines.ExperimentalCoroutinesApi
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Before
import org.junit.Test
import java.io.File
import java.nio.file.Files
@OptIn(ExperimentalCoroutinesApi::class)
class HlsTranscodingSessionTest {
private lateinit var workDir: File
@Before
fun setUp() {
workDir = Files.createTempDirectory("hls-session-test").toFile()
}
@After
fun tearDown() {
workDir.deleteRecursively()
}
private fun rendition360p() = Rendition(Resolution.SD_360, 500)
private fun rendition540p() = Rendition(Resolution.SD_540, 1200)
private fun fakeCombinedSegment(payload: String = "fake-mp4-bytes"): HlsSegment {
val temp = Files.createTempFile("hls-seg", ".mp4").toFile()
temp.writeText(payload)
return HlsSegment(
file = temp,
index = 0,
durationSeconds = 6.0,
isInitSegment = false,
isCombinedRendition = true,
)
}
private fun driveHappyPath(
session: HlsTranscodingSession,
rendition: Rendition,
playlist: String,
segmentPayload: String = "fake-mp4-bytes",
) {
session.onStart(1)
session.onRenditionStart(rendition)
session.onSegmentReady(rendition, fakeCombinedSegment(segmentPayload))
session.onRenditionComplete(rendition, playlist)
}
@Test
fun onCompleteEmitsHlsBundleWithMasterPlaylist() {
val session = HlsTranscodingSession(workDir)
val rendition = rendition360p()
val mediaPlaylist = "#EXTM3U\n#EXT-X-MAP:URI=\"360p.mp4\"\n"
val masterPlaylist = "#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=500000\n360p/media.m3u8\n"
driveHappyPath(session, rendition, mediaPlaylist)
session.onComplete(masterPlaylist)
val bundle = session.terminal.getCompleted()
assertEquals(masterPlaylist, bundle.masterPlaylist)
assertEquals(1, bundle.renditions.size)
assertEquals("360p", bundle.renditions[0].label)
assertEquals(mediaPlaylist, bundle.renditions[0].mediaPlaylist)
assertEquals(500, bundle.renditions[0].bitrateKbps)
}
@Test
fun onSegmentReadyRenamesCombinedFileToWorkDir() {
val session = HlsTranscodingSession(workDir)
val rendition = rendition360p()
val segment = fakeCombinedSegment(payload = "payload-360p")
val originalPath = segment.file.absolutePath
session.onStart(1)
session.onRenditionStart(rendition)
session.onSegmentReady(rendition, segment)
session.onRenditionComplete(rendition, "#EXTM3U\n")
session.onComplete("#EXTM3U\n")
val bundle = session.terminal.getCompleted()
val combined = bundle.renditions[0].combinedFile
assertEquals(File(workDir, "360p.mp4"), combined)
assertTrue(combined.exists())
assertEquals("payload-360p", combined.readText())
assertFalse(File(originalPath).exists())
}
@Test
fun happyPathWithTwoRenditionsProducesBundleWithBoth() {
val session = HlsTranscodingSession(workDir)
session.onStart(2)
session.onRenditionStart(rendition360p())
session.onSegmentReady(rendition360p(), fakeCombinedSegment("p360"))
session.onRenditionComplete(rendition360p(), "p360-playlist")
session.onRenditionStart(rendition540p())
session.onSegmentReady(rendition540p(), fakeCombinedSegment("p540"))
session.onRenditionComplete(rendition540p(), "p540-playlist")
session.onComplete("master-playlist")
val bundle = session.terminal.getCompleted()
assertEquals(2, bundle.renditions.size)
assertEquals(listOf("360p", "540p"), bundle.renditions.map { it.label })
assertEquals("p360-playlist", bundle.renditions[0].mediaPlaylist)
assertEquals("p540-playlist", bundle.renditions[1].mediaPlaylist)
assertEquals("p360", bundle.renditions[0].combinedFile.readText())
assertEquals("p540", bundle.renditions[1].combinedFile.readText())
}
@Test
fun onFailureCompletesTerminalExceptionally() {
val session = HlsTranscodingSession(workDir)
session.onStart(1)
session.onFailure(HlsError("boom", emptyList(), emptyList()))
assertTrue(session.terminal.isCompleted)
try {
session.terminal.getCompleted()
fail("expected exception")
} catch (e: Throwable) {
assertNotNull(e.message)
assertTrue(e.message!!.contains("boom"))
}
}
@Test
fun onCancelledCancelsTerminal() {
val session = HlsTranscodingSession(workDir)
session.onStart(1)
session.onCancelled()
assertTrue(session.terminal.isCancelled)
}
@Test
fun onProgressForwardsToCallback() {
val observed = mutableListOf<Pair<String, Int>>()
val session =
HlsTranscodingSession(workDir) { label, percent ->
observed += label to percent
}
session.onStart(1)
session.onRenditionStart(rendition360p())
session.onProgress(rendition360p(), 33.7f)
session.onProgress(rendition360p(), 75.0f)
assertEquals(listOf("360p" to 33, "360p" to 75), observed)
}
@Test
fun nonCombinedSegmentsAreIgnored() {
val session = HlsTranscodingSession(workDir)
val rendition = rendition360p()
val nonCombined =
HlsSegment(
file = Files.createTempFile("hls-init", ".mp4").toFile().apply { writeText("init") },
index = 0,
durationSeconds = 0.0,
isInitSegment = true,
isCombinedRendition = false,
)
val combined = fakeCombinedSegment("combined")
session.onStart(1)
session.onRenditionStart(rendition)
session.onSegmentReady(rendition, nonCombined)
session.onSegmentReady(rendition, combined)
session.onRenditionComplete(rendition, "playlist")
session.onComplete("master")
val bundle = session.terminal.getCompleted()
assertEquals(1, bundle.renditions.size)
assertEquals("combined", bundle.renditions[0].combinedFile.readText())
}
}
@@ -1,298 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.uploads.hls
import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.io.File
import java.nio.file.Files
class HlsUploadPipelineTest {
private lateinit var workDir: File
@Before
fun setUp() {
workDir = Files.createTempDirectory("hls-pipeline-test").toFile()
}
@After
fun tearDown() {
workDir.deleteRecursively()
}
private class FakeUploader : HlsBlobUploader {
data class Call(
val fileName: String,
val contentType: String,
val content: String,
)
val calls = mutableListOf<Call>()
override suspend fun upload(
file: File,
contentType: String,
): MediaUploadResult {
val content = file.readText()
calls += Call(file.name, contentType, content)
val url = "https://cdn.test/${calls.size}-${file.name}"
return MediaUploadResult(url = url, sha256 = "sha-${calls.size}", size = file.length())
}
}
private class BareUrlUploader : HlsBlobUploader {
val calls = mutableListOf<Triple<String, String, String>>()
override suspend fun upload(
file: File,
contentType: String,
): MediaUploadResult {
val content = file.readText()
calls += Triple(file.name, contentType, content)
return MediaUploadResult(url = "https://blossom.test/bare-${calls.size}", sha256 = "sha-${calls.size}", size = file.length())
}
}
private fun createBundle(labels: List<String>): HlsBundle {
val renditions =
labels.map { label ->
val combined = File(workDir, "$label.mp4").apply { writeText("bytes-$label") }
val mediaPlaylist =
"""
#EXTM3U
#EXT-X-VERSION:7
#EXT-X-MAP:URI="$label.mp4",BYTERANGE="1000@0"
#EXTINF:6.000,
#EXT-X-BYTERANGE:500000@1000
$label.mp4
#EXT-X-ENDLIST
""".trimIndent()
HlsBundleRendition(
label = label,
combinedFile = combined,
mediaPlaylist = mediaPlaylist,
bitrateKbps = 500 + labels.indexOf(label) * 1000,
)
}
val masterLines =
buildList {
add("#EXTM3U")
add("#EXT-X-VERSION:7")
renditions.forEach {
add("#EXT-X-STREAM-INF:BANDWIDTH=${it.bitrateKbps * 1000}")
add("${it.label}/media.m3u8")
}
}
return HlsBundle(
workDir = workDir,
masterPlaylist = masterLines.joinToString("\n"),
renditions = renditions,
)
}
@Test
fun uploadsCombinedThenMediaThenMasterInOrder() {
val bundle = createBundle(listOf("360p"))
val uploader = FakeUploader()
val pipeline = HlsUploadPipeline(uploader)
runBlocking { pipeline.upload(bundle) }
assertEquals(3, uploader.calls.size)
assertEquals("360p.mp4", uploader.calls[0].fileName)
assertEquals("video/mp4", uploader.calls[0].contentType)
assertTrue(uploader.calls[1].fileName.endsWith(".m3u8"))
assertEquals("application/vnd.apple.mpegurl", uploader.calls[1].contentType)
assertEquals("application/vnd.apple.mpegurl", uploader.calls[2].contentType)
}
@Test
fun mediaPlaylistIsRewrittenWithUploadedCombinedUrl() {
val bundle = createBundle(listOf("360p"))
val uploader = FakeUploader()
val pipeline = HlsUploadPipeline(uploader)
runBlocking { pipeline.upload(bundle) }
val combinedUrl = "https://cdn.test/1-360p.mp4"
val uploadedMediaPlaylist = uploader.calls[1].content
assertTrue(uploadedMediaPlaylist.contains(combinedUrl))
// Original filename reference must be gone
assertTrue(!uploadedMediaPlaylist.lines().any { it.trim() == "360p.mp4" })
// EXTINF metadata must still be present
assertTrue(uploadedMediaPlaylist.contains("#EXTINF:6.000,"))
// BYTERANGE must still be present
assertTrue(uploadedMediaPlaylist.contains("#EXT-X-BYTERANGE:500000@1000"))
}
@Test
fun masterPlaylistIsRewrittenWithUploadedMediaPlaylistUrls() {
val bundle = createBundle(listOf("360p", "540p"))
val uploader = FakeUploader()
val pipeline = HlsUploadPipeline(uploader)
runBlocking { pipeline.upload(bundle) }
// 2 renditions × (combined + media) + 1 master = 5 uploads
assertEquals(5, uploader.calls.size)
val masterContent = uploader.calls[4].content
// The uploaded media playlist URLs should appear in the rewritten master
val media360Url = uploader.calls[1].content.let { "https://cdn.test/2-" } // 2nd call is 360p media
// Extract the actual URLs the fake returned for each media playlist upload
val media360PlaylistUrl = "https://cdn.test/2-" + uploader.calls[1].fileName
val media540PlaylistUrl = "https://cdn.test/4-" + uploader.calls[3].fileName
assertTrue("master should contain $media360PlaylistUrl", masterContent.contains(media360PlaylistUrl))
assertTrue("master should contain $media540PlaylistUrl", masterContent.contains(media540PlaylistUrl))
// EXT-X-STREAM-INF metadata must survive
assertTrue(masterContent.contains("#EXT-X-STREAM-INF:BANDWIDTH=500000"))
assertTrue(masterContent.contains("#EXT-X-STREAM-INF:BANDWIDTH=1500000"))
// Original rendition filenames must be gone
assertTrue(!masterContent.lines().any { it.trim() == "360p/media.m3u8" })
assertTrue(!masterContent.lines().any { it.trim() == "540p/media.m3u8" })
}
@Test
fun bareServerUrlsPassThroughVerbatim() {
// Policy: the pipeline uses whatever URL the server returned, unchanged.
// Even a bare-hash URL with no extension flows straight into the rewritten
// playlists. If it does not play, the fix is server-side (return a playable URL).
val bundle = createBundle(listOf("360p"))
val uploader = BareUrlUploader()
val pipeline = HlsUploadPipeline(uploader)
val result = runBlocking { pipeline.upload(bundle) }
assertEquals("https://blossom.test/bare-1", result.renditions[0].combinedUrl)
assertEquals("https://blossom.test/bare-2", result.renditions[0].playlistUrl)
assertEquals("https://blossom.test/bare-3", result.masterUrl)
// The rewritten media playlist that the server received must contain the bare url.
val uploadedMediaPlaylist = uploader.calls[1].third
assertTrue(
"media playlist should reference bare url: $uploadedMediaPlaylist",
uploadedMediaPlaylist.contains("https://blossom.test/bare-1"),
)
}
@Test
fun serverUrlsAlreadyWithExtensionPassThroughUntouched() {
// Matches the server-side fix where the NIP-96 plugin returns clean "<hash>.m3u8"
// URLs. The pipeline must not append a second ".m3u8" on top.
val bundle = createBundle(listOf("360p"))
val uploader =
object : HlsBlobUploader {
var count = 0
override suspend fun upload(
file: File,
contentType: String,
): MediaUploadResult {
count++
val ext =
when (contentType) {
HlsUploadPipeline.CONTENT_TYPE_VIDEO_MP4 -> "mp4"
HlsUploadPipeline.CONTENT_TYPE_HLS -> "m3u8"
else -> "bin"
}
return MediaUploadResult(
url = "https://server.test/hash-$count.$ext",
sha256 = "sha-$count",
size = file.length(),
)
}
}
val pipeline = HlsUploadPipeline(uploader)
val result = runBlocking { pipeline.upload(bundle) }
assertEquals("https://server.test/hash-1.mp4", result.renditions[0].combinedUrl)
assertEquals("https://server.test/hash-2.m3u8", result.renditions[0].playlistUrl)
assertEquals("https://server.test/hash-3.m3u8", result.masterUrl)
// And crucially, no double-extension anywhere:
assertTrue(!result.masterUrl.contains(".m3u8.m3u8"))
assertTrue(!result.renditions[0].combinedUrl.contains(".mp4.mp4"))
assertTrue(!result.renditions[0].playlistUrl.contains(".m3u8.m3u8"))
}
@Test
fun reportsUploadProgressWithCurrentLabelBeforeEachStep() {
val bundle = createBundle(listOf("360p", "540p"))
val uploader = FakeUploader()
val pipeline = HlsUploadPipeline(uploader)
val observed = mutableListOf<Triple<Int, Int, String>>()
runBlocking {
pipeline.upload(bundle) { done, total, label ->
observed += Triple(done, total, label)
}
}
// 2 renditions × 2 + 1 master = 5 uploads. Label is emitted BEFORE each upload with
// the done-count of previously-completed uploads, so the UI can show "Uploading 360p
// video (0 / 5)" while the first file is actually in flight. A trailing (5, 5, "")
// marks the final completion.
assertEquals(
listOf(
Triple(0, 5, "360p video"),
Triple(1, 5, "360p playlist"),
Triple(2, 5, "540p video"),
Triple(3, 5, "540p playlist"),
Triple(4, 5, "master playlist"),
Triple(5, 5, ""),
),
observed,
)
}
@Test
fun resultExposesMasterUrlAndPerRenditionDetails() {
val bundle = createBundle(listOf("360p", "540p"))
val uploader = FakeUploader()
val pipeline = HlsUploadPipeline(uploader)
val result = runBlocking { pipeline.upload(bundle) }
// Master was the 5th upload
assertEquals("https://cdn.test/5-master.m3u8", result.masterUrl)
assertEquals("sha-5", result.masterSha256)
assertEquals(2, result.renditions.size)
val r360 = result.renditions[0]
assertEquals("360p", r360.label)
assertEquals("https://cdn.test/1-360p.mp4", r360.combinedUrl)
assertEquals("sha-1", r360.combinedSha256)
assertEquals(500, r360.bitrateKbps)
val r540 = result.renditions[1]
assertEquals("540p", r540.label)
assertEquals("https://cdn.test/3-540p.mp4", r540.combinedUrl)
assertEquals(1500, r540.bitrateKbps)
}
}
@@ -20,6 +20,10 @@
*/ */
package com.vitorpamplona.amethyst.service.uploads.hls package com.vitorpamplona.amethyst.service.uploads.hls
import com.davotoula.lightcompressor.Resolution
import com.davotoula.lightcompressor.hls.HlsRenditionSummary
import com.davotoula.lightcompressor.hls.Rendition
import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult
import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent
import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
@@ -27,75 +31,67 @@ import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
import java.io.File
class HlsVideoEventBuilderTest { class HlsVideoEventBuilderTest {
private val landscapeMasterPlaylist = private val landscapeRenditions =
""" listOf(
#EXTM3U summary(Resolution.SD_360, width = 640, height = 360),
#EXT-X-VERSION:7 summary(Resolution.HD_720, width = 1280, height = 720),
#EXT-X-STREAM-INF:BANDWIDTH=500000,RESOLUTION=640x360,CODECS="avc1.64001e,mp4a.40.2"
360p/media.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=2500000,RESOLUTION=1280x720,CODECS="avc1.64001f,mp4a.40.2"
720p/media.m3u8
""".trimIndent()
private val portraitMasterPlaylist =
"""
#EXTM3U
#EXT-X-VERSION:7
#EXT-X-STREAM-INF:BANDWIDTH=500000,RESOLUTION=360x640,CODECS="avc1.64001e,mp4a.40.2"
360p/media.m3u8
""".trimIndent()
private fun bundle(master: String): HlsBundle {
val workDir = File("/tmp/unused-builder-test")
val labels = Regex("""(\d+p)/media\.m3u8""").findAll(master).map { it.groupValues[1] }.toList()
val renditions =
labels.mapIndexed { i, label ->
HlsBundleRendition(
label = label,
combinedFile = File(workDir, "$label.mp4"),
mediaPlaylist = "", // not needed by the builder
bitrateKbps = 500 + i * 2000,
)
}
return HlsBundle(workDir, master, renditions)
}
private fun uploadResult(renditions: List<HlsBundleRendition>): HlsUploadResult =
HlsUploadResult(
masterUrl = "https://cdn.test/master.m3u8",
masterSha256 = "master-sha",
renditions =
renditions.map {
HlsUploadedRendition(
label = it.label,
combinedUrl = "https://cdn.test/${it.label}.mp4",
combinedSha256 = "${it.label}-sha",
combinedSize = 1_000_000L,
playlistUrl = "https://cdn.test/${it.label}-media.m3u8",
bitrateKbps = it.bitrateKbps,
)
},
) )
private val portraitRenditions =
listOf(
summary(Resolution.SD_360, width = 360, height = 640),
)
private fun summary(
resolution: Resolution,
width: Int,
height: Int,
): HlsRenditionSummary =
HlsRenditionSummary(
rendition = Rendition(resolution, bitrateKbps = 500),
mediaPlaylist = "", // not needed by the builder
playlistFilename = "${resolution.label}/media.m3u8",
width = width,
height = height,
codecString = "avc1.64001f",
combinedFilename = "${resolution.label}.mp4",
)
private fun segmentUploadsFor(renditions: List<HlsRenditionSummary>): Map<String, MediaUploadResult> {
val uploads = mutableMapOf<String, MediaUploadResult>()
renditions.forEach { r ->
val label = r.rendition.resolution.label
uploads["$label.mp4"] =
MediaUploadResult(
url = "https://cdn.test/$label.mp4",
sha256 = "$label-sha",
size = 1_000_000L,
)
uploads["$label/media.m3u8"] =
MediaUploadResult(
url = "https://cdn.test/$label-media.m3u8",
sha256 = "$label-playlist-sha",
)
}
return uploads
}
private fun input( private fun input(
master: String, renditions: List<HlsRenditionSummary>,
title: String = "My HD Video", title: String = "My HD Video",
description: String = "A cool video", description: String = "A cool video",
alt: String? = null, alt: String? = null,
duration: Int? = null, duration: Int? = null,
contentWarning: String? = null, contentWarning: String? = null,
dTag: String? = "fixed-d-tag", dTag: String? = "fixed-d-tag",
): HlsVideoPublishInput { ): HlsVideoPublishInput =
val b = bundle(master) HlsVideoPublishInput(
return HlsVideoPublishInput( renditions = renditions,
bundle = b, segmentUploads = segmentUploadsFor(renditions),
uploadResult = uploadResult(b.renditions), masterUrl = "https://cdn.test/master.m3u8",
masterSha256 = "master-sha",
title = title, title = title,
description = description, description = description,
alt = alt, alt = alt,
@@ -104,15 +100,14 @@ class HlsVideoEventBuilderTest {
dTag = dTag, dTag = dTag,
createdAt = 1_700_000_000L, createdAt = 1_700_000_000L,
) )
}
private fun Array<Array<String>>.findTag(name: String): Array<String>? = firstOrNull { it.isNotEmpty() && it[0] == name } private fun Array<Array<String>>.findTag(name: String): Array<String>? = firstOrNull { it.isNotEmpty() && it[0] == name }
private fun Array<Array<String>>.findAllTags(name: String): List<Array<String>> = filter { it.isNotEmpty() && it[0] == name } private fun Array<Array<String>>.findAllTags(name: String): List<Array<String>> = filter { it.isNotEmpty() && it[0] == name }
@Test @Test
fun landscapeMasterBuildsHorizontalTemplateKind34235() { fun landscapeRenditionsBuildHorizontalTemplateKind34235() {
val result = HlsVideoEventBuilder.build(input(landscapeMasterPlaylist)) val result = HlsVideoEventBuilder.build(input(landscapeRenditions))
assertTrue("expected Horizontal template", result is HlsVideoEventTemplate.Horizontal) assertTrue("expected Horizontal template", result is HlsVideoEventTemplate.Horizontal)
val template = (result as HlsVideoEventTemplate.Horizontal).template val template = (result as HlsVideoEventTemplate.Horizontal).template
@@ -121,8 +116,8 @@ class HlsVideoEventBuilderTest {
} }
@Test @Test
fun portraitMasterBuildsVerticalTemplateKind34236() { fun portraitRenditionsBuildVerticalTemplateKind34236() {
val result = HlsVideoEventBuilder.build(input(portraitMasterPlaylist)) val result = HlsVideoEventBuilder.build(input(portraitRenditions))
assertTrue("expected Vertical template", result is HlsVideoEventTemplate.Vertical) assertTrue("expected Vertical template", result is HlsVideoEventTemplate.Vertical)
val template = (result as HlsVideoEventTemplate.Vertical).template val template = (result as HlsVideoEventTemplate.Vertical).template
@@ -131,7 +126,7 @@ class HlsVideoEventBuilderTest {
@Test @Test
fun horizontalTemplateHasTitleAndDTag() { fun horizontalTemplateHasTitleAndDTag() {
val result = HlsVideoEventBuilder.build(input(landscapeMasterPlaylist)) val result = HlsVideoEventBuilder.build(input(landscapeRenditions))
val tags = (result as HlsVideoEventTemplate.Horizontal).template.tags val tags = (result as HlsVideoEventTemplate.Horizontal).template.tags
val title = tags.findTag("title") val title = tags.findTag("title")
@@ -145,7 +140,7 @@ class HlsVideoEventBuilderTest {
@Test @Test
fun templateContainsOneImetaForMasterAndOnePerRendition() { fun templateContainsOneImetaForMasterAndOnePerRendition() {
val result = HlsVideoEventBuilder.build(input(landscapeMasterPlaylist)) val result = HlsVideoEventBuilder.build(input(landscapeRenditions))
val tags = (result as HlsVideoEventTemplate.Horizontal).template.tags val tags = (result as HlsVideoEventTemplate.Horizontal).template.tags
val imetas = tags.findAllTags("imeta") val imetas = tags.findAllTags("imeta")
@@ -173,7 +168,7 @@ class HlsVideoEventBuilderTest {
fun durationTagWhenDurationProvided() { fun durationTagWhenDurationProvided() {
val result = val result =
HlsVideoEventBuilder.build( HlsVideoEventBuilder.build(
input(landscapeMasterPlaylist, duration = 123), input(landscapeRenditions, duration = 123),
) )
val tags = (result as HlsVideoEventTemplate.Horizontal).template.tags val tags = (result as HlsVideoEventTemplate.Horizontal).template.tags
@@ -184,7 +179,7 @@ class HlsVideoEventBuilderTest {
@Test @Test
fun noDurationTagWhenNotProvided() { fun noDurationTagWhenNotProvided() {
val result = HlsVideoEventBuilder.build(input(landscapeMasterPlaylist)) val result = HlsVideoEventBuilder.build(input(landscapeRenditions))
val tags = (result as HlsVideoEventTemplate.Horizontal).template.tags val tags = (result as HlsVideoEventTemplate.Horizontal).template.tags
assertNull(tags.findTag("duration")) assertNull(tags.findTag("duration"))
} }
@@ -193,7 +188,7 @@ class HlsVideoEventBuilderTest {
fun contentWarningTagWhenProvided() { fun contentWarningTagWhenProvided() {
val result = val result =
HlsVideoEventBuilder.build( HlsVideoEventBuilder.build(
input(landscapeMasterPlaylist, contentWarning = "NSFW"), input(landscapeRenditions, contentWarning = "NSFW"),
) )
val tags = (result as HlsVideoEventTemplate.Horizontal).template.tags val tags = (result as HlsVideoEventTemplate.Horizontal).template.tags
@@ -204,14 +199,14 @@ class HlsVideoEventBuilderTest {
@Test @Test
fun noContentWarningTagWhenNull() { fun noContentWarningTagWhenNull() {
val result = HlsVideoEventBuilder.build(input(landscapeMasterPlaylist)) val result = HlsVideoEventBuilder.build(input(landscapeRenditions))
val tags = (result as HlsVideoEventTemplate.Horizontal).template.tags val tags = (result as HlsVideoEventTemplate.Horizontal).template.tags
assertNull(tags.findTag("content-warning")) assertNull(tags.findTag("content-warning"))
} }
@Test @Test
fun horizontalTemplateCarriesAutoGeneratedAltTag() { fun horizontalTemplateCarriesAutoGeneratedAltTag() {
val result = HlsVideoEventBuilder.build(input(landscapeMasterPlaylist)) val result = HlsVideoEventBuilder.build(input(landscapeRenditions))
val tags = (result as HlsVideoEventTemplate.Horizontal).template.tags val tags = (result as HlsVideoEventTemplate.Horizontal).template.tags
val alt = tags.findTag("alt") val alt = tags.findTag("alt")
assertNotNull(alt) assertNotNull(alt)
@@ -220,7 +215,7 @@ class HlsVideoEventBuilderTest {
@Test @Test
fun verticalTemplateCarriesVerticalAltTag() { fun verticalTemplateCarriesVerticalAltTag() {
val result = HlsVideoEventBuilder.build(input(portraitMasterPlaylist)) val result = HlsVideoEventBuilder.build(input(portraitRenditions))
val tags = (result as HlsVideoEventTemplate.Vertical).template.tags val tags = (result as HlsVideoEventTemplate.Vertical).template.tags
val alt = tags.findTag("alt") val alt = tags.findTag("alt")
assertNotNull(alt) assertNotNull(alt)
+1 -1
View File
@@ -38,7 +38,7 @@ genaiPrompt = "1.0.0-beta2"
genaiRewriting = "1.0.0-beta1" genaiRewriting = "1.0.0-beta1"
languageId = "17.0.6" languageId = "17.0.6"
lifecycleRuntimeKtx = "2.10.0" lifecycleRuntimeKtx = "2.10.0"
lightcompressor-enhanced = "2.1.0" lightcompressor-enhanced = "2.1.1-hls-SNAPSHOT"
markdown = "f92ef49c9d" markdown = "f92ef49c9d"
material3 = "1.9.0" material3 = "1.9.0"
materialIconsExtended = "1.7.3" materialIconsExtended = "1.7.3"