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
This commit is contained in:
@@ -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<HlsBundleRendition>,
|
||||
)
|
||||
|
||||
data class HlsBundleRendition(
|
||||
val label: String,
|
||||
val combinedFile: File,
|
||||
val mediaPlaylist: String,
|
||||
val bitrateKbps: Int,
|
||||
)
|
||||
+72
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+113
@@ -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<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)
|
||||
+147
@@ -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<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.
|
||||
*/
|
||||
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/<sha256>), 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"
|
||||
}
|
||||
}
|
||||
+137
@@ -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<VideoHorizontalEvent>,
|
||||
) : HlsVideoEventTemplate()
|
||||
|
||||
data class Vertical(
|
||||
val template: EventTemplate<VideoVerticalEvent>,
|
||||
) : 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<RenditionDimension> =
|
||||
streamInfRegex
|
||||
.findAll(masterPlaylist)
|
||||
.map { RenditionDimension(it.groupValues[1].toInt(), it.groupValues[2].toInt()) }
|
||||
.toList()
|
||||
}
|
||||
+113
@@ -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>(HlsPublishState.Idle)
|
||||
val state: StateFlow<HlsPublishState> = _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
|
||||
}
|
||||
+46
@@ -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()
|
||||
}
|
||||
+146
@@ -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<Uri?>(null)
|
||||
private set
|
||||
var sourceMetadata by mutableStateOf<HlsSourceMetadata?>(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<ServerName?>(null)
|
||||
|
||||
private var orchestrator: HlsPublishOrchestrator? = null
|
||||
private var currentJob: Job? = null
|
||||
|
||||
val state: StateFlow<HlsPublishState>
|
||||
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,
|
||||
)
|
||||
+301
@@ -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<String> = 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<HlsVideoEventTemplate>()
|
||||
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<HlsPublishState>()
|
||||
val capturedDuringUpload = mutableListOf<HlsPublishState>()
|
||||
val capturedDuringPublish = mutableListOf<HlsPublishState>()
|
||||
|
||||
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<HlsVideoEventTemplate>()
|
||||
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<HlsVideoEventTemplate>()
|
||||
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)
|
||||
}
|
||||
}
|
||||
+211
@@ -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<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())
|
||||
}
|
||||
}
|
||||
+273
@@ -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<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 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<Pair<Int, Int>>()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
+229
@@ -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<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 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<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 }
|
||||
|
||||
@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])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user