feat(hls): adapter for Nip96/Blossom + production orchestrator wiring

HlsBlobUploaderFactory turns a user-chosen ServerName into the simple
file+contentType HlsBlobUploader the pipeline expects by adapting
BlossomUploader or Nip96Uploader. Each adapter reuses the existing
roleBasedHttpClientBuilder.okHttpClientForUploads and the account's
signer helpers (createBlossomUploadAuth / createHTTPAuthorization),
matching how UploadOrchestrator wires them today. NIP-95 is rejected
explicitly — storing each rendition as an event would blow past relay
size limits.

createProductionHlsPublishOrchestrator binds the transcode to
HlsTranscoder, uploads to HlsBlobUploaderFactory, and signAndPublish to
account.signer.sign(...) + account.sendAutomatic(...). The Uri is
captured via a lazy provider so the orchestrator can be constructed at
VM load time before the user picks a video.

NewHlsVideoViewModel.load(account, context) is the new one-call setup
the screen will use; the existing load(account, orchestrator) overload
stays for unit tests.

Milestone 5b of the HLS video sharing plan (2026-04-13).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
davotoula
2026-04-15 15:33:47 +02:00
co-authored by Claude Opus 4.5
parent c008daee7e
commit 6942314cf8
3 changed files with 180 additions and 0 deletions
@@ -0,0 +1,98 @@
/*
* 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 androidx.core.net.toUri
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader
import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
/**
* Turns a user-chosen [ServerName] into an [HlsBlobUploader] by adapting the concrete
* [Nip96Uploader] / [BlossomUploader] to the simpler file+contentType interface the HLS upload
* pipeline uses. Keeps the pipeline free of direct Amethyst/account wiring so it stays
* unit-testable.
*/
object HlsBlobUploaderFactory {
fun create(
server: ServerName,
account: Account,
context: Context,
): HlsBlobUploader =
when (server.type) {
ServerType.Blossom -> {
blossomAdapter(server.baseUrl, account, context)
}
ServerType.NIP96 -> {
nip96Adapter(server.baseUrl, account, context)
}
ServerType.NIP95 -> {
throw IllegalArgumentException(
"NIP-95 storage stores each blob as an event and is not suitable for HLS renditions",
)
}
}
private fun blossomAdapter(
serverBaseUrl: String,
account: Account,
context: Context,
): HlsBlobUploader =
HlsBlobUploader { file, contentType ->
BlossomUploader().upload(
uri = file.toUri(),
contentType = contentType,
size = file.length(),
alt = null,
sensitiveContent = null,
serverBaseUrl = serverBaseUrl,
okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
httpAuth = account::createBlossomUploadAuth,
context = context,
)
}
private fun nip96Adapter(
serverBaseUrl: String,
account: Account,
context: Context,
): HlsBlobUploader =
HlsBlobUploader { file, contentType ->
Nip96Uploader().upload(
uri = file.toUri(),
contentType = contentType,
size = file.length(),
alt = null,
sensitiveContent = null,
serverBaseUrl = serverBaseUrl,
okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
onProgress = { /* pipeline reports progress per-upload; NIP-96 per-request progress is not forwarded */ },
httpAuth = account::createHTTPAuthorization,
context = context,
)
}
}
@@ -0,0 +1,70 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.video.hls
import android.content.Context
import android.net.Uri
import com.vitorpamplona.amethyst.model.Account
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 java.io.File
/**
* Production wiring for [HlsPublishOrchestrator]. Binds the transcode to [HlsTranscoder], the
* uploader factory to [HlsBlobUploaderFactory], and the 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
* once (at VM load) before the user actually picks a video.
*/
fun createProductionHlsPublishOrchestrator(
account: Account,
context: Context,
uriProvider: () -> Uri?,
): HlsPublishOrchestrator =
HlsPublishOrchestrator(
runTranscode = { workDir, codec, onProgress ->
val uri = uriProvider() ?: error("No video picked")
HlsTranscoder.transcode(
context = context,
uri = uri,
workDir = workDir,
codec = codec,
onRenditionProgress = onProgress,
)
},
buildUploader = { server ->
HlsBlobUploaderFactory.create(server, account, context)
},
signAndPublish = { template ->
val signed =
when (template) {
is HlsVideoEventTemplate.Horizontal -> account.signer.sign(template.template)
is HlsVideoEventTemplate.Vertical -> account.signer.sign(template.template)
}
account.sendAutomatic(signed)
signed.id
},
workDirFactory = {
File(context.cacheDir, "hls-${System.currentTimeMillis()}").apply { mkdirs() }
},
)
@@ -78,6 +78,18 @@ open class NewHlsVideoViewModel : ViewModel() {
this.selectedServer = this.selectedServer ?: DEFAULT_MEDIA_SERVERS.first()
}
fun load(
account: Account,
context: Context,
) = load(
account,
createProductionHlsPublishOrchestrator(
account = account,
context = context,
uriProvider = { pickedUri },
),
)
fun onVideoPicked(
uri: Uri,
metadata: HlsSourceMetadata?,