mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +00:00
feat(cli): publish Podcasting-2.0 podcasts via amy podcast20
Add a separate command group for authoring the Podcasting-2.0 (podstr) kinds, kept distinct from the NIP-F4 `podcast` commands because the models differ — here the logged-in account is the creator and signs everything with its own key, and episodes/trailers are addressable (d-tag) events. amy podcast20 metadata --title T [...] kind:30078 show metadata (JSON body) amy podcast20 episode --title T --audio URL[,URL] [...] kind:30054 episode amy podcast20 trailer --title T --url URL [...] kind:30055 trailer amy podcast20 list [USER] [--limit N] metadata + episodes + trailers Episodes accept the full rich tag set (video, episode/season, transcript, chapters, topics, duration); d-tags and the RFC2822 pubdate are auto-generated when omitted. Thin assembly only — added Podcasting20PodcastMetadata.build() in quartz so JSON-body construction stays out of cli (covered by a round-trip test). Verified end-to-end against the running CLI: all three commands build, sign and emit the expected kinds (30078/30054/30055) with correct d-tags and the --json single-line contract. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
This commit is contained in:
@@ -53,6 +53,7 @@ import com.vitorpamplona.amethyst.cli.commands.NotesCommands
|
||||
import com.vitorpamplona.amethyst.cli.commands.NsiteCommands
|
||||
import com.vitorpamplona.amethyst.cli.commands.OfferCommands
|
||||
import com.vitorpamplona.amethyst.cli.commands.OutboxCommand
|
||||
import com.vitorpamplona.amethyst.cli.commands.Podcast20Commands
|
||||
import com.vitorpamplona.amethyst.cli.commands.PodcastCommands
|
||||
import com.vitorpamplona.amethyst.cli.commands.ProfileCommands
|
||||
import com.vitorpamplona.amethyst.cli.commands.PublishCommand
|
||||
@@ -233,6 +234,7 @@ private suspend fun dispatch(argv: Array<String>): Int {
|
||||
"serve" -> ServeCommand.run(dataDir, tail)
|
||||
"cashu" -> CashuCommands.dispatch(dataDir, tail)
|
||||
"podcast" -> PodcastCommands.dispatch(dataDir, tail)
|
||||
"podcast20" -> Podcast20Commands.dispatch(dataDir, tail)
|
||||
"bunker" -> BunkerCommand.run(dataDir, tail)
|
||||
else -> {
|
||||
System.err.println("unknown subcommand: $head")
|
||||
@@ -478,6 +480,20 @@ private fun printUsage() {
|
||||
| [--image URL] [--content MARKDOWN]
|
||||
| podcast list [USER] [--limit N] list a user's metadata + episodes
|
||||
|
|
||||
|Podcasts (Podcasting 2.0 / podstr):
|
||||
| podcast20 metadata --title T publish kind:30078 show metadata (JSON body)
|
||||
| [--description D] [--author A] [--image URL] [--language L]
|
||||
| [--categories A,B] [--funding URL,URL] [--website URL]
|
||||
| [--copyright C] [--type episodic|serial] [--explicit] [--complete]
|
||||
| podcast20 episode --title T --audio URL[,URL] publish a kind:30054 episode
|
||||
| [--d ID] [--audio-type MIME] [--description D] [--image URL]
|
||||
| [--duration SECS] [--video URL] [--video-type MIME]
|
||||
| [--episode N] [--season N] [--transcript URL] [--chapters URL]
|
||||
| [--topic A,B] [--content MARKDOWN] [--pubdate RFC2822]
|
||||
| podcast20 trailer --title T --url URL publish a kind:30055 trailer
|
||||
| [--d ID] [--type MIME] [--length BYTES] [--season N] [--pubdate RFC2822]
|
||||
| podcast20 list [USER] [--limit N] list a creator's metadata + episodes + trailers
|
||||
|
|
||||
|Static websites (NIP-5A kind:15128/35128):
|
||||
| nsite fetch AUTHOR [--d ID] [--path P] resolve one path over Nostr + Blossom and
|
||||
| [--server URL[,URL]] [--relay URL[,URL]] VERIFY it against the manifest's sha256 pin
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
/*
|
||||
* 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.cli.commands
|
||||
|
||||
import com.vitorpamplona.amethyst.cli.Args
|
||||
import com.vitorpamplona.amethyst.cli.Context
|
||||
import com.vitorpamplona.amethyst.cli.DataDir
|
||||
import com.vitorpamplona.amethyst.cli.Output
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.Podcasting20PodcastMetadata
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent
|
||||
import com.vitorpamplona.quartz.podcasts.PodcastAudio
|
||||
import java.time.ZoneId
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* `amy podcast20 <metadata|episode|trailer|list>` — the Podcasting-2.0 draft (derekross/podstr),
|
||||
* kept separate from the NIP-F4 `podcast` commands because the two models differ: here the
|
||||
* logged-in account IS the creator and signs everything with its own key, and episodes/trailers
|
||||
* are addressable (`d`-tag) events that can be edited in place.
|
||||
*
|
||||
* metadata publish kind:30078 show metadata (`d=podcast-metadata`, JSON body)
|
||||
* episode publish a kind:30054 episode
|
||||
* trailer publish a kind:30055 trailer
|
||||
* list list a creator's metadata + episodes + trailers
|
||||
*
|
||||
* Thin assembly only: events and JSON live in quartz (`Podcasting20EpisodeEvent`,
|
||||
* `Podcasting20TrailerEvent`, `Podcasting20PodcastMetadata`).
|
||||
*/
|
||||
object Podcast20Commands {
|
||||
suspend fun dispatch(
|
||||
dataDir: DataDir,
|
||||
tail: Array<String>,
|
||||
): Int =
|
||||
route(
|
||||
"podcast20",
|
||||
tail,
|
||||
"podcast20 <metadata|episode|trailer|list>",
|
||||
mapOf(
|
||||
"metadata" to { rest -> metadata(dataDir, rest) },
|
||||
"episode" to { rest -> episode(dataDir, rest) },
|
||||
"trailer" to { rest -> trailer(dataDir, rest) },
|
||||
"list" to { rest -> list(dataDir, rest) },
|
||||
),
|
||||
)
|
||||
|
||||
private suspend fun metadata(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val title = args.flag("title") ?: return Output.error("bad_args", "podcast20 metadata requires --title")
|
||||
|
||||
val content =
|
||||
Podcasting20PodcastMetadata.Content(
|
||||
title = title,
|
||||
description = args.flag("description"),
|
||||
author = args.flag("author"),
|
||||
email = args.flag("email"),
|
||||
image = args.flag("image"),
|
||||
language = args.flag("language"),
|
||||
categories = listFlag(args, "categories"),
|
||||
explicit = trueIfPresent(args, "explicit"),
|
||||
website = args.flag("website"),
|
||||
copyright = args.flag("copyright"),
|
||||
funding = listFlag(args, "funding"),
|
||||
locked = trueIfPresent(args, "locked"),
|
||||
type = args.flag("type"),
|
||||
complete = trueIfPresent(args, "complete"),
|
||||
guid = args.flag("guid"),
|
||||
)
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val signed = ctx.signer.sign(Podcasting20PodcastMetadata.build(content))
|
||||
val ack = ctx.publish(signed, RawEventSupport.publishTargets(ctx, args))
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"event_id" to signed.id,
|
||||
"kind" to signed.kind,
|
||||
"d" to Podcasting20PodcastMetadata.PODCAST_METADATA_D_TAG,
|
||||
"title" to title,
|
||||
"published_to" to ack.filterValues { it }.keys.map { it.url },
|
||||
),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun episode(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val title = args.flag("title") ?: return Output.error("bad_args", "podcast20 episode requires --title")
|
||||
val audioType = args.flag("audio-type")
|
||||
val audios =
|
||||
args
|
||||
.flag("audio")
|
||||
?.split(',')
|
||||
?.map { it.trim() }
|
||||
?.filter { it.isNotEmpty() }
|
||||
?.map { PodcastAudio(it, audioType) }
|
||||
.orEmpty()
|
||||
if (audios.isEmpty()) return Output.error("bad_args", "podcast20 episode requires --audio URL[,URL…]")
|
||||
|
||||
val dTag = args.flag("d") ?: generateDTag("episode")
|
||||
val video = args.flag("video")?.let { PodcastAudio(it, args.flag("video-type")) }
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val template =
|
||||
Podcasting20EpisodeEvent.build(
|
||||
dTag = dTag,
|
||||
title = title,
|
||||
audios = audios,
|
||||
pubdate = args.flag("pubdate") ?: rfc2822Now(),
|
||||
description = args.flag("description"),
|
||||
image = args.flag("image"),
|
||||
durationInSeconds = args.flag("duration")?.toLongOrNull(),
|
||||
video = video,
|
||||
episodeNumber = args.flag("episode")?.toIntOrNull(),
|
||||
season = args.flag("season")?.toIntOrNull(),
|
||||
transcriptUrl = args.flag("transcript"),
|
||||
chaptersUrl = args.flag("chapters"),
|
||||
topics = listFlag(args, "topic"),
|
||||
markdownContent = args.flag("content", "") ?: "",
|
||||
)
|
||||
val signed = ctx.signer.sign(template)
|
||||
val ack = ctx.publish(signed, RawEventSupport.publishTargets(ctx, args))
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"event_id" to signed.id,
|
||||
"kind" to signed.kind,
|
||||
"d" to dTag,
|
||||
"title" to title,
|
||||
"audios" to audios.map { it.url },
|
||||
"published_to" to ack.filterValues { it }.keys.map { it.url },
|
||||
),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun trailer(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val title = args.flag("title") ?: return Output.error("bad_args", "podcast20 trailer requires --title")
|
||||
val url = args.flag("url") ?: return Output.error("bad_args", "podcast20 trailer requires --url")
|
||||
val dTag = args.flag("d") ?: generateDTag("trailer")
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val template =
|
||||
Podcasting20TrailerEvent.build(
|
||||
dTag = dTag,
|
||||
title = title,
|
||||
url = url,
|
||||
pubdate = args.flag("pubdate") ?: rfc2822Now(),
|
||||
lengthInBytes = args.flag("length")?.toLongOrNull(),
|
||||
mimeType = args.flag("type"),
|
||||
season = args.flag("season")?.toIntOrNull(),
|
||||
)
|
||||
val signed = ctx.signer.sign(template)
|
||||
val ack = ctx.publish(signed, RawEventSupport.publishTargets(ctx, args))
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"event_id" to signed.id,
|
||||
"kind" to signed.kind,
|
||||
"d" to dTag,
|
||||
"title" to title,
|
||||
"url" to url,
|
||||
"published_to" to ack.filterValues { it }.keys.map { it.url },
|
||||
),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun list(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val limit = args.intFlag("limit", 50)
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val author = args.positionalOrNull(0)?.let { ctx.requireUserHex(it) } ?: ctx.identity.pubKeyHex
|
||||
val relays = RawEventSupport.queryTargets(ctx, args)
|
||||
val received =
|
||||
ctx.drain(
|
||||
relays.associateWith {
|
||||
listOf(
|
||||
Filter(
|
||||
kinds = listOf(Podcasting20EpisodeEvent.KIND, Podcasting20TrailerEvent.KIND, AppSpecificDataEvent.KIND),
|
||||
authors = listOf(author),
|
||||
limit = limit,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
val events = received.map { it.second }.distinctBy { it.id }
|
||||
val show =
|
||||
events
|
||||
.filterIsInstance<AppSpecificDataEvent>()
|
||||
.mapNotNull { Podcasting20PodcastMetadata.parse(it) }
|
||||
.maxByOrNull { it.event.createdAt }
|
||||
val episodes =
|
||||
events
|
||||
.filterIsInstance<Podcasting20EpisodeEvent>()
|
||||
.sortedByDescending { it.createdAt }
|
||||
.map {
|
||||
mapOf(
|
||||
"event_id" to it.id,
|
||||
"d" to it.dTag(),
|
||||
"title" to it.title(),
|
||||
"season" to it.season(),
|
||||
"episode" to it.number(),
|
||||
"audios" to it.audios().map { a -> a.url },
|
||||
"created_at" to it.createdAt,
|
||||
)
|
||||
}
|
||||
val trailers =
|
||||
events
|
||||
.filterIsInstance<Podcasting20TrailerEvent>()
|
||||
.sortedByDescending { it.createdAt }
|
||||
.map {
|
||||
mapOf(
|
||||
"event_id" to it.id,
|
||||
"d" to it.dTag(),
|
||||
"title" to it.title(),
|
||||
"url" to it.url(),
|
||||
"season" to it.season(),
|
||||
"created_at" to it.createdAt,
|
||||
)
|
||||
}
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"pubkey" to author,
|
||||
"metadata" to
|
||||
show?.let {
|
||||
mapOf(
|
||||
"title" to it.showTitle(),
|
||||
"description" to it.showDescription(),
|
||||
"image" to it.showImage(),
|
||||
"author" to it.showAuthor(),
|
||||
"categories" to it.showCategories(),
|
||||
"funding" to it.showFundingUrls(),
|
||||
)
|
||||
},
|
||||
"episode_count" to episodes.size,
|
||||
"episodes" to episodes,
|
||||
"trailer_count" to trailers.size,
|
||||
"trailers" to trailers,
|
||||
),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
private fun listFlag(
|
||||
args: Args,
|
||||
name: String,
|
||||
): List<String> =
|
||||
args
|
||||
.flag(name)
|
||||
?.split(',')
|
||||
?.map { it.trim() }
|
||||
?.filter { it.isNotEmpty() }
|
||||
.orEmpty()
|
||||
|
||||
/** A boolean flag maps to `true` when present and `null` when absent, so it's omitted from the JSON. */
|
||||
private fun trueIfPresent(
|
||||
args: Args,
|
||||
name: String,
|
||||
): Boolean? = if (args.bool(name)) true else null
|
||||
|
||||
private fun generateDTag(prefix: String): String = "$prefix-${System.currentTimeMillis() / 1000}-${UUID.randomUUID().toString().take(8)}"
|
||||
|
||||
/** Current time as an RFC2822 date string (e.g. `Tue, 24 Jun 2025 12:00:00 GMT`), as the spec's `pubdate` expects. */
|
||||
private fun rfc2822Now(): String = DateTimeFormatter.RFC_1123_DATE_TIME.format(ZonedDateTime.now(ZoneId.of("GMT")))
|
||||
}
|
||||
+11
@@ -22,8 +22,10 @@ package com.vitorpamplona.quartz.nipXXPodcasting20.metadata
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
|
||||
import com.vitorpamplona.quartz.podcasts.PodcastShow
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
@@ -112,5 +114,14 @@ class Podcasting20PodcastMetadata(
|
||||
val content = runCatching { JsonMapper.fromJson<Content>(event.content) }.getOrNull() ?: return null
|
||||
return Podcasting20PodcastMetadata(event, content)
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the kind:30078 show-metadata event template (`d="podcast-metadata"`) by serializing
|
||||
* [content] to its JSON body. Unset/default fields are omitted, keeping the payload minimal.
|
||||
*/
|
||||
fun build(
|
||||
content: Content,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
): EventTemplate<AppSpecificDataEvent> = AppSpecificDataEvent.build(PODCAST_METADATA_D_TAG, JsonMapper.toJson(content), createdAt)
|
||||
}
|
||||
}
|
||||
|
||||
+26
@@ -91,6 +91,32 @@ class Podcasting20PodcastMetadataTest {
|
||||
assertFalse(show.isLocked())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build round-trips a Content through sign and parse`() {
|
||||
val content =
|
||||
Podcasting20PodcastMetadata.Content(
|
||||
title = "Built Show",
|
||||
description = "made in quartz",
|
||||
author = "Jane",
|
||||
image = "https://example.com/c.jpg",
|
||||
categories = listOf("Tech"),
|
||||
explicit = true,
|
||||
funding = listOf("https://example.com/donate"),
|
||||
complete = false,
|
||||
)
|
||||
val event = signer.sign<AppSpecificDataEvent>(Podcasting20PodcastMetadata.build(content))
|
||||
|
||||
assertEquals("podcast-metadata", event.dTag())
|
||||
val parsed = Podcasting20PodcastMetadata.parse(event)
|
||||
assertTrue(parsed != null)
|
||||
assertEquals("Built Show", parsed.showTitle())
|
||||
assertEquals("Jane", parsed.showAuthor())
|
||||
assertEquals(listOf("Tech"), parsed.showCategories())
|
||||
assertEquals(listOf("https://example.com/donate"), parsed.showFundingUrls())
|
||||
assertTrue(parsed.showIsExplicit())
|
||||
assertFalse(parsed.showIsComplete())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `optional rich fields default to empty or null when absent`() {
|
||||
val event = appDataEvent("podcast-metadata", """{"title":"Bare","description":"d","image":"i"}""")
|
||||
|
||||
Reference in New Issue
Block a user