feat(hls): cross-post HLS upload as a kind-1 note

Adds an optional kind-1 TextNoteEvent companion to every HLS publish so
the upload shows up in the home feed (where most people scroll)
alongside the NIP-71 VideoHorizontalEvent/VideoVerticalEvent in the
dedicated video tab. The companion note's content is the title, the
description and the master playlist URL joined with blank lines so
Amethyst's rich-text parser renders the inline video player and
non-NIP-71 clients still see a clickable master.m3u8 link.

Orchestrator picks up a new suspend signAndPublishNote callback that
takes the note content and returns the signed event id. The note is
only signed after the NIP-71 publish succeeds, so a failed video
publish never produces an orphaned note. HlsPublishState.Success now
carries the nullable noteEventId, and the success screen offers a
"View note" primary button that navigates to Route.Note(noteEventId)
when set; otherwise it falls back to the existing "Done" button.

The form switch defaults to on and sits just below the content-warning
row. Turning it off leaves the noteEventId null in the final Success
state.

Production wiring uses TextNoteEvent.build(content) +
account.signer.sign + account.sendAutomatic, matching the existing
short-note publish path.

Two new orchestrator tests lock in the cross-post behaviour: one that
captures the note content and asserts it contains the title,
description and master URL, and one that verifies crossPostAsNote=false
never invokes the note callback and leaves noteEventId null.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
davotoula
2026-04-14 20:44:57 +02:00
parent 3bb9aba8fb
commit 50e76716bd
7 changed files with 165 additions and 9 deletions
@@ -41,6 +41,7 @@ data class HlsPublishRequest(
val codec: VideoCodec, val codec: VideoCodec,
val server: ServerName, val server: ServerName,
val durationSeconds: Int? = null, val durationSeconds: Int? = null,
val crossPostAsNote: Boolean = true,
) )
/** /**
@@ -60,6 +61,7 @@ class HlsPublishOrchestrator(
) -> HlsBundle, ) -> HlsBundle,
private val buildUploader: (ServerName) -> HlsBlobUploader, private val buildUploader: (ServerName) -> HlsBlobUploader,
private val signAndPublish: suspend (HlsVideoEventTemplate) -> String, private val signAndPublish: suspend (HlsVideoEventTemplate) -> String,
private val signAndPublishNote: suspend (content: String) -> String,
private val workDirFactory: () -> File, private val workDirFactory: () -> File,
) { ) {
val state: StateFlow<HlsPublishState> = _state val state: StateFlow<HlsPublishState> = _state
@@ -96,7 +98,20 @@ class HlsPublishOrchestrator(
) )
val eventId = signAndPublish(template) val eventId = signAndPublish(template)
_state.value = HlsPublishState.Success(eventId = eventId, masterUrl = uploadResult.masterUrl) val noteEventId =
if (request.crossPostAsNote) {
val content = buildCompanionNoteContent(request, uploadResult.masterUrl)
signAndPublishNote(content)
} else {
null
}
_state.value =
HlsPublishState.Success(
eventId = eventId,
masterUrl = uploadResult.masterUrl,
noteEventId = noteEventId,
)
} catch (e: CancellationException) { } catch (e: CancellationException) {
_state.value = HlsPublishState.Failure(message = "Cancelled") _state.value = HlsPublishState.Failure(message = "Cancelled")
throw e throw e
@@ -110,4 +125,13 @@ class HlsPublishOrchestrator(
} }
private fun contentWarningOrNull(request: HlsPublishRequest): String? = if (request.sensitiveContent) request.contentWarningReason else null private fun contentWarningOrNull(request: HlsPublishRequest): String? = if (request.sensitiveContent) request.contentWarningReason else null
private fun buildCompanionNoteContent(
request: HlsPublishRequest,
masterUrl: String,
): String =
listOf(request.title, request.description, masterUrl)
.map { it.trim() }
.filter { it.isNotEmpty() }
.joinToString("\n\n")
} }
@@ -26,6 +26,7 @@ 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.HlsTranscoder
import com.vitorpamplona.amethyst.service.uploads.hls.HlsVideoEventTemplate import com.vitorpamplona.amethyst.service.uploads.hls.HlsVideoEventTemplate
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import java.io.File import java.io.File
@@ -67,6 +68,11 @@ fun createProductionHlsPublishOrchestrator(
account.sendAutomatic(signed) account.sendAutomatic(signed)
signed.id signed.id
}, },
signAndPublishNote = { content ->
val signed = account.signer.sign(TextNoteEvent.build(content))
account.sendAutomatic(signed)
signed.id
},
workDirFactory = { workDirFactory = {
File(context.cacheDir, "hls-${System.currentTimeMillis()}").apply { mkdirs() } File(context.cacheDir, "hls-${System.currentTimeMillis()}").apply { mkdirs() }
}, },
@@ -38,6 +38,7 @@ sealed class HlsPublishState {
data class Success( data class Success(
val eventId: String, val eventId: String,
val masterUrl: String, val masterUrl: String,
val noteEventId: String? = null,
) : HlsPublishState() ) : HlsPublishState()
data class Failure( data class Failure(
@@ -79,6 +79,7 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.components.TextSpinner import com.vitorpamplona.amethyst.ui.components.TextSpinner
import com.vitorpamplona.amethyst.ui.components.TitleExplainer import com.vitorpamplona.amethyst.ui.components.TitleExplainer
import com.vitorpamplona.amethyst.ui.navigation.navs.INav 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.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
@@ -318,6 +319,30 @@ private fun FormFields(vm: NewHlsVideoViewModel) {
) )
} }
Spacer(Modifier.height(8.dp))
// Cross-post as kind-1 note toggle
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.hls_cross_post_as_note),
style = MaterialTheme.typography.bodyLarge,
)
Text(
text = stringResource(R.string.hls_cross_post_as_note_explainer),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(
checked = vm.crossPostAsNote,
onCheckedChange = { vm.crossPostAsNote = it },
)
}
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
// Server picker — reads the user's configured Blossom servers from the account // Server picker — reads the user's configured Blossom servers from the account
@@ -587,14 +612,38 @@ private fun SuccessBody(
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
Button(
onClick = { val noteId = state.noteEventId
vm.reset() if (noteId != null) {
nav.popBack() Button(
}, onClick = {
modifier = Modifier.fillMaxWidth(), vm.reset()
) { nav.nav(Route.Note(noteId))
Text(stringResource(R.string.hls_done)) },
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.hls_view_note))
}
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))
}
} }
} }
} }
@@ -63,6 +63,7 @@ open class NewHlsVideoViewModel : ViewModel() {
var sensitiveContent by mutableStateOf(false) var sensitiveContent by mutableStateOf(false)
var contentWarningReason by mutableStateOf("") var contentWarningReason by mutableStateOf("")
var useH265 by mutableStateOf(true) var useH265 by mutableStateOf(true)
var crossPostAsNote by mutableStateOf(true)
var selectedServer by mutableStateOf<ServerName?>(null) var selectedServer by mutableStateOf<ServerName?>(null)
private val _state = MutableStateFlow<HlsPublishState>(HlsPublishState.Idle) private val _state = MutableStateFlow<HlsPublishState>(HlsPublishState.Idle)
@@ -146,6 +147,7 @@ open class NewHlsVideoViewModel : ViewModel() {
codec = codec, codec = codec,
server = server, server = server,
durationSeconds = sourceMetadata?.durationSeconds, durationSeconds = sourceMetadata?.durationSeconds,
crossPostAsNote = crossPostAsNote,
) )
currentJob = currentJob =
+2
View File
@@ -2112,6 +2112,8 @@
<string name="hls_view_note">View note</string> <string name="hls_view_note">View note</string>
<string name="hls_done">Done</string> <string name="hls_done">Done</string>
<string name="hls_try_again">Try again</string> <string name="hls_try_again">Try again</string>
<string name="hls_cross_post_as_note">Cross-post as note</string>
<string name="hls_cross_post_as_note_explainer">Also publish a regular Nostr note linking to the video so it shows in the home feed.</string>
<string name="pack_actions_dialog_title">Pack Actions</string> <string name="pack_actions_dialog_title">Pack Actions</string>
<string name="list_actions_dialog_title">List Actions</string> <string name="list_actions_dialog_title">List Actions</string>
@@ -98,6 +98,7 @@ class HlsPublishOrchestratorTest {
description: String = "A test clip", description: String = "A test clip",
sensitive: Boolean = false, sensitive: Boolean = false,
warningReason: String = "", warningReason: String = "",
crossPostAsNote: Boolean = false,
) = HlsPublishRequest( ) = HlsPublishRequest(
title = title, title = title,
description = description, description = description,
@@ -105,6 +106,7 @@ class HlsPublishOrchestratorTest {
contentWarningReason = warningReason, contentWarningReason = warningReason,
codec = VideoCodec.H265, codec = VideoCodec.H265,
server = server, server = server,
crossPostAsNote = crossPostAsNote,
) )
@Test @Test
@@ -119,6 +121,7 @@ class HlsPublishOrchestratorTest {
publishedTemplates += tpl publishedTemplates += tpl
"signed-event-id" "signed-event-id"
}, },
signAndPublishNote = { "note-id" },
workDirFactory = { File(workDir, "work").apply { mkdirs() } }, workDirFactory = { File(workDir, "work").apply { mkdirs() } },
) )
@@ -161,6 +164,7 @@ class HlsPublishOrchestratorTest {
capturedDuringPublish += orchestrator.state.value capturedDuringPublish += orchestrator.state.value
"event-id" "event-id"
}, },
signAndPublishNote = { "note-id" },
workDirFactory = { File(workDir, "work").apply { mkdirs() } }, workDirFactory = { File(workDir, "work").apply { mkdirs() } },
) )
@@ -184,6 +188,7 @@ class HlsPublishOrchestratorTest {
runTranscode = { _, _, _ -> throw RuntimeException("decode failed") }, runTranscode = { _, _, _ -> throw RuntimeException("decode failed") },
buildUploader = { CannedUploader() }, buildUploader = { CannedUploader() },
signAndPublish = { "never" }, signAndPublish = { "never" },
signAndPublishNote = { "note-id" },
workDirFactory = { File(workDir, "work").apply { mkdirs() } }, workDirFactory = { File(workDir, "work").apply { mkdirs() } },
) )
@@ -204,6 +209,7 @@ class HlsPublishOrchestratorTest {
HlsBlobUploader { _, _ -> throw RuntimeException("server 500") } HlsBlobUploader { _, _ -> throw RuntimeException("server 500") }
}, },
signAndPublish = { "never" }, signAndPublish = { "never" },
signAndPublishNote = { "note-id" },
workDirFactory = { File(workDir, "work").apply { mkdirs() } }, workDirFactory = { File(workDir, "work").apply { mkdirs() } },
) )
@@ -222,6 +228,7 @@ class HlsPublishOrchestratorTest {
runTranscode = { _, _, _ -> fakeBundle() }, runTranscode = { _, _, _ -> fakeBundle() },
buildUploader = { CannedUploader() }, buildUploader = { CannedUploader() },
signAndPublish = { throw RuntimeException("relay rejected") }, signAndPublish = { throw RuntimeException("relay rejected") },
signAndPublishNote = { "note-id" },
workDirFactory = { File(workDir, "work").apply { mkdirs() } }, workDirFactory = { File(workDir, "work").apply { mkdirs() } },
) )
@@ -244,6 +251,7 @@ class HlsPublishOrchestratorTest {
captured += tpl captured += tpl
"event-id" "event-id"
}, },
signAndPublishNote = { "note-id" },
workDirFactory = { File(workDir, "work").apply { mkdirs() } }, workDirFactory = { File(workDir, "work").apply { mkdirs() } },
) )
@@ -282,6 +290,7 @@ class HlsPublishOrchestratorTest {
captured += tpl captured += tpl
"event-id" "event-id"
}, },
signAndPublishNote = { "note-id" },
workDirFactory = { File(workDir, "work").apply { mkdirs() } }, workDirFactory = { File(workDir, "work").apply { mkdirs() } },
) )
@@ -290,6 +299,68 @@ class HlsPublishOrchestratorTest {
assertTrue(captured.single() is HlsVideoEventTemplate.Vertical) assertTrue(captured.single() is HlsVideoEventTemplate.Vertical)
} }
@Test
fun crossPostAsNoteInvokesSignAndPublishNoteWithTitleDescriptionAndMasterUrl() {
val capturedNoteContent = mutableListOf<String>()
val orchestrator =
HlsPublishOrchestrator(
_state = MutableStateFlow(HlsPublishState.Idle),
runTranscode = { _, _, _ -> fakeBundle() },
buildUploader = { CannedUploader() },
signAndPublish = { "video-event-id" },
signAndPublishNote = { content ->
capturedNoteContent += content
"note-event-id"
},
workDirFactory = { File(workDir, "work").apply { mkdirs() } },
)
runBlocking {
orchestrator.publish(
newRequest(
title = "Sunset",
description = "Golden hour",
crossPostAsNote = true,
),
)
}
assertEquals(1, capturedNoteContent.size)
val note = capturedNoteContent[0]
assertTrue("note missing title: $note", note.contains("Sunset"))
assertTrue("note missing description: $note", note.contains("Golden hour"))
assertTrue("note missing master url: $note", note.contains("https://cdn.test/"))
val final = orchestrator.state.value as HlsPublishState.Success
assertEquals("note-event-id", final.noteEventId)
assertEquals("video-event-id", final.eventId)
}
@Test
fun crossPostDisabledLeavesNoteEventIdNull() {
val noteCallbackInvocations = mutableListOf<String>()
val orchestrator =
HlsPublishOrchestrator(
_state = MutableStateFlow(HlsPublishState.Idle),
runTranscode = { _, _, _ -> fakeBundle() },
buildUploader = { CannedUploader() },
signAndPublish = { "video-event-id" },
signAndPublishNote = {
noteCallbackInvocations += it
"should-not-be-used"
},
workDirFactory = { File(workDir, "work").apply { mkdirs() } },
)
runBlocking {
orchestrator.publish(newRequest(crossPostAsNote = false))
}
assertEquals(0, noteCallbackInvocations.size)
val final = orchestrator.state.value as HlsPublishState.Success
assertEquals(null, final.noteEventId)
}
@Test @Test
fun resetRestoresIdleState() { fun resetRestoresIdleState() {
val orchestrator = val orchestrator =
@@ -298,6 +369,7 @@ class HlsPublishOrchestratorTest {
runTranscode = { _, _, _ -> throw RuntimeException("boom") }, runTranscode = { _, _, _ -> throw RuntimeException("boom") },
buildUploader = { CannedUploader() }, buildUploader = { CannedUploader() },
signAndPublish = { "never" }, signAndPublish = { "never" },
signAndPublishNote = { "note-id" },
workDirFactory = { File(workDir, "work").apply { mkdirs() } }, workDirFactory = { File(workDir, "work").apply { mkdirs() } },
) )