From c008daee7ea632d11e5d8265d6137dcb8f141b5b Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 14 Apr 2026 15:35:01 +0200 Subject: [PATCH] feat(hls): publish orchestrator + ViewModel state machine feat(hls): build NIP-71 video event templates from upload result feat(hls): orchestrate per-rendition and master uploads feat(hls): wrap HlsPreparer into HlsTranscoder + HlsBundle --- .../amethyst/service/uploads/hls/HlsBundle.kt | 36 +++ .../service/uploads/hls/HlsTranscoder.kt | 72 +++++ .../uploads/hls/HlsTranscodingSession.kt | 113 +++++++ .../service/uploads/hls/HlsUploadPipeline.kt | 147 +++++++++ .../uploads/hls/HlsVideoEventBuilder.kt | 137 ++++++++ .../video/hls/HlsPublishOrchestrator.kt | 113 +++++++ .../loggedIn/video/hls/HlsPublishState.kt | 46 +++ .../video/hls/NewHlsVideoViewModel.kt | 146 +++++++++ .../uploads/hls/HlsPublishOrchestratorTest.kt | 301 ++++++++++++++++++ .../uploads/hls/HlsTranscodingSessionTest.kt | 211 ++++++++++++ .../uploads/hls/HlsUploadPipelineTest.kt | 273 ++++++++++++++++ .../uploads/hls/HlsVideoEventBuilderTest.kt | 229 +++++++++++++ 12 files changed, 1824 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsBundle.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsTranscoder.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsTranscodingSession.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsUploadPipeline.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsVideoEventBuilder.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishOrchestrator.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishState.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoViewModel.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsPublishOrchestratorTest.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsTranscodingSessionTest.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsUploadPipelineTest.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsVideoEventBuilderTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsBundle.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsBundle.kt new file mode 100644 index 000000000..cd9d2f138 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsBundle.kt @@ -0,0 +1,36 @@ +/* + * 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 java.io.File + +data class HlsBundle( + val workDir: File, + val masterPlaylist: String, + val renditions: List, +) + +data class HlsBundleRendition( + val label: String, + val combinedFile: File, + val mediaPlaylist: String, + val bitrateKbps: Int, +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsTranscoder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsTranscoder.kt new file mode 100644 index 000000000..111ed8914 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsTranscoder.kt @@ -0,0 +1,72 @@ +/* + * 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 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, + onRenditionProgress: (label: String, percent: Int) -> Unit = { _, _ -> }, + ): HlsBundle { + workDir.mkdirs() + val session = HlsTranscodingSession(workDir, onRenditionProgress) + val config = HlsConfig(codec = codec) + + HlsPreparer.start( + context = context, + uri = uri, + config = config, + listener = session, + ) + + return try { + session.terminal.await() + } catch (e: CancellationException) { + HlsPreparer.cancel() + throw e + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsTranscodingSession.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsTranscodingSession.kt new file mode 100644 index 000000000..2516b8b38 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsTranscodingSession.kt @@ -0,0 +1,113 @@ +/* + * 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 = CompletableDeferred() + + private val combinedByLabel = mutableMapOf() + private val completed = mutableListOf() + + 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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsUploadPipeline.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsUploadPipeline.kt new file mode 100644 index 000000000..4c0221dbd --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsUploadPipeline.kt @@ -0,0 +1,147 @@ +/* + * 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, +) + +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. + */ +class HlsUploadPipeline( + private val uploader: HlsBlobUploader, +) { + suspend fun upload( + bundle: HlsBundle, + onProgress: (done: Int, total: Int) -> 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 -> + val combined = uploader.upload(rendition.combinedFile, CONTENT_TYPE_VIDEO_MP4) + onProgress(++done, total) + val combinedUrl = + withExtensionHint( + combined.url ?: error("Uploader returned null URL for ${rendition.combinedFile.name}"), + CONTENT_TYPE_VIDEO_MP4, + ) + + val rewrittenMedia = + HlsPlaylistRewriter.rewrite( + rendition.mediaPlaylist, + mapOf("${rendition.label}.mp4" to combinedUrl), + ) + val mediaPlaylistFile = + File(playlistDir, "${rendition.label}-media.m3u8").apply { writeText(rewrittenMedia) } + val mediaPlaylist = uploader.upload(mediaPlaylistFile, CONTENT_TYPE_HLS) + onProgress(++done, total) + val mediaPlaylistUrl = + withExtensionHint( + mediaPlaylist.url ?: error("Uploader returned null URL for media playlist ${rendition.label}"), + CONTENT_TYPE_HLS, + ) + + 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) } + val master = uploader.upload(masterFile, CONTENT_TYPE_HLS) + onProgress(++done, total) + val masterUrl = + withExtensionHint( + master.url ?: error("Uploader returned null URL for master playlist"), + CONTENT_TYPE_HLS, + ) + + return HlsUploadResult( + masterUrl = masterUrl, + masterSha256 = master.sha256, + renditions = uploadedRenditions, + ) + } + + // Blossom servers typically return bare-hash URLs (https://server/), but HLS parsers + // and ExoPlayer's Util.inferContentType sniff the URL extension to pick the right source + // factory. Append a hint unless the upload server already baked one in. + private fun withExtensionHint( + url: String, + contentType: String, + ): String { + val ext = + when (contentType) { + CONTENT_TYPE_VIDEO_MP4 -> ".mp4" + CONTENT_TYPE_HLS -> ".m3u8" + else -> return url + } + return if (url.endsWith(ext, ignoreCase = true)) url else url + ext + } + + companion object { + const val CONTENT_TYPE_VIDEO_MP4 = "video/mp4" + const val CONTENT_TYPE_HLS = "application/vnd.apple.mpegurl" + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsVideoEventBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsVideoEventBuilder.kt new file mode 100644 index 000000000..014ebd567 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsVideoEventBuilder.kt @@ -0,0 +1,137 @@ +/* + * 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.hls.HlsUploadPipeline.Companion.CONTENT_TYPE_HLS +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning +import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent +import com.vitorpamplona.quartz.nip71Video.VideoMeta +import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent +import com.vitorpamplona.quartz.nip71Video.duration +import com.vitorpamplona.quartz.nip71Video.title +import com.vitorpamplona.quartz.nip71Video.videoIMetas +import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +data class HlsVideoPublishInput( + val bundle: HlsBundle, + val uploadResult: HlsUploadResult, + val title: String, + val description: String, + val alt: String? = null, + val durationSeconds: Int? = null, + val contentWarning: String? = null, + val dTag: String? = null, + val createdAt: Long? = null, +) + +sealed class HlsVideoEventTemplate { + data class Horizontal( + val template: EventTemplate, + ) : HlsVideoEventTemplate() + + data class Vertical( + val template: EventTemplate, + ) : HlsVideoEventTemplate() +} + +/** + * 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 + * master playlist: portrait (height > width) selects kind 34236, otherwise 34235. + * + * 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 + * `m application/vnd.apple.mpegurl`. + * + * Returns the unsigned template wrapped in a sealed [HlsVideoEventTemplate]; the caller signs + * via the account's signer and publishes via the relay client. + */ +@OptIn(ExperimentalUuidApi::class) +object HlsVideoEventBuilder { + private val streamInfRegex = Regex("""#EXT-X-STREAM-INF:[^\n]*RESOLUTION=(\d+)x(\d+)""") + + fun build(input: HlsVideoPublishInput): HlsVideoEventTemplate { + val renditionDimensions = parseRenditionDimensions(input.bundle.masterPlaylist) + val isVertical = renditionDimensions.firstOrNull()?.let { it.height > it.width } ?: false + + val masterDimension = renditionDimensions.maxByOrNull { it.width * it.height }?.toDimensionTag() + val masterVideoMeta = + VideoMeta( + url = input.uploadResult.masterUrl, + mimeType = CONTENT_TYPE_HLS, + hash = input.uploadResult.masterSha256, + dimension = masterDimension, + alt = input.alt, + ) + + val renditionMetas = + input.uploadResult.renditions.mapIndexed { index, uploaded -> + VideoMeta( + url = uploaded.playlistUrl, + mimeType = CONTENT_TYPE_HLS, + hash = uploaded.combinedSha256, + size = uploaded.combinedSize?.toInt(), + dimension = renditionDimensions.getOrNull(index)?.toDimensionTag(), + ) + } + + val videoMetas = listOf(masterVideoMeta) + renditionMetas + val dTag = input.dTag ?: Uuid.random().toString() + val createdAt = input.createdAt ?: TimeUtils.now() + + return if (isVertical) { + HlsVideoEventTemplate.Vertical( + VideoVerticalEvent.build(input.description, dTag, createdAt) { + videoIMetas(videoMetas) + title(input.title) + input.durationSeconds?.let { duration(it) } + input.contentWarning?.let { contentWarning(it) } + }, + ) + } else { + HlsVideoEventTemplate.Horizontal( + VideoHorizontalEvent.build(input.description, dTag, createdAt) { + videoIMetas(videoMetas) + title(input.title) + input.durationSeconds?.let { duration(it) } + input.contentWarning?.let { contentWarning(it) } + }, + ) + } + } + + private data class RenditionDimension( + val width: Int, + val height: Int, + ) { + fun toDimensionTag(): DimensionTag = DimensionTag(width, height) + } + + private fun parseRenditionDimensions(masterPlaylist: String): List = + streamInfRegex + .findAll(masterPlaylist) + .map { RenditionDimension(it.groupValues[1].toInt(), it.groupValues[2].toInt()) } + .toList() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishOrchestrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishOrchestrator.kt new file mode 100644 index 000000000..c673c92b6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishOrchestrator.kt @@ -0,0 +1,113 @@ +/* + * 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.ui.screen.loggedIn.video.hls + +import com.davotoula.lightcompressor.VideoCodec +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.HlsVideoEventTemplate +import com.vitorpamplona.amethyst.service.uploads.hls.HlsVideoPublishInput +import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import java.io.File + +data class HlsPublishRequest( + val title: String, + val description: String, + val sensitiveContent: Boolean, + val contentWarningReason: String, + val codec: VideoCodec, + val server: ServerName, + val durationSeconds: Int? = null, +) + +/** + * Orchestrates the transcode → upload → build → publish pipeline for a single HLS video publish. + * All Android/account-specific concerns are injected as suspending callbacks so the whole state + * machine is unit-testable. + * + * State transitions: Idle → Transcoding → Uploading → Publishing → Success, or → Failure on any + * exception. The [state] flow emits each transition as it happens so the UI can reflect progress. + */ +class HlsPublishOrchestrator( + private val runTranscode: suspend ( + workDir: File, + codec: VideoCodec, + onProgress: (label: String, percent: Int) -> Unit, + ) -> HlsBundle, + private val buildUploader: (ServerName) -> HlsBlobUploader, + private val signAndPublish: suspend (HlsVideoEventTemplate) -> String, + private val workDirFactory: () -> File, +) { + private val _state = MutableStateFlow(HlsPublishState.Idle) + val state: StateFlow = _state + + suspend fun publish(request: HlsPublishRequest) { + val workDir = workDirFactory() + try { + _state.value = HlsPublishState.Transcoding(currentLabel = "", percent = 0) + val bundle = + runTranscode(workDir, request.codec) { label, percent -> + _state.value = HlsPublishState.Transcoding(label, percent) + } + + 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 = + pipeline.upload(bundle) { done, total -> + _state.value = HlsPublishState.Uploading(done, total) + } + + _state.value = HlsPublishState.Publishing + val template = + HlsVideoEventBuilder.build( + HlsVideoPublishInput( + bundle = bundle, + uploadResult = uploadResult, + title = request.title, + description = request.description, + durationSeconds = request.durationSeconds, + contentWarning = contentWarningOrNull(request), + ), + ) + val eventId = signAndPublish(template) + + _state.value = HlsPublishState.Success(eventId = eventId, masterUrl = uploadResult.masterUrl) + } catch (e: CancellationException) { + _state.value = HlsPublishState.Failure(message = "Cancelled") + throw e + } catch (e: Throwable) { + _state.value = HlsPublishState.Failure(message = e.message ?: e::class.simpleName.orEmpty()) + } + } + + fun reset() { + _state.value = HlsPublishState.Idle + } + + private fun contentWarningOrNull(request: HlsPublishRequest): String? = if (request.sensitiveContent) request.contentWarningReason else null +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishState.kt new file mode 100644 index 000000000..da48161d1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishState.kt @@ -0,0 +1,46 @@ +/* + * 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.ui.screen.loggedIn.video.hls + +sealed class HlsPublishState { + data object Idle : HlsPublishState() + + data class Transcoding( + val currentLabel: String, + val percent: Int, + ) : HlsPublishState() + + data class Uploading( + val done: Int, + val total: Int, + ) : HlsPublishState() + + data object Publishing : HlsPublishState() + + data class Success( + val eventId: String, + val masterUrl: String, + ) : HlsPublishState() + + data class Failure( + val message: String, + ) : HlsPublishState() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoViewModel.kt new file mode 100644 index 000000000..8a5fe3da3 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoViewModel.kt @@ -0,0 +1,146 @@ +/* + * 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.ui.screen.loggedIn.video.hls + +import android.content.Context +import android.net.Uri +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.davotoula.lightcompressor.VideoCodec +import com.davotoula.lightcompressor.utils.CompressorUtils +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS +import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +/** + * Compose-facing ViewModel for the "Share HD Video" screen. Holds the form state and a single + * [HlsPublishOrchestrator] that runs the transcode → upload → publish pipeline. The orchestrator + * receives closures that capture the account/context so the VM only needs a [load] call from the + * screen to wire everything together. + * + * This class is intentionally thin — all orchestration logic and state-machine tests live in + * [HlsPublishOrchestrator]. + */ +@Stable +open class NewHlsVideoViewModel : ViewModel() { + var account: Account? = null + private set + var pickedUri by mutableStateOf(null) + private set + var sourceMetadata by mutableStateOf(null) + private set + + var title by mutableStateOf("") + var description by mutableStateOf("") + var sensitiveContent by mutableStateOf(false) + var contentWarningReason by mutableStateOf("") + var useH265 by mutableStateOf(true) + var selectedServer by mutableStateOf(null) + + private var orchestrator: HlsPublishOrchestrator? = null + private var currentJob: Job? = null + + val state: StateFlow + get() = orchestrator?.state ?: throw IllegalStateException("load() must be called first") + + fun load( + account: Account, + orchestrator: HlsPublishOrchestrator, + ) { + this.account = account + this.orchestrator = orchestrator + this.selectedServer = this.selectedServer ?: DEFAULT_MEDIA_SERVERS.first() + } + + fun onVideoPicked( + uri: Uri, + metadata: HlsSourceMetadata?, + ) { + pickedUri = uri + sourceMetadata = metadata + } + + fun clearPickedVideo() { + pickedUri = null + sourceMetadata = null + } + + fun publish(context: Context) { + val orch = orchestrator ?: return + val server = selectedServer ?: return + if (pickedUri == null) return + if (title.isBlank()) return + + val codec = effectiveCodec(useH265) + val request = + HlsPublishRequest( + title = title, + description = description, + sensitiveContent = sensitiveContent, + contentWarningReason = contentWarningReason, + codec = codec, + server = server, + durationSeconds = sourceMetadata?.durationSeconds, + ) + + currentJob = + viewModelScope.launch(Dispatchers.IO) { + orch.publish(request) + } + } + + fun cancel() { + currentJob?.cancel() + currentJob = null + orchestrator?.reset() + } + + fun reset() { + orchestrator?.reset() + } + + override fun onCleared() { + super.onCleared() + currentJob?.cancel() + } + + private fun effectiveCodec(wantH265: Boolean): VideoCodec = + if (wantH265 && CompressorUtils.isHevcEncodingSupported()) { + VideoCodec.H265 + } else { + VideoCodec.H264 + } +} + +data class HlsSourceMetadata( + val width: Int, + val height: Int, + val durationSeconds: Int, + val sizeBytes: Long, +) diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsPublishOrchestratorTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsPublishOrchestratorTest.kt new file mode 100644 index 000000000..e662d528c --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsPublishOrchestratorTest.kt @@ -0,0 +1,301 @@ +/* + * 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.VideoCodec +import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult +import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName +import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.hls.HlsPublishOrchestrator +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.hls.HlsPublishRequest +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.hls.HlsPublishState +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.io.File +import java.nio.file.Files + +class HlsPublishOrchestratorTest { + private lateinit var workDir: File + + private val server = ServerName("Test Blossom", "https://test.example/", ServerType.Blossom) + + @Before + fun setUp() { + workDir = Files.createTempDirectory("hls-orchestrator-test").toFile() + } + + @After + fun tearDown() { + workDir.deleteRecursively() + } + + private fun fakeBundle(labels: List = listOf("360p")): HlsBundle { + val renditions = + labels.map { label -> + val file = File(workDir, "$label.mp4").apply { writeText("bytes-$label") } + HlsBundleRendition( + label = label, + combinedFile = file, + mediaPlaylist = + """ + #EXTM3U + #EXT-X-MAP:URI="$label.mp4",BYTERANGE="100@0" + #EXTINF:6.0, + $label.mp4 + """.trimIndent(), + bitrateKbps = 500, + ) + } + val master = + buildString { + appendLine("#EXTM3U") + labels.forEachIndexed { i, label -> + 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 { + var count = 0 + + override suspend fun upload( + file: File, + contentType: String, + ): MediaUploadResult { + count++ + return MediaUploadResult(url = "https://cdn.test/$count", sha256 = "sha-$count", size = file.length()) + } + } + + private fun newRequest( + title: String = "My HD Clip", + description: String = "A test clip", + sensitive: Boolean = false, + warningReason: String = "", + ) = HlsPublishRequest( + title = title, + description = description, + sensitiveContent = sensitive, + contentWarningReason = warningReason, + codec = VideoCodec.H265, + server = server, + ) + + @Test + fun happyPathEndsInSuccessWithMasterUrlAndEventId() { + val publishedTemplates = mutableListOf() + val orchestrator = + HlsPublishOrchestrator( + runTranscode = { _, _, _ -> fakeBundle() }, + buildUploader = { CannedUploader() }, + signAndPublish = { tpl -> + publishedTemplates += tpl + "signed-event-id" + }, + workDirFactory = { File(workDir, "work").apply { mkdirs() } }, + ) + + runBlocking { orchestrator.publish(newRequest()) } + + val final = orchestrator.state.value + assertTrue("expected Success, was $final", final is HlsPublishState.Success) + final as HlsPublishState.Success + assertEquals("signed-event-id", final.eventId) + assertTrue("masterUrl should contain https://cdn.test/", final.masterUrl.startsWith("https://cdn.test/")) + + assertEquals(1, publishedTemplates.size) + assertTrue(publishedTemplates[0] is HlsVideoEventTemplate.Horizontal) + } + + @Test + fun statePhasesVisibleToFakesDuringPublish() { + // Capture state.value at the point each phase's fake runs — this verifies that the + // orchestrator has already transitioned into the right state before dispatching the + // corresponding dep call. + lateinit var orchestrator: HlsPublishOrchestrator + val capturedDuringTranscode = mutableListOf() + val capturedDuringUpload = mutableListOf() + val capturedDuringPublish = mutableListOf() + + orchestrator = + HlsPublishOrchestrator( + runTranscode = { _, _, onProgress -> + capturedDuringTranscode += orchestrator.state.value + onProgress("360p", 42) + capturedDuringTranscode += orchestrator.state.value + fakeBundle() + }, + buildUploader = { + capturedDuringUpload += orchestrator.state.value + CannedUploader() + }, + signAndPublish = { + capturedDuringPublish += orchestrator.state.value + "event-id" + }, + workDirFactory = { File(workDir, "work").apply { mkdirs() } }, + ) + + runBlocking { orchestrator.publish(newRequest()) } + + assertTrue(capturedDuringTranscode.all { it is HlsPublishState.Transcoding }) + assertEquals("360p", (capturedDuringTranscode.last() as HlsPublishState.Transcoding).currentLabel) + assertEquals(42, (capturedDuringTranscode.last() as HlsPublishState.Transcoding).percent) + + assertTrue(capturedDuringUpload.single() is HlsPublishState.Uploading) + assertTrue(capturedDuringPublish.single() is HlsPublishState.Publishing) + + assertTrue(orchestrator.state.value is HlsPublishState.Success) + } + + @Test + fun transcodeExceptionTransitionsToFailure() { + val orchestrator = + HlsPublishOrchestrator( + runTranscode = { _, _, _ -> throw RuntimeException("decode failed") }, + buildUploader = { CannedUploader() }, + signAndPublish = { "never" }, + workDirFactory = { File(workDir, "work").apply { mkdirs() } }, + ) + + runBlocking { orchestrator.publish(newRequest()) } + + val final = orchestrator.state.value + assertTrue("expected Failure, was $final", final is HlsPublishState.Failure) + assertEquals("decode failed", (final as HlsPublishState.Failure).message) + } + + @Test + fun uploadExceptionTransitionsToFailure() { + val orchestrator = + HlsPublishOrchestrator( + runTranscode = { _, _, _ -> fakeBundle() }, + buildUploader = { + HlsBlobUploader { _, _ -> throw RuntimeException("server 500") } + }, + signAndPublish = { "never" }, + workDirFactory = { File(workDir, "work").apply { mkdirs() } }, + ) + + runBlocking { orchestrator.publish(newRequest()) } + + val final = orchestrator.state.value + assertTrue(final is HlsPublishState.Failure) + assertEquals("server 500", (final as HlsPublishState.Failure).message) + } + + @Test + fun publishExceptionTransitionsToFailure() { + val orchestrator = + HlsPublishOrchestrator( + runTranscode = { _, _, _ -> fakeBundle() }, + buildUploader = { CannedUploader() }, + signAndPublish = { throw RuntimeException("relay rejected") }, + workDirFactory = { File(workDir, "work").apply { mkdirs() } }, + ) + + runBlocking { orchestrator.publish(newRequest()) } + + val final = orchestrator.state.value + assertTrue(final is HlsPublishState.Failure) + assertEquals("relay rejected", (final as HlsPublishState.Failure).message) + } + + @Test + fun sensitiveContentPassesContentWarningIntoTemplate() { + val captured = mutableListOf() + val orchestrator = + HlsPublishOrchestrator( + runTranscode = { _, _, _ -> fakeBundle() }, + buildUploader = { CannedUploader() }, + signAndPublish = { tpl -> + captured += tpl + "event-id" + }, + workDirFactory = { File(workDir, "work").apply { mkdirs() } }, + ) + + runBlocking { + orchestrator.publish(newRequest(sensitive = true, warningReason = "NSFW")) + } + + val template = (captured.single() as HlsVideoEventTemplate.Horizontal).template + val cw = template.tags.firstOrNull { it.isNotEmpty() && it[0] == "content-warning" } + assertNotNull(cw) + assertEquals("NSFW", cw!![1]) + } + + @Test + fun portraitBundleProducesVerticalTemplate() { + val portraitMaster = + """ + #EXTM3U + #EXT-X-STREAM-INF:BANDWIDTH=500000,RESOLUTION=360x640 + 360p/media.m3u8 + """.trimIndent() + val rendition = + HlsBundleRendition( + label = "360p", + combinedFile = File(workDir, "360p.mp4").apply { writeText("bytes") }, + mediaPlaylist = "#EXTM3U\n#EXT-X-MAP:URI=\"360p.mp4\"\n#EXTINF:6.0,\n360p.mp4\n", + bitrateKbps = 500, + ) + val captured = mutableListOf() + val orchestrator = + HlsPublishOrchestrator( + runTranscode = { _, _, _ -> HlsBundle(workDir, portraitMaster, listOf(rendition)) }, + buildUploader = { CannedUploader() }, + signAndPublish = { tpl -> + captured += tpl + "event-id" + }, + workDirFactory = { File(workDir, "work").apply { mkdirs() } }, + ) + + runBlocking { orchestrator.publish(newRequest()) } + + assertTrue(captured.single() is HlsVideoEventTemplate.Vertical) + } + + @Test + fun resetRestoresIdleState() { + val orchestrator = + HlsPublishOrchestrator( + runTranscode = { _, _, _ -> throw RuntimeException("boom") }, + buildUploader = { CannedUploader() }, + signAndPublish = { "never" }, + workDirFactory = { File(workDir, "work").apply { mkdirs() } }, + ) + + runBlocking { orchestrator.publish(newRequest()) } + assertTrue(orchestrator.state.value is HlsPublishState.Failure) + + orchestrator.reset() + assertEquals(HlsPublishState.Idle, orchestrator.state.value) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsTranscodingSessionTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsTranscodingSessionTest.kt new file mode 100644 index 000000000..98e04c7f2 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsTranscodingSessionTest.kt @@ -0,0 +1,211 @@ +/* + * 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>() + 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()) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsUploadPipelineTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsUploadPipelineTest.kt new file mode 100644 index 000000000..06a6a9951 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsUploadPipelineTest.kt @@ -0,0 +1,273 @@ +/* + * 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() + + 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>() + + 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): 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 appendsMp4HintToBareCombinedUrlInMediaPlaylist() { + val bundle = createBundle(listOf("360p")) + val uploader = BareUrlUploader() + val pipeline = HlsUploadPipeline(uploader) + + runBlocking { pipeline.upload(bundle) } + + val uploadedMediaPlaylist = uploader.calls[1].third + assertTrue( + "media playlist should reference url with .mp4 hint", + uploadedMediaPlaylist.contains("https://blossom.test/bare-1.mp4"), + ) + assertTrue(!uploadedMediaPlaylist.contains("https://blossom.test/bare-1\"")) + } + + @Test + fun appendsM3u8HintToBarePlaylistUrlInMasterPlaylist() { + val bundle = createBundle(listOf("360p")) + val uploader = BareUrlUploader() + val pipeline = HlsUploadPipeline(uploader) + + val result = runBlocking { pipeline.upload(bundle) } + + val uploadedMaster = uploader.calls[2].third + assertTrue( + "master playlist should reference playlist url with .m3u8 hint", + uploadedMaster.contains("https://blossom.test/bare-2.m3u8"), + ) + assertEquals("https://blossom.test/bare-3.m3u8", result.masterUrl) + assertEquals("https://blossom.test/bare-1.mp4", result.renditions[0].combinedUrl) + } + + @Test + fun doesNotDoubleAppendExtensionWhenAlreadyPresent() { + val bundle = createBundle(listOf("360p")) + val uploader = FakeUploader() // returns urls ending in .mp4 / .m3u8 + val pipeline = HlsUploadPipeline(uploader) + + runBlocking { pipeline.upload(bundle) } + + val uploadedMediaPlaylist = uploader.calls[1].content + assertTrue(!uploadedMediaPlaylist.contains(".mp4.mp4")) + val uploadedMaster = uploader.calls[2].content + assertTrue(!uploadedMaster.contains(".m3u8.m3u8")) + } + + @Test + fun reportsUploadProgressPerStep() { + val bundle = createBundle(listOf("360p", "540p")) + val uploader = FakeUploader() + val pipeline = HlsUploadPipeline(uploader) + val observed = mutableListOf>() + + runBlocking { + pipeline.upload(bundle) { done, total -> + observed += done to total + } + } + + // 2 renditions × 2 + 1 master = 5 uploads + assertEquals( + listOf(1 to 5, 2 to 5, 3 to 5, 4 to 5, 5 to 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) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsVideoEventBuilderTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsVideoEventBuilderTest.kt new file mode 100644 index 000000000..450fdc30e --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsVideoEventBuilderTest.kt @@ -0,0 +1,229 @@ +/* + * 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.quartz.nip71Video.VideoHorizontalEvent +import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +class HlsVideoEventBuilderTest { + private val landscapeMasterPlaylist = + """ + #EXTM3U + #EXT-X-VERSION:7 + + #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): 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 fun input( + master: String, + title: String = "My HD Video", + description: String = "A cool video", + alt: String? = null, + duration: Int? = null, + contentWarning: String? = null, + dTag: String? = "fixed-d-tag", + ): HlsVideoPublishInput { + val b = bundle(master) + return HlsVideoPublishInput( + bundle = b, + uploadResult = uploadResult(b.renditions), + title = title, + description = description, + alt = alt, + durationSeconds = duration, + contentWarning = contentWarning, + dTag = dTag, + createdAt = 1_700_000_000L, + ) + } + + private fun Array>.findTag(name: String): Array? = firstOrNull { it.isNotEmpty() && it[0] == name } + + private fun Array>.findAllTags(name: String): List> = filter { it.isNotEmpty() && it[0] == name } + + @Test + fun landscapeMasterBuildsHorizontalTemplateKind34235() { + val result = HlsVideoEventBuilder.build(input(landscapeMasterPlaylist)) + + assertTrue("expected Horizontal template", result is HlsVideoEventTemplate.Horizontal) + val template = (result as HlsVideoEventTemplate.Horizontal).template + assertEquals(VideoHorizontalEvent.KIND, template.kind) + assertEquals("A cool video", template.content) + } + + @Test + fun portraitMasterBuildsVerticalTemplateKind34236() { + val result = HlsVideoEventBuilder.build(input(portraitMasterPlaylist)) + + assertTrue("expected Vertical template", result is HlsVideoEventTemplate.Vertical) + val template = (result as HlsVideoEventTemplate.Vertical).template + assertEquals(VideoVerticalEvent.KIND, template.kind) + } + + @Test + fun horizontalTemplateHasTitleAndDTag() { + val result = HlsVideoEventBuilder.build(input(landscapeMasterPlaylist)) + val tags = (result as HlsVideoEventTemplate.Horizontal).template.tags + + val title = tags.findTag("title") + assertNotNull(title) + assertEquals("My HD Video", title!![1]) + + val d = tags.findTag("d") + assertNotNull(d) + assertEquals("fixed-d-tag", d!![1]) + } + + @Test + fun templateContainsOneImetaForMasterAndOnePerRendition() { + val result = HlsVideoEventBuilder.build(input(landscapeMasterPlaylist)) + val tags = (result as HlsVideoEventTemplate.Horizontal).template.tags + + val imetas = tags.findAllTags("imeta") + // 1 master + 2 renditions + assertEquals(3, imetas.size) + + // First imeta is the master + val masterImeta = imetas[0].joinToString("|") + assertTrue(masterImeta.contains("url https://cdn.test/master.m3u8")) + assertTrue(masterImeta.contains("m application/vnd.apple.mpegurl")) + + // Subsequent imetas are per-rendition playlist URLs + val r360Imeta = imetas[1].joinToString("|") + assertTrue("360p imeta: $r360Imeta", r360Imeta.contains("url https://cdn.test/360p-media.m3u8")) + assertTrue(r360Imeta.contains("m application/vnd.apple.mpegurl")) + assertTrue("360p dim: $r360Imeta", r360Imeta.contains("dim 640x360")) + assertTrue(r360Imeta.contains("x 360p-sha")) + + val r720Imeta = imetas[2].joinToString("|") + assertTrue("720p imeta: $r720Imeta", r720Imeta.contains("url https://cdn.test/720p-media.m3u8")) + assertTrue(r720Imeta.contains("dim 1280x720")) + } + + @Test + fun durationTagWhenDurationProvided() { + val result = + HlsVideoEventBuilder.build( + input(landscapeMasterPlaylist, duration = 123), + ) + val tags = (result as HlsVideoEventTemplate.Horizontal).template.tags + + val duration = tags.findTag("duration") + assertNotNull(duration) + assertEquals("123", duration!![1]) + } + + @Test + fun noDurationTagWhenNotProvided() { + val result = HlsVideoEventBuilder.build(input(landscapeMasterPlaylist)) + val tags = (result as HlsVideoEventTemplate.Horizontal).template.tags + assertNull(tags.findTag("duration")) + } + + @Test + fun contentWarningTagWhenProvided() { + val result = + HlsVideoEventBuilder.build( + input(landscapeMasterPlaylist, contentWarning = "NSFW"), + ) + val tags = (result as HlsVideoEventTemplate.Horizontal).template.tags + + val warning = tags.findTag("content-warning") + assertNotNull(warning) + assertEquals("NSFW", warning!![1]) + } + + @Test + fun noContentWarningTagWhenNull() { + val result = HlsVideoEventBuilder.build(input(landscapeMasterPlaylist)) + val tags = (result as HlsVideoEventTemplate.Horizontal).template.tags + assertNull(tags.findTag("content-warning")) + } + + @Test + fun horizontalTemplateCarriesAutoGeneratedAltTag() { + val result = HlsVideoEventBuilder.build(input(landscapeMasterPlaylist)) + val tags = (result as HlsVideoEventTemplate.Horizontal).template.tags + val alt = tags.findTag("alt") + assertNotNull(alt) + assertEquals(VideoHorizontalEvent.ALT_DESCRIPTION, alt!![1]) + } + + @Test + fun verticalTemplateCarriesVerticalAltTag() { + val result = HlsVideoEventBuilder.build(input(portraitMasterPlaylist)) + val tags = (result as HlsVideoEventTemplate.Vertical).template.tags + val alt = tags.findTag("alt") + assertNotNull(alt) + assertEquals(VideoVerticalEvent.ALT_DESCRIPTION, alt!![1]) + } +}