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:
Claude
2026-06-27 23:05:01 +00:00
parent 88ba824a6e
commit 8573e4fd2f
4 changed files with 359 additions and 0 deletions
@@ -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")))
}
@@ -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)
}
}
@@ -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"}""")