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:
+25
-1
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
+6
@@ -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() }
|
||||||
},
|
},
|
||||||
|
|||||||
+1
@@ -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(
|
||||||
|
|||||||
+57
-8
@@ -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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
@@ -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 =
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
+72
@@ -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() } },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user