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