diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index f457cb6887..d738af64a2 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -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): 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 diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Podcast20Commands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Podcast20Commands.kt new file mode 100644 index 0000000000..a0db3d64d9 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Podcast20Commands.kt @@ -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 ` — 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, + ): Int = + route( + "podcast20", + tail, + "podcast20 ", + 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, + ): 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, + ): 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, + ): 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, + ): 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() + .mapNotNull { Podcasting20PodcastMetadata.parse(it) } + .maxByOrNull { it.event.createdAt } + val episodes = + events + .filterIsInstance() + .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() + .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 = + 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"))) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/metadata/Podcasting20PodcastMetadata.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/metadata/Podcasting20PodcastMetadata.kt index 7c2447000f..79457d18fc 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/metadata/Podcasting20PodcastMetadata.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/metadata/Podcasting20PodcastMetadata.kt @@ -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(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.build(PODCAST_METADATA_D_TAG, JsonMapper.toJson(content), createdAt) } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/Podcasting20PodcastMetadataTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/Podcasting20PodcastMetadataTest.kt index acd33c8b05..7eb3aaa773 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/Podcasting20PodcastMetadataTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/Podcasting20PodcastMetadataTest.kt @@ -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(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"}""")