mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 00:16:59 +00:00
Merge pull request #2821 from davotoula/fix/hls-imeta-blurhash-thumbhash
Rich imeta on every published HLS event + auto-published kind:1 sibling
This commit is contained in:
+91
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* 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.HlsContentTypes
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.references.reference
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip71Video.blurhash
|
||||
import com.vitorpamplona.quartz.nip71Video.dims
|
||||
import com.vitorpamplona.quartz.nip71Video.hash
|
||||
import com.vitorpamplona.quartz.nip71Video.image
|
||||
import com.vitorpamplona.quartz.nip71Video.mimeType
|
||||
import com.vitorpamplona.quartz.nip71Video.thumbhash
|
||||
import com.vitorpamplona.quartz.nip92IMeta.imeta
|
||||
import com.vitorpamplona.quartz.nip92IMeta.imetaTagBuilder
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/**
|
||||
* Builds the kind:1 short-note that's published as a sibling of the NIP-71 video event from
|
||||
* Amethyst's HLS publish flow. Receivers that don't speak NIP-71 can still render a rich
|
||||
* preview (poster + dim + blurhash/thumbhash) from the kind:1 alone, while NIP-71-aware
|
||||
* receivers can hop to the addressable form via the `a` tag.
|
||||
*
|
||||
* Tag layout:
|
||||
* - One `imeta` mirroring the master rendition (url=master m3u8, m=hls, x=master sha256,
|
||||
* dim=master dim, image=poster, blurhash, thumbhash).
|
||||
* - One `r` tag with the master m3u8 URL (idiomatic kind:1 reference).
|
||||
*
|
||||
* Content mirrors the previous "Draft note" layout: `title\n\ndescription\n\nmasterUrl`,
|
||||
* blank fields skipped.
|
||||
*
|
||||
* Intentionally omits an `a` tag back-reference to the NIP-71 event: in practice Amethyst's
|
||||
* note renderer prefers the embedded addressable form when an `a` tag is present, and falls
|
||||
* back to a placeholder if the relay set hasn't returned it yet — masking the rich imeta we
|
||||
* just added. Without `a`, the kind:1 renders as a vanilla rich-imeta video note across
|
||||
* clients, which is what users expect from a shared video link.
|
||||
*/
|
||||
object HlsKind1SiblingBuilder {
|
||||
fun build(
|
||||
title: String,
|
||||
description: String,
|
||||
masterUrl: String,
|
||||
masterSha256: String?,
|
||||
masterDimension: DimensionTag?,
|
||||
posterUrl: String?,
|
||||
blurhash: String?,
|
||||
thumbhash: String?,
|
||||
createdAt: Long? = null,
|
||||
): EventTemplate<TextNoteEvent> {
|
||||
val content =
|
||||
listOf(title, description, masterUrl)
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
.joinToString("\n\n")
|
||||
|
||||
val masterImeta =
|
||||
imetaTagBuilder(masterUrl) {
|
||||
mimeType(HlsContentTypes.HLS_PLAYLIST)
|
||||
masterSha256?.let { hash(it) }
|
||||
masterDimension?.let { dims(it) }
|
||||
posterUrl?.let { image(it) }
|
||||
blurhash?.let { this.blurhash(it) }
|
||||
thumbhash?.let { this.thumbhash(it) }
|
||||
}
|
||||
|
||||
return TextNoteEvent.build(content, createdAt ?: TimeUtils.now()) {
|
||||
imeta(masterImeta)
|
||||
reference(masterUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
+40
-20
@@ -54,6 +54,9 @@ data class HlsVideoPublishInput(
|
||||
// (gallery thumbnails, previews) have a still to render — the .m3u8 playlist itself is a
|
||||
// text manifest that can't be decoded as an image frame.
|
||||
val posterUrl: String? = null,
|
||||
// Identical for every rendition since the source frame is the same.
|
||||
val blurhash: String? = null,
|
||||
val thumbhash: String? = null,
|
||||
)
|
||||
|
||||
sealed class HlsVideoEventTemplate {
|
||||
@@ -66,6 +69,16 @@ sealed class HlsVideoEventTemplate {
|
||||
) : HlsVideoEventTemplate()
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of [HlsVideoEventBuilder.build]. Exposes the unsigned NIP-71 [template] plus the
|
||||
* master [masterDimension] (largest rendition's WxH) so the kind:1 sibling imeta can carry
|
||||
* the same dim as the NIP-71 master imeta.
|
||||
*/
|
||||
data class HlsBuiltTemplate(
|
||||
val template: HlsVideoEventTemplate,
|
||||
val masterDimension: DimensionTag?,
|
||||
)
|
||||
|
||||
/**
|
||||
* Assembles a NIP-71 VideoHorizontalEvent / VideoVerticalEvent template from an HLS upload
|
||||
* result. Orientation is decided from the first rendition's width/height: portrait
|
||||
@@ -81,7 +94,7 @@ sealed class HlsVideoEventTemplate {
|
||||
*/
|
||||
@OptIn(ExperimentalUuidApi::class)
|
||||
object HlsVideoEventBuilder {
|
||||
fun build(input: HlsVideoPublishInput): HlsVideoEventTemplate {
|
||||
fun build(input: HlsVideoPublishInput): HlsBuiltTemplate {
|
||||
val firstRendition = input.renditions.firstOrNull()
|
||||
val isVertical = firstRendition != null && firstRendition.height > firstRendition.width
|
||||
|
||||
@@ -96,6 +109,8 @@ object HlsVideoEventBuilder {
|
||||
dimension = masterDimension,
|
||||
alt = input.alt,
|
||||
image = posterImage,
|
||||
blurhash = input.blurhash,
|
||||
thumbhash = input.thumbhash,
|
||||
)
|
||||
|
||||
val renditionMetas =
|
||||
@@ -115,6 +130,8 @@ object HlsVideoEventBuilder {
|
||||
size = combinedMetadata?.size?.toInt(),
|
||||
dimension = DimensionTag(summary.width, summary.height),
|
||||
image = posterImage,
|
||||
blurhash = input.blurhash,
|
||||
thumbhash = input.thumbhash,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -122,24 +139,27 @@ object HlsVideoEventBuilder {
|
||||
val dTag = input.dTag ?: Uuid.random().toString()
|
||||
val createdAt = input.createdAt ?: TimeUtils.now()
|
||||
|
||||
return if (isVertical) {
|
||||
HlsVideoEventTemplate.Vertical(
|
||||
VideoVerticalEvent.build(input.description, dTag, createdAt) {
|
||||
videoIMetas(videoMetas)
|
||||
title(input.title)
|
||||
input.durationSeconds?.let { duration(it) }
|
||||
input.contentWarning?.let { contentWarning(it) }
|
||||
},
|
||||
)
|
||||
} else {
|
||||
HlsVideoEventTemplate.Horizontal(
|
||||
VideoHorizontalEvent.build(input.description, dTag, createdAt) {
|
||||
videoIMetas(videoMetas)
|
||||
title(input.title)
|
||||
input.durationSeconds?.let { duration(it) }
|
||||
input.contentWarning?.let { contentWarning(it) }
|
||||
},
|
||||
)
|
||||
}
|
||||
val template =
|
||||
if (isVertical) {
|
||||
HlsVideoEventTemplate.Vertical(
|
||||
VideoVerticalEvent.build(input.description, dTag, createdAt) {
|
||||
videoIMetas(videoMetas)
|
||||
title(input.title)
|
||||
input.durationSeconds?.let { duration(it) }
|
||||
input.contentWarning?.let { contentWarning(it) }
|
||||
},
|
||||
)
|
||||
} else {
|
||||
HlsVideoEventTemplate.Horizontal(
|
||||
VideoHorizontalEvent.build(input.description, dTag, createdAt) {
|
||||
videoIMetas(videoMetas)
|
||||
title(input.title)
|
||||
input.durationSeconds?.let { duration(it) }
|
||||
input.contentWarning?.let { contentWarning(it) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return HlsBuiltTemplate(template, masterDimension)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-14
@@ -40,13 +40,13 @@ import androidx.media3.common.util.UnstableApi
|
||||
import coil3.compose.AsyncImagePainter
|
||||
import coil3.compose.SubcomposeAsyncImage
|
||||
import coil3.compose.SubcomposeAsyncImageContent
|
||||
import com.davotoula.lightcompressor.hls.HlsContentTypes
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlContent
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser.Companion.isHlsMimeType
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser.Companion.isVideoUrl
|
||||
import com.vitorpamplona.amethyst.commons.richtext.toCoilModel
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
@@ -69,19 +69,6 @@ import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent
|
||||
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
|
||||
import com.vitorpamplona.quartz.nip71Video.VideoEvent
|
||||
|
||||
// Mirrors the canonical HLS-playlist mime list used in MediaItemCache.toExoPlayerMimeType.
|
||||
// Kept inline rather than extracting a shared helper for one read-side caller.
|
||||
private fun isHlsMimeType(mimeType: String?): Boolean =
|
||||
when (mimeType?.lowercase()) {
|
||||
HlsContentTypes.HLS_PLAYLIST,
|
||||
"application/x-mpegurl",
|
||||
"audio/x-mpegurl",
|
||||
"audio/mpegurl",
|
||||
-> true
|
||||
|
||||
else -> false
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GalleryThumbnail(
|
||||
baseNote: Note,
|
||||
|
||||
+39
-6
@@ -31,10 +31,13 @@ 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.HlsKind1SiblingBuilder
|
||||
import com.vitorpamplona.amethyst.service.uploads.hls.HlsVideoEventBuilder
|
||||
import com.vitorpamplona.amethyst.service.uploads.hls.HlsVideoEventTemplate
|
||||
import com.vitorpamplona.amethyst.service.uploads.hls.HlsVideoPublishInput
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -55,6 +58,18 @@ data class HlsPublishRequest(
|
||||
val durationSeconds: Int? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Poster JPEG upload result. Carries the public URL plus blurhash/thumbhash derived from the
|
||||
* same poster pixels, so every imeta in the published NIP-71 event can render an instant
|
||||
* low-res placeholder. All three fields share a single source frame; per-rendition variants
|
||||
* would be redundant.
|
||||
*/
|
||||
data class HlsPosterUpload(
|
||||
val url: String,
|
||||
val blurhash: String? = null,
|
||||
val thumbhash: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -76,12 +91,17 @@ class HlsPublishOrchestrator(
|
||||
) -> HlsUploadResult<MediaUploadResult>,
|
||||
private val buildUploader: (ServerName) -> HlsBlobUploader,
|
||||
private val uploadMaster: suspend (HlsBlobUploader, String) -> MediaUploadResult,
|
||||
private val signAndPublish: suspend (HlsVideoEventTemplate) -> String,
|
||||
// Signs and broadcasts the NIP-71 video event AND the kind:1 sibling note. Returns the
|
||||
// NIP-71 event id.
|
||||
private val signAndPublish: suspend (
|
||||
primary: HlsVideoEventTemplate,
|
||||
sibling: EventTemplate<TextNoteEvent>,
|
||||
) -> String,
|
||||
// Generates a poster JPEG from the picked source video and uploads it via the supplied
|
||||
// uploader, returning the public URL. Returns null if poster generation isn't possible
|
||||
// (unsupported source, decode failure, no readable frame). Failures here must NOT fail
|
||||
// the whole publish — the orchestrator catches and continues without a poster.
|
||||
private val uploadPoster: suspend (HlsBlobUploader) -> String? = { _ -> null },
|
||||
private val uploadPoster: suspend (HlsBlobUploader) -> HlsPosterUpload? = { _ -> null },
|
||||
) {
|
||||
val state: StateFlow<HlsPublishState> = _state
|
||||
|
||||
@@ -208,7 +228,7 @@ class HlsPublishOrchestrator(
|
||||
// previews) have a still to render. Tolerate failure: skip the poster rather than
|
||||
// failing the entire publish, since the user has already paid the cost of the long
|
||||
// segment uploads.
|
||||
val posterUrl =
|
||||
val posterResult =
|
||||
try {
|
||||
uploadPoster(uploader)
|
||||
} catch (e: CancellationException) {
|
||||
@@ -219,7 +239,7 @@ class HlsPublishOrchestrator(
|
||||
}
|
||||
|
||||
_state.value = HlsPublishState.Publishing
|
||||
val template =
|
||||
val built =
|
||||
HlsVideoEventBuilder.build(
|
||||
HlsVideoPublishInput(
|
||||
renditions = uploadResult.renditions,
|
||||
@@ -230,10 +250,23 @@ class HlsPublishOrchestrator(
|
||||
description = request.description,
|
||||
durationSeconds = request.durationSeconds,
|
||||
contentWarning = contentWarningOrNull(request),
|
||||
posterUrl = posterUrl,
|
||||
posterUrl = posterResult?.url,
|
||||
blurhash = posterResult?.blurhash,
|
||||
thumbhash = posterResult?.thumbhash,
|
||||
),
|
||||
)
|
||||
val eventId = signAndPublish(template)
|
||||
val sibling =
|
||||
HlsKind1SiblingBuilder.build(
|
||||
title = request.title,
|
||||
description = request.description,
|
||||
masterUrl = masterUrl,
|
||||
masterSha256 = masterUpload.sha256,
|
||||
masterDimension = built.masterDimension,
|
||||
posterUrl = posterResult?.url,
|
||||
blurhash = posterResult?.blurhash,
|
||||
thumbhash = posterResult?.thumbhash,
|
||||
)
|
||||
val eventId = signAndPublish(built.template, sibling)
|
||||
|
||||
_state.value =
|
||||
HlsPublishState.Success(
|
||||
|
||||
+38
-5
@@ -28,11 +28,13 @@ import com.davotoula.lightcompressor.hls.HlsContentTypes
|
||||
import com.davotoula.lightcompressor.hls.HlsUploadHelper
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult
|
||||
import com.vitorpamplona.amethyst.service.uploads.PreviewMetadataCalculator
|
||||
import com.vitorpamplona.amethyst.service.uploads.getThumbnail
|
||||
import com.vitorpamplona.amethyst.service.uploads.hls.HlsBlobUploader
|
||||
import com.vitorpamplona.amethyst.service.uploads.hls.HlsBlobUploaderFactory
|
||||
import com.vitorpamplona.amethyst.service.uploads.hls.HlsVideoEventTemplate
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -87,7 +89,7 @@ fun createProductionHlsPublishOrchestrator(
|
||||
}
|
||||
}
|
||||
},
|
||||
signAndPublish = { template ->
|
||||
signAndPublish = { template, sibling ->
|
||||
val inner =
|
||||
when (template) {
|
||||
is HlsVideoEventTemplate.Horizontal -> template.template
|
||||
@@ -95,6 +97,17 @@ fun createProductionHlsPublishOrchestrator(
|
||||
}
|
||||
val signed = account.signer.sign(inner)
|
||||
account.sendAutomatic(signed)
|
||||
|
||||
// Sibling-publish failure is a soft warning: the NIP-71 event already landed, so a
|
||||
// partial success is more useful than a hard fail.
|
||||
try {
|
||||
val signedSibling = account.signer.sign(sibling)
|
||||
account.sendAutomatic(signedSibling)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Throwable) {
|
||||
Log.w(TAG) { "kind:1 sibling sign/publish failed: ${e.message}" }
|
||||
}
|
||||
signed.id
|
||||
},
|
||||
uploadPoster = { uploader ->
|
||||
@@ -108,8 +121,9 @@ fun createProductionHlsPublishOrchestrator(
|
||||
)
|
||||
|
||||
/**
|
||||
* Extracts a still frame from the source video at [uri], encodes it as JPEG, and uploads it
|
||||
* via [uploader]. Returns the public URL on success or null if any step fails (unsupported
|
||||
* Extracts a still frame from the source video at [uri], encodes it as JPEG, computes blurhash +
|
||||
* thumbhash from the same JPEG bytes, and uploads it via [uploader]. Returns an [HlsPosterUpload]
|
||||
* carrying the public URL plus the two hashes on success, or null if any step fails (unsupported
|
||||
* source, no readable frame, encode/upload error). The orchestrator treats null as "publish
|
||||
* without a poster" — failure here must never abort the publish.
|
||||
*/
|
||||
@@ -117,11 +131,28 @@ private suspend fun generateAndUploadPoster(
|
||||
context: Context,
|
||||
uri: Uri,
|
||||
uploader: HlsBlobUploader,
|
||||
): String? {
|
||||
): HlsPosterUpload? {
|
||||
val posterFile = extractPosterToTempFile(context, uri) ?: return null
|
||||
return try {
|
||||
// Read once: hash from the same bytes we upload, so the hashes describe the exact
|
||||
// JPEG receiving clients will fetch via the imeta `image` URL.
|
||||
val posterBytes = posterFile.readBytes()
|
||||
val hashes =
|
||||
try {
|
||||
PreviewMetadataCalculator.computeFromBytes(posterBytes, POSTER_CONTENT_TYPE, null)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG) { "uploadPoster: blurhash/thumbhash compute failed: ${e.message}" }
|
||||
null
|
||||
}
|
||||
val result = uploader.upload(posterFile, POSTER_CONTENT_TYPE) { _, _ -> }
|
||||
result.url
|
||||
val url = result.url ?: return null
|
||||
HlsPosterUpload(
|
||||
url = url,
|
||||
blurhash = hashes?.blurhash?.blurhash,
|
||||
thumbhash = hashes?.thumbhash?.thumbhash,
|
||||
)
|
||||
} finally {
|
||||
if (!posterFile.delete()) {
|
||||
Log.w(TAG) { "uploadPoster: failed to delete temp file ${posterFile.absolutePath}" }
|
||||
@@ -155,6 +186,8 @@ private suspend fun extractPosterToTempFile(
|
||||
}
|
||||
throw e
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG) { "extractPosterToTempFile: failed for $uri — ${e.message}" }
|
||||
null
|
||||
|
||||
+8
-70
@@ -83,7 +83,6 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.ui.components.TextSpinner
|
||||
import com.vitorpamplona.amethyst.ui.components.TitleExplainer
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
@@ -323,31 +322,6 @@ private fun FormFields(vm: NewHlsVideoViewModel) {
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
// Draft-a-note-after-upload toggle — opens the existing short-note composer prefilled
|
||||
// with the title, description and master playlist URL; the user edits and posts.
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = stringResource(R.string.hls_draft_note_after_upload),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.hls_draft_note_after_upload_explainer),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = vm.draftNoteAfterUpload,
|
||||
onCheckedChange = { vm.draftNoteAfterUpload = it },
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// Server picker — reads the user's configured Blossom servers from the account
|
||||
@@ -686,54 +660,18 @@ private fun SuccessBody(
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
if (vm.draftNoteAfterUpload) {
|
||||
Button(
|
||||
onClick = {
|
||||
val draft = buildDraftNoteText(vm.title, vm.description, state.masterUrl)
|
||||
vm.reset()
|
||||
// Pop the HLS publish screen off the back stack as we open the composer,
|
||||
// so that after the user posts (or backs out of) the draft they land on
|
||||
// the screen they came from, not back on the HLS publish flow.
|
||||
nav.popUpTo(Route.NewShortNote(message = draft), Route.NewHlsVideo::class)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(stringResource(R.string.hls_draft_note_button))
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
vm.reset()
|
||||
nav.popBack()
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(stringResource(R.string.hls_done))
|
||||
}
|
||||
} else {
|
||||
Button(
|
||||
onClick = {
|
||||
vm.reset()
|
||||
nav.popBack()
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(stringResource(R.string.hls_done))
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
vm.reset()
|
||||
nav.popBack()
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(stringResource(R.string.hls_done))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildDraftNoteText(
|
||||
title: String,
|
||||
description: String,
|
||||
masterUrl: String,
|
||||
): String =
|
||||
listOf(title, description, masterUrl)
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
.joinToString("\n\n")
|
||||
|
||||
@Composable
|
||||
private fun FailureBody(
|
||||
vm: NewHlsVideoViewModel,
|
||||
|
||||
-1
@@ -64,7 +64,6 @@ open class NewHlsVideoViewModel : ViewModel() {
|
||||
var sensitiveContent by mutableStateOf(false)
|
||||
var contentWarningReason by mutableStateOf("")
|
||||
var useH265 by mutableStateOf(true)
|
||||
var draftNoteAfterUpload by mutableStateOf(true)
|
||||
var selectedServer by mutableStateOf<ServerName?>(null)
|
||||
|
||||
var selectedRenditionLabels by mutableStateOf(
|
||||
|
||||
@@ -2295,9 +2295,6 @@
|
||||
<string name="hls_view_note">Zobrazit poznámku</string>
|
||||
<string name="hls_done">Hotovo</string>
|
||||
<string name="hls_try_again">Zkusit znovu</string>
|
||||
<string name="hls_draft_note_after_upload">Vytvořit poznámku po nahrání</string>
|
||||
<string name="hls_draft_note_after_upload_explainer">Otevře editor poznámky předvyplněný titulkem, popisem a odkazem na video, abyste ho mohli upravit před odesláním.</string>
|
||||
<string name="hls_draft_note_button">Vytvořit poznámku</string>
|
||||
<string name="pack_actions_dialog_title">Akce balíčku</string>
|
||||
<string name="list_actions_dialog_title">Akce seznamu</string>
|
||||
<string name="bookmark_item_actions_dialog_title">Akce záložky</string>
|
||||
|
||||
@@ -2275,9 +2275,6 @@ anz der Bedingungen ist erforderlich</string>
|
||||
<string name="hls_view_note">Notiz anzeigen</string>
|
||||
<string name="hls_done">Fertig</string>
|
||||
<string name="hls_try_again">Erneut versuchen</string>
|
||||
<string name="hls_draft_note_after_upload">Notiz nach Upload entwerfen</string>
|
||||
<string name="hls_draft_note_after_upload_explainer">Öffnet den Notiz-Editor mit Titel, Beschreibung und Video-Link vorausgefüllt, damit du sie vor dem Posten anpassen kannst.</string>
|
||||
<string name="hls_draft_note_button">Notiz entwerfen</string>
|
||||
<string name="pack_actions_dialog_title">Paket-Aktionen</string>
|
||||
<string name="list_actions_dialog_title">Listenaktionen</string>
|
||||
<string name="bookmark_item_actions_dialog_title">Lesezeichen-Aktionen</string>
|
||||
|
||||
@@ -2317,9 +2317,6 @@
|
||||
<string name="hls_view_note">टीका देखें</string>
|
||||
<string name="hls_done">हो गया</string>
|
||||
<string name="hls_try_again">पुनः प्रयास करें</string>
|
||||
<string name="hls_draft_note_after_upload">टीका सम्पादन आरोहण पश्चात</string>
|
||||
<string name="hls_draft_note_after_upload_explainer">टीका सम्पादक खोलें शीर्षक विवरण तथा दृश्याभिलेख योजक जानकारी से पूर्वयुक्त जिसे आप शोधन कर सकते है प्रकाशन पूर्व।</string>
|
||||
<string name="hls_draft_note_button">टीका सम्पादन</string>
|
||||
<string name="pack_actions_dialog_title">पोटली कार्य</string>
|
||||
<string name="list_actions_dialog_title">सूची कार्य</string>
|
||||
<string name="bookmark_item_actions_dialog_title">स्मर्त्तव्यचिह्न कार्य</string>
|
||||
|
||||
@@ -2316,9 +2316,6 @@
|
||||
<string name="hls_view_note">Bejegyzés megtekintése</string>
|
||||
<string name="hls_done">Kész</string>
|
||||
<string name="hls_try_again">Próbálja újra</string>
|
||||
<string name="hls_draft_note_after_upload">Bejegyzéspiszkozat feltöltés után</string>
|
||||
<string name="hls_draft_note_after_upload_explainer">Bejegyzésszerkesztő megnyitása a címmel, leírással és videóhivatkozásokkal előre kitöltve, hogy a közzététel előtt finomíthasson rajta.</string>
|
||||
<string name="hls_draft_note_button">Bejegyzéspiszkozat</string>
|
||||
<string name="pack_actions_dialog_title">Csomagműveletek</string>
|
||||
<string name="list_actions_dialog_title">Listaműveletek</string>
|
||||
<string name="bookmark_item_actions_dialog_title">Könyvjelző-műveletek</string>
|
||||
|
||||
@@ -2084,9 +2084,6 @@
|
||||
<string name="hls_view_note">Bericht bekijken</string>
|
||||
<string name="hls_done">Klaar</string>
|
||||
<string name="hls_try_again">Opnieuw proberen</string>
|
||||
<string name="hls_draft_note_after_upload">Conceptnotitie na upload</string>
|
||||
<string name="hls_draft_note_after_upload_explainer">Open de notitie-editor met titel, omschrijving en videolink voorgeladen zodat je het kunt aanpassen voordat je plaatst.</string>
|
||||
<string name="hls_draft_note_button">Conceptnotitie</string>
|
||||
<string name="pack_actions_dialog_title">Pakket-acties</string>
|
||||
<string name="list_actions_dialog_title">Lijst-acties</string>
|
||||
<string name="bookmark_item_actions_dialog_title">Bladwijzer-acties</string>
|
||||
|
||||
@@ -2339,9 +2339,6 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
|
||||
<string name="hls_view_note">Zobacz publikację</string>
|
||||
<string name="hls_done">Gotowe</string>
|
||||
<string name="hls_try_again">Spróbuj ponownie</string>
|
||||
<string name="hls_draft_note_after_upload">Szkic publikacji po wgraniu</string>
|
||||
<string name="hls_draft_note_after_upload_explainer">Otwórz edytor publikacji z gotowym tytułem, opisem i linkiem do filmu, abyś mógł wprowadzić zmiany przed opublikowaniem.</string>
|
||||
<string name="hls_draft_note_button">Szkic publikacji</string>
|
||||
<string name="pack_actions_dialog_title">Akcje pakietu</string>
|
||||
<string name="list_actions_dialog_title">Akcje listy</string>
|
||||
<string name="bookmark_item_actions_dialog_title">Akcje zakładki</string>
|
||||
|
||||
@@ -2271,9 +2271,6 @@
|
||||
<string name="hls_view_note">Ver nota</string>
|
||||
<string name="hls_done">Concluído</string>
|
||||
<string name="hls_try_again">Tentar novamente</string>
|
||||
<string name="hls_draft_note_after_upload">Rascunho de nota após upload</string>
|
||||
<string name="hls_draft_note_after_upload_explainer">Abre o compositor de nota pré-preenchido com o título, descrição e link do vídeo para que você possa ajustá-lo antes de publicar.</string>
|
||||
<string name="hls_draft_note_button">Rascunhar nota</string>
|
||||
<string name="pack_actions_dialog_title">Ações do pacote</string>
|
||||
<string name="list_actions_dialog_title">Ações da lista</string>
|
||||
<string name="bookmark_item_actions_dialog_title">Ações de favorito</string>
|
||||
|
||||
@@ -2243,9 +2243,6 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
||||
<string name="hls_view_note">Poglej zapisek</string>
|
||||
<string name="hls_done">Končano</string>
|
||||
<string name="hls_try_again">Poskusi znova</string>
|
||||
<string name="hls_draft_note_after_upload">Osnutek zapiska po nalaganju</string>
|
||||
<string name="hls_draft_note_after_upload_explainer">Odpri urejevalnik z že izpolnjenim naslovom, opisom in povezavo do videa, da ga lahko pred objavo še dodelaš.</string>
|
||||
<string name="hls_draft_note_button">Osnutek zapiska</string>
|
||||
<string name="pack_actions_dialog_title">Možnosti paketa</string>
|
||||
<string name="list_actions_dialog_title">Možnosti seznama</string>
|
||||
<string name="bookmark_item_actions_dialog_title">Možnosti zaznamkov</string>
|
||||
|
||||
@@ -2270,9 +2270,6 @@
|
||||
<string name="hls_view_note">Visa anteckning</string>
|
||||
<string name="hls_done">Klar</string>
|
||||
<string name="hls_try_again">Försök igen</string>
|
||||
<string name="hls_draft_note_after_upload">Utkast till anteckning efter uppladdning</string>
|
||||
<string name="hls_draft_note_after_upload_explainer">Öppnar anteckningskompositören förifylld med titel, beskrivning och videolänk så att du kan justera den innan publicering.</string>
|
||||
<string name="hls_draft_note_button">Utkast till anteckning</string>
|
||||
<string name="pack_actions_dialog_title">Paketåtgärder</string>
|
||||
<string name="list_actions_dialog_title">Liståtgärder</string>
|
||||
<string name="bookmark_item_actions_dialog_title">Bokmärkesåtgärder</string>
|
||||
|
||||
@@ -2303,9 +2303,6 @@
|
||||
<string name="hls_view_note">查看笔记</string>
|
||||
<string name="hls_done">完成</string>
|
||||
<string name="hls_try_again">重试</string>
|
||||
<string name="hls_draft_note_after_upload">上传后的笔记草稿</string>
|
||||
<string name="hls_draft_note_after_upload_explainer">打开预填了标题、描述和视频链接的笔记,以便您可以在发布之前调整它。</string>
|
||||
<string name="hls_draft_note_button">起草笔记</string>
|
||||
<string name="pack_actions_dialog_title">包操作</string>
|
||||
<string name="list_actions_dialog_title">列表操作</string>
|
||||
<string name="bookmark_item_actions_dialog_title">书签操作</string>
|
||||
|
||||
@@ -2586,9 +2586,6 @@
|
||||
<string name="hls_view_note">View note</string>
|
||||
<string name="hls_done">Done</string>
|
||||
<string name="hls_try_again">Try again</string>
|
||||
<string name="hls_draft_note_after_upload">Draft note after upload</string>
|
||||
<string name="hls_draft_note_after_upload_explainer">Open the note composer pre-filled with the title, description and video link so you can tweak it before posting.</string>
|
||||
<string name="hls_draft_note_button">Draft note</string>
|
||||
|
||||
<string name="pack_actions_dialog_title">Pack Actions</string>
|
||||
<string name="list_actions_dialog_title">List Actions</string>
|
||||
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.uploads.hls
|
||||
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class HlsKind1SiblingBuilderTest {
|
||||
private val masterUrl = "https://cdn.test/master.m3u8"
|
||||
private val masterSha = "ffeeddccbbaa00112233445566778899aabbccddeeff00112233445566778899"
|
||||
private val posterUrl = "https://cdn.test/poster.jpg"
|
||||
private val blurhash = "LFE.@D9F01_2~q%2tRj["
|
||||
private val thumbhash = "wJlGAA"
|
||||
private val masterDim = DimensionTag(2160, 3840)
|
||||
|
||||
private fun build(
|
||||
title: String = "My HD video",
|
||||
description: String = "A clip",
|
||||
masterSha256: String? = masterSha,
|
||||
masterDimension: DimensionTag? = masterDim,
|
||||
posterUrlArg: String? = posterUrl,
|
||||
blurhashArg: String? = blurhash,
|
||||
thumbhashArg: String? = thumbhash,
|
||||
createdAt: Long? = 1_700_000_000L,
|
||||
) = HlsKind1SiblingBuilder.build(
|
||||
title = title,
|
||||
description = description,
|
||||
masterUrl = masterUrl,
|
||||
masterSha256 = masterSha256,
|
||||
masterDimension = masterDimension,
|
||||
posterUrl = posterUrlArg,
|
||||
blurhash = blurhashArg,
|
||||
thumbhash = thumbhashArg,
|
||||
createdAt = createdAt,
|
||||
)
|
||||
|
||||
private fun Array<Array<String>>.findTag(name: String): Array<String>? = firstOrNull { it.isNotEmpty() && it[0] == name }
|
||||
|
||||
@Test
|
||||
fun kindIs1() {
|
||||
val template = build()
|
||||
assertEquals(TextNoteEvent.KIND, template.kind)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun contentJoinsTitleDescriptionMasterUrlOnDoubleNewline() {
|
||||
val template = build(title = "T", description = "D")
|
||||
assertEquals("T\n\nD\n\n$masterUrl", template.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun blankFieldsAreOmittedFromContent() {
|
||||
val template = build(title = "", description = " ")
|
||||
assertEquals(masterUrl, template.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fieldsAreTrimmed() {
|
||||
val template = build(title = " T ", description = "\nD\n")
|
||||
assertEquals("T\n\nD\n\n$masterUrl", template.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun masterImetaCarriesAllVisualFields() {
|
||||
val template = build()
|
||||
val imeta = template.tags.findTag("imeta")
|
||||
assertNotNull(imeta)
|
||||
val flat = imeta!!.joinToString("|")
|
||||
assertTrue("url: $flat", flat.contains("url $masterUrl"))
|
||||
assertTrue("mime: $flat", flat.contains("m application/vnd.apple.mpegurl"))
|
||||
assertTrue("hash: $flat", flat.contains("x $masterSha"))
|
||||
assertTrue("dim: $flat", flat.contains("dim 2160x3840"))
|
||||
assertTrue("image: $flat", flat.contains("image $posterUrl"))
|
||||
assertTrue("blurhash: $flat", flat.contains("blurhash $blurhash"))
|
||||
assertTrue("thumbhash: $flat", flat.contains("thumbhash $thumbhash"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nullVisualFieldsAreOmittedFromImeta() {
|
||||
val template =
|
||||
build(
|
||||
masterSha256 = null,
|
||||
masterDimension = null,
|
||||
posterUrlArg = null,
|
||||
blurhashArg = null,
|
||||
thumbhashArg = null,
|
||||
)
|
||||
val imeta = template.tags.findTag("imeta")!!
|
||||
val flat = imeta.joinToString("|")
|
||||
assertTrue("url is required: $flat", flat.contains("url $masterUrl"))
|
||||
assertTrue("mime is always present: $flat", flat.contains("m application/vnd.apple.mpegurl"))
|
||||
assertFalse("no hash: $flat", flat.contains("x "))
|
||||
assertFalse("no dim: $flat", flat.contains("dim "))
|
||||
assertFalse("no image: $flat", flat.contains("image "))
|
||||
assertFalse("no blurhash: $flat", flat.contains("blurhash "))
|
||||
assertFalse("no thumbhash: $flat", flat.contains("thumbhash "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rTagCarriesMasterUrl() {
|
||||
val template = build()
|
||||
val ref = template.tags.findTag("r")
|
||||
assertNotNull(ref)
|
||||
assertEquals(masterUrl, ref!![1])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noATagIsEmitted() {
|
||||
val template = build()
|
||||
assertNull(template.tags.findTag("a"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun createdAtUsesProvidedValue() {
|
||||
val template = build(createdAt = 12_345L)
|
||||
assertEquals(12_345L, template.createdAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun createdAtDefaultsToNowWhenNull() {
|
||||
val before = System.currentTimeMillis() / 1_000
|
||||
val template = build(createdAt = null)
|
||||
val after = System.currentTimeMillis() / 1_000
|
||||
assertTrue(template.createdAt in before..after)
|
||||
}
|
||||
}
|
||||
+24
-12
@@ -30,9 +30,12 @@ import com.davotoula.lightcompressor.hls.Rendition
|
||||
import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.hls.HlsPosterUpload
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.hls.HlsPublishOrchestrator
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.hls.HlsPublishRequest
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.hls.HlsPublishState
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
@@ -160,7 +163,7 @@ class HlsPublishOrchestratorTest {
|
||||
runUpload = fakeRunUpload(),
|
||||
buildUploader = { canned },
|
||||
uploadMaster = fakeUploadMaster(canned),
|
||||
signAndPublish = { tpl ->
|
||||
signAndPublish = { tpl, _ ->
|
||||
publishedTemplates += tpl
|
||||
"signed-event-id"
|
||||
},
|
||||
@@ -220,7 +223,7 @@ class HlsPublishOrchestratorTest {
|
||||
runUpload = capturingRunUpload,
|
||||
buildUploader = { canned },
|
||||
uploadMaster = fakeUploadMaster(canned),
|
||||
signAndPublish = {
|
||||
signAndPublish = { _, _ ->
|
||||
capturedDuringPublish += orchestrator.state.value
|
||||
"event-id"
|
||||
},
|
||||
@@ -246,7 +249,7 @@ class HlsPublishOrchestratorTest {
|
||||
runUpload = { _, _, _ -> throw RuntimeException("decode failed") },
|
||||
buildUploader = { CannedUploader() },
|
||||
uploadMaster = { _, _ -> MediaUploadResult(url = "never") },
|
||||
signAndPublish = { "never" },
|
||||
signAndPublish = { _, _ -> "never" },
|
||||
)
|
||||
|
||||
runBlocking { orchestrator.publish(newRequest()) }
|
||||
@@ -266,7 +269,7 @@ class HlsPublishOrchestratorTest {
|
||||
HlsBlobUploader { _, _, _ -> throw RuntimeException("server 500") }
|
||||
},
|
||||
uploadMaster = { _, _ -> MediaUploadResult(url = "never") },
|
||||
signAndPublish = { "never" },
|
||||
signAndPublish = { _, _ -> "never" },
|
||||
)
|
||||
|
||||
runBlocking { orchestrator.publish(newRequest()) }
|
||||
@@ -285,7 +288,7 @@ class HlsPublishOrchestratorTest {
|
||||
runUpload = fakeRunUpload(),
|
||||
buildUploader = { canned },
|
||||
uploadMaster = { _, _ -> throw RuntimeException("master upload failed") },
|
||||
signAndPublish = { "never" },
|
||||
signAndPublish = { _, _ -> "never" },
|
||||
)
|
||||
|
||||
runBlocking { orchestrator.publish(newRequest()) }
|
||||
@@ -304,7 +307,7 @@ class HlsPublishOrchestratorTest {
|
||||
runUpload = fakeRunUpload(),
|
||||
buildUploader = { canned },
|
||||
uploadMaster = fakeUploadMaster(canned),
|
||||
signAndPublish = { throw RuntimeException("relay rejected") },
|
||||
signAndPublish = { _, _ -> throw RuntimeException("relay rejected") },
|
||||
)
|
||||
|
||||
runBlocking { orchestrator.publish(newRequest()) }
|
||||
@@ -324,7 +327,7 @@ class HlsPublishOrchestratorTest {
|
||||
runUpload = fakeRunUpload(),
|
||||
buildUploader = { canned },
|
||||
uploadMaster = fakeUploadMaster(canned),
|
||||
signAndPublish = { tpl ->
|
||||
signAndPublish = { tpl, _ ->
|
||||
captured += tpl
|
||||
"event-id"
|
||||
},
|
||||
@@ -362,7 +365,7 @@ class HlsPublishOrchestratorTest {
|
||||
runUpload = fakeRunUpload(portrait),
|
||||
buildUploader = { canned },
|
||||
uploadMaster = fakeUploadMaster(canned),
|
||||
signAndPublish = { tpl ->
|
||||
signAndPublish = { tpl, _ ->
|
||||
captured += tpl
|
||||
"event-id"
|
||||
},
|
||||
@@ -376,6 +379,7 @@ class HlsPublishOrchestratorTest {
|
||||
@Test
|
||||
fun posterUrlFromUploadPosterClosureLandsOnEveryImeta() {
|
||||
val captured = mutableListOf<HlsVideoEventTemplate>()
|
||||
val capturedSiblings = mutableListOf<EventTemplate<TextNoteEvent>>()
|
||||
val canned = CannedUploader()
|
||||
val orchestrator =
|
||||
HlsPublishOrchestrator(
|
||||
@@ -383,11 +387,12 @@ class HlsPublishOrchestratorTest {
|
||||
runUpload = fakeRunUpload(),
|
||||
buildUploader = { canned },
|
||||
uploadMaster = fakeUploadMaster(canned),
|
||||
signAndPublish = { tpl ->
|
||||
signAndPublish = { tpl, sibling ->
|
||||
captured += tpl
|
||||
capturedSiblings += sibling
|
||||
"event-id"
|
||||
},
|
||||
uploadPoster = { _ -> "https://cdn.test/poster.jpg" },
|
||||
uploadPoster = { _ -> HlsPosterUpload("https://cdn.test/poster.jpg") },
|
||||
)
|
||||
|
||||
runBlocking { orchestrator.publish(newRequest()) }
|
||||
@@ -399,6 +404,13 @@ class HlsPublishOrchestratorTest {
|
||||
val flat = imeta.joinToString("|")
|
||||
assertTrue("imeta missing poster: $flat", flat.contains("image https://cdn.test/poster.jpg"))
|
||||
}
|
||||
|
||||
// Sibling kind:1 carries the same poster URL on its single imeta.
|
||||
val sibling = capturedSiblings.single()
|
||||
val siblingImeta = sibling.tags.firstOrNull { it.isNotEmpty() && it[0] == "imeta" }
|
||||
assertNotNull("sibling missing imeta", siblingImeta)
|
||||
val siblingFlat = siblingImeta!!.joinToString("|")
|
||||
assertTrue("sibling imeta missing poster: $siblingFlat", siblingFlat.contains("image https://cdn.test/poster.jpg"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -411,7 +423,7 @@ class HlsPublishOrchestratorTest {
|
||||
runUpload = fakeRunUpload(),
|
||||
buildUploader = { canned },
|
||||
uploadMaster = fakeUploadMaster(canned),
|
||||
signAndPublish = { tpl ->
|
||||
signAndPublish = { tpl, _ ->
|
||||
captured += tpl
|
||||
"event-id"
|
||||
},
|
||||
@@ -439,7 +451,7 @@ class HlsPublishOrchestratorTest {
|
||||
runUpload = { _, _, _ -> throw RuntimeException("boom") },
|
||||
buildUploader = { CannedUploader() },
|
||||
uploadMaster = { _, _ -> MediaUploadResult(url = "never") },
|
||||
signAndPublish = { "never" },
|
||||
signAndPublish = { _, _ -> "never" },
|
||||
)
|
||||
|
||||
runBlocking { orchestrator.publish(newRequest()) }
|
||||
|
||||
+14
-14
@@ -120,8 +120,8 @@ class HlsVideoEventBuilderTest {
|
||||
fun landscapeRenditionsBuildHorizontalTemplateKind34235() {
|
||||
val result = HlsVideoEventBuilder.build(input(landscapeRenditions))
|
||||
|
||||
assertTrue("expected Horizontal template", result is HlsVideoEventTemplate.Horizontal)
|
||||
val template = (result as HlsVideoEventTemplate.Horizontal).template
|
||||
assertTrue("expected Horizontal template", result.template is HlsVideoEventTemplate.Horizontal)
|
||||
val template = (result.template as HlsVideoEventTemplate.Horizontal).template
|
||||
assertEquals(VideoHorizontalEvent.KIND, template.kind)
|
||||
assertEquals("A cool video", template.content)
|
||||
}
|
||||
@@ -130,15 +130,15 @@ class HlsVideoEventBuilderTest {
|
||||
fun portraitRenditionsBuildVerticalTemplateKind34236() {
|
||||
val result = HlsVideoEventBuilder.build(input(portraitRenditions))
|
||||
|
||||
assertTrue("expected Vertical template", result is HlsVideoEventTemplate.Vertical)
|
||||
val template = (result as HlsVideoEventTemplate.Vertical).template
|
||||
assertTrue("expected Vertical template", result.template is HlsVideoEventTemplate.Vertical)
|
||||
val template = (result.template as HlsVideoEventTemplate.Vertical).template
|
||||
assertEquals(VideoVerticalEvent.KIND, template.kind)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun horizontalTemplateHasTitleAndDTag() {
|
||||
val result = HlsVideoEventBuilder.build(input(landscapeRenditions))
|
||||
val tags = (result as HlsVideoEventTemplate.Horizontal).template.tags
|
||||
val tags = (result.template as HlsVideoEventTemplate.Horizontal).template.tags
|
||||
|
||||
val title = tags.findTag("title")
|
||||
assertNotNull(title)
|
||||
@@ -152,7 +152,7 @@ class HlsVideoEventBuilderTest {
|
||||
@Test
|
||||
fun templateContainsOneImetaForMasterAndOnePerRendition() {
|
||||
val result = HlsVideoEventBuilder.build(input(landscapeRenditions))
|
||||
val tags = (result as HlsVideoEventTemplate.Horizontal).template.tags
|
||||
val tags = (result.template as HlsVideoEventTemplate.Horizontal).template.tags
|
||||
|
||||
val imetas = tags.findAllTags("imeta")
|
||||
// 1 master + 2 renditions
|
||||
@@ -181,7 +181,7 @@ class HlsVideoEventBuilderTest {
|
||||
HlsVideoEventBuilder.build(
|
||||
input(landscapeRenditions, duration = 123),
|
||||
)
|
||||
val tags = (result as HlsVideoEventTemplate.Horizontal).template.tags
|
||||
val tags = (result.template as HlsVideoEventTemplate.Horizontal).template.tags
|
||||
|
||||
val duration = tags.findTag("duration")
|
||||
assertNotNull(duration)
|
||||
@@ -191,7 +191,7 @@ class HlsVideoEventBuilderTest {
|
||||
@Test
|
||||
fun noDurationTagWhenNotProvided() {
|
||||
val result = HlsVideoEventBuilder.build(input(landscapeRenditions))
|
||||
val tags = (result as HlsVideoEventTemplate.Horizontal).template.tags
|
||||
val tags = (result.template as HlsVideoEventTemplate.Horizontal).template.tags
|
||||
assertNull(tags.findTag("duration"))
|
||||
}
|
||||
|
||||
@@ -201,7 +201,7 @@ class HlsVideoEventBuilderTest {
|
||||
HlsVideoEventBuilder.build(
|
||||
input(landscapeRenditions, contentWarning = "NSFW"),
|
||||
)
|
||||
val tags = (result as HlsVideoEventTemplate.Horizontal).template.tags
|
||||
val tags = (result.template as HlsVideoEventTemplate.Horizontal).template.tags
|
||||
|
||||
val warning = tags.findTag("content-warning")
|
||||
assertNotNull(warning)
|
||||
@@ -211,14 +211,14 @@ class HlsVideoEventBuilderTest {
|
||||
@Test
|
||||
fun noContentWarningTagWhenNull() {
|
||||
val result = HlsVideoEventBuilder.build(input(landscapeRenditions))
|
||||
val tags = (result as HlsVideoEventTemplate.Horizontal).template.tags
|
||||
val tags = (result.template as HlsVideoEventTemplate.Horizontal).template.tags
|
||||
assertNull(tags.findTag("content-warning"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun horizontalTemplateCarriesAutoGeneratedAltTag() {
|
||||
val result = HlsVideoEventBuilder.build(input(landscapeRenditions))
|
||||
val tags = (result as HlsVideoEventTemplate.Horizontal).template.tags
|
||||
val tags = (result.template as HlsVideoEventTemplate.Horizontal).template.tags
|
||||
val alt = tags.findTag("alt")
|
||||
assertNotNull(alt)
|
||||
assertEquals(VideoHorizontalEvent.ALT_DESCRIPTION, alt!![1])
|
||||
@@ -227,7 +227,7 @@ class HlsVideoEventBuilderTest {
|
||||
@Test
|
||||
fun verticalTemplateCarriesVerticalAltTag() {
|
||||
val result = HlsVideoEventBuilder.build(input(portraitRenditions))
|
||||
val tags = (result as HlsVideoEventTemplate.Vertical).template.tags
|
||||
val tags = (result.template as HlsVideoEventTemplate.Vertical).template.tags
|
||||
val alt = tags.findTag("alt")
|
||||
assertNotNull(alt)
|
||||
assertEquals(VideoVerticalEvent.ALT_DESCRIPTION, alt!![1])
|
||||
@@ -239,7 +239,7 @@ class HlsVideoEventBuilderTest {
|
||||
HlsVideoEventBuilder.build(
|
||||
input(landscapeRenditions, posterUrl = "https://cdn.test/poster.jpg"),
|
||||
)
|
||||
val tags = (result as HlsVideoEventTemplate.Horizontal).template.tags
|
||||
val tags = (result.template as HlsVideoEventTemplate.Horizontal).template.tags
|
||||
|
||||
val imetas = tags.findAllTags("imeta")
|
||||
// 1 master + 2 renditions, each must carry image <posterUrl>
|
||||
@@ -253,7 +253,7 @@ class HlsVideoEventBuilderTest {
|
||||
@Test
|
||||
fun noPosterUrlMeansNoImagePropertyOnImetas() {
|
||||
val result = HlsVideoEventBuilder.build(input(landscapeRenditions))
|
||||
val tags = (result as HlsVideoEventTemplate.Horizontal).template.tags
|
||||
val tags = (result.template as HlsVideoEventTemplate.Horizontal).template.tags
|
||||
|
||||
val imetas = tags.findAllTags("imeta")
|
||||
imetas.forEach { imeta ->
|
||||
|
||||
+22
-1
@@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.nip92IMeta.imetasByUrl
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.tags.BlurhashTag
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.tags.HashSha256Tag
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.tags.ImageTag
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.tags.MimeTypeTag
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbhashTag
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
@@ -64,7 +65,12 @@ class RichTextParser {
|
||||
|
||||
if (contentType != null) {
|
||||
isImage = contentType.startsWith("image/")
|
||||
isVideo = contentType.startsWith("video/") || contentType.startsWith("audio/")
|
||||
// HLS playlists are advertised with a non-`video/*` MIME (`application/vnd.apple.mpegurl`
|
||||
// and three legacy aliases). Without these, an imeta-described `.m3u8` falls into the
|
||||
// null bucket below and the renderer drops back to a plain hyperlink — even though
|
||||
// the matching extension would have routed it to MediaUrlVideo. Mirror the canonical
|
||||
// list used by MediaItemCache.toExoPlayerMimeType / GalleryThumb.isHlsMimeType.
|
||||
isVideo = contentType.startsWith("video/") || contentType.startsWith("audio/") || isHlsMimeType(contentType)
|
||||
isPdf = contentType.startsWith("application/pdf")
|
||||
} else if (fullUrl.startsWith("data:")) {
|
||||
isImage = fullUrl.startsWith("data:image/")
|
||||
@@ -99,6 +105,10 @@ class RichTextParser {
|
||||
dim = frags[DimensionTag.TAG_NAME]?.let { DimensionTag.parse(it) } ?: tags[DimensionTag.TAG_NAME]?.firstOrNull()?.let { DimensionTag.parse(it) },
|
||||
contentWarning = frags[ContentWarningTag.TAG_NAME] ?: tags[ContentWarningTag.TAG_NAME]?.firstOrNull(),
|
||||
uri = callbackUri,
|
||||
// Poster URL from the imeta's `image` property — downstream gallery-add reads
|
||||
// this as the entry's `image` tag so the gallery thumbnail can render the
|
||||
// poster JPEG instead of falling back to the blurhash placeholder.
|
||||
artworkUri = frags[ImageTag.TAG_NAME] ?: tags[ImageTag.TAG_NAME]?.firstOrNull(),
|
||||
mimeType = contentType,
|
||||
thumbhash = frags[ThumbhashTag.TAG_NAME] ?: tags[ThumbhashTag.TAG_NAME]?.firstOrNull(),
|
||||
authorPubKey = authorPubKey,
|
||||
@@ -457,6 +467,17 @@ class RichTextParser {
|
||||
return videoExtensions.any { removedParamsFromUrl.endsWith(it) }
|
||||
}
|
||||
|
||||
// Mirrors the canonical HLS-playlist MIME list also kept in MediaItemCache.toExoPlayerMimeType.
|
||||
// Called per URL during feed render — uses `equals(ignoreCase)` instead of `lowercase()` to
|
||||
// avoid a per-call String allocation on the common non-HLS path.
|
||||
fun isHlsMimeType(mimeType: String?): Boolean {
|
||||
if (mimeType == null) return false
|
||||
return mimeType.equals("application/vnd.apple.mpegurl", ignoreCase = true) ||
|
||||
mimeType.equals("application/x-mpegurl", ignoreCase = true) ||
|
||||
mimeType.equals("audio/x-mpegurl", ignoreCase = true) ||
|
||||
mimeType.equals("audio/mpegurl", ignoreCase = true)
|
||||
}
|
||||
|
||||
fun isPdfUrl(url: String): Boolean {
|
||||
val removedParamsFromUrl = removeQueryParamsForExtensionComparison(url)
|
||||
return pdfExtensions.any { removedParamsFromUrl.endsWith(it) }
|
||||
|
||||
Reference in New Issue
Block a user