feat(quartz): merge NIP-F4 and Podcasting-2.0 podcast kinds into one episode list

Amethyst's podcast support (NIP-F4) and derekross/podstr use incompatible
identity models: NIP-F4 makes each podcast its own keypair with regular
kind:54 episodes, while the Podcasting-2.0 draft signs editable, addressable
kind:30054 episodes with the human creator's key. The two cannot share a wire
kind, but a client can still render them in one list.

Add the Podcasting-2.0 episode (kind:30054) and trailer (kind:30055) event
classes plus their tags, and introduce a spec-neutral `PodcastEpisode`
abstraction (with `PodcastAudio`) that both kind:54 and kind:30054 implement.
Feeds and UI can now depend on the shared interface and surface both kind sets
in a single, ordered podcast/episode list. NIP-F4 remains Amethyst's publish
format; this only adds read/parse support for the Podcasting-2.0 kinds.

Register both new kinds in EventFactory and KindNames. Covered by round-trip,
spec-example-JSON, and a unified-list test proving both kinds flow through the
shared abstraction.

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 18:38:10 +00:00
parent 2e47eaf3a6
commit 3d822dac07
23 changed files with 1225 additions and 0 deletions
@@ -300,6 +300,8 @@ import com.vitorpamplona.quartz.nipF4Podcasts.authored.AuthoredPodcastsEvent
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
import com.vitorpamplona.quartz.nipF4Podcasts.favorites.FavoritePodcastsListEvent
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent
/**
* Human-readable label and defining NIP for a Nostr event kind.
@@ -337,6 +339,8 @@ object KindNames {
PodcastMetadataEvent.KIND to KindName("Podcast Show", "F4"),
AuthoredPodcastsEvent.KIND to KindName("Authored Podcasts", "F4"),
FavoritePodcastsListEvent.KIND to KindName("Favorite Podcasts", "F4"),
Podcasting20EpisodeEvent.KIND to KindName("Podcast Episode (Podcasting 2.0)", null),
Podcasting20TrailerEvent.KIND to KindName("Podcast Trailer (Podcasting 2.0)", null),
AttestationEvent.KIND to KindName("Attestation", null),
AttestationRequestEvent.KIND to KindName("Attestation Request", null),
AttestorRecommendationEvent.KIND to KindName("Attestor Recommendation", null),
@@ -30,6 +30,8 @@ import com.vitorpamplona.quartz.nipF4Podcasts.episode.tags.AudioTag
import com.vitorpamplona.quartz.nipF4Podcasts.episode.tags.DescriptionTag
import com.vitorpamplona.quartz.nipF4Podcasts.episode.tags.ImageTag
import com.vitorpamplona.quartz.nipF4Podcasts.episode.tags.TitleTag
import com.vitorpamplona.quartz.podcasts.PodcastAudio
import com.vitorpamplona.quartz.podcasts.PodcastEpisode
import com.vitorpamplona.quartz.utils.TimeUtils
/**
@@ -48,6 +50,7 @@ class PodcastEpisodeEvent(
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig),
PodcastEpisode,
SearchableEvent {
override fun indexableContent() = listOfNotNull(title(), description(), content).joinToString("\n")
@@ -59,6 +62,20 @@ class PodcastEpisodeEvent(
fun audios() = tags.mapNotNull(AudioTag::parse)
override fun episodeTitle() = title()
override fun episodeImage() = image()
override fun episodeDescription() = description()
override fun episodeAudio() = audios().map { PodcastAudio(it.url, it.mediaType) }
// NIP-F4 episodes carry no duration tag; clients derive it from the audio stream.
override fun episodeDurationInSeconds(): Long? = null
// NIP-F4 episodes are regular events ordered by their publication time.
override fun episodePublishedAt() = createdAt
companion object {
const val KIND = 54
@@ -0,0 +1,129 @@
/*
* 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.quartz.nipXXPodcasting20.episode
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
import com.vitorpamplona.quartz.nip31Alts.AltTag
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip50Search.SearchableEvent
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.AudioTag
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.DescriptionTag
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.DurationTag
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.EditTag
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.ImageTag
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.PubDateTag
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.TitleTag
import com.vitorpamplona.quartz.podcasts.PodcastAudio
import com.vitorpamplona.quartz.podcasts.PodcastEpisode
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* Podcasting-2.0 draft podcast episode (`kind:30054`), as published by clients like
* derekross/podstr. Unlike NIP-F4 (where the podcast is its own keypair and episodes
* are regular `kind:54` events), here the **human creator** is the keypair and each
* episode is an *addressable* event keyed by its `d` tag, so it can be edited in place.
*
* Implements the spec-neutral [PodcastEpisode] so it lands in the same merged
* episode list as NIP-F4 episodes.
*/
@Immutable
class Podcasting20EpisodeEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig),
PodcastEpisode,
SearchableEvent {
override fun indexableContent() = listOfNotNull(title(), description(), content).joinToString("\n")
fun title() = tags.firstNotNullOfOrNull(TitleTag::parse)
fun image() = tags.firstNotNullOfOrNull(ImageTag::parse)
fun description() = tags.firstNotNullOfOrNull(DescriptionTag::parse)
fun audios() = tags.mapNotNull(AudioTag::parse)
fun durationInSeconds() = tags.firstNotNullOfOrNull(DurationTag::parse)
/** RFC2822 publication date string, kept verbatim for RSS generation. */
fun pubDate() = tags.firstNotNullOfOrNull(PubDateTag::parse)
fun alt() = tags.firstNotNullOfOrNull(AltTag::parse)
fun topics() = hashtags()
/** Event id of the original publication when this is an edit, if present. */
fun editsEventId() = tags.firstNotNullOfOrNull(EditTag::parse)
override fun episodeTitle() = title()
override fun episodeImage() = image()
override fun episodeDescription() = description()
override fun episodeAudio() = audios()
override fun episodeDurationInSeconds() = durationInSeconds()
override fun episodePublishedAt() = createdAt
companion object {
const val KIND = 30054
fun build(
dTag: String,
title: String,
audios: List<PodcastAudio>,
pubdate: String,
alt: String = "Podcast episode: $title",
description: String? = null,
image: String? = null,
durationInSeconds: Long? = null,
topics: List<String> = emptyList(),
markdownContent: String = "",
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<Podcasting20EpisodeEvent>.() -> Unit = {},
) = eventTemplate<Podcasting20EpisodeEvent>(KIND, markdownContent, createdAt) {
dTag(dTag)
title(title)
audios.forEach { audio(it) }
pubdate(pubdate)
alt(alt)
description?.let { description(it) }
image?.let { image(it) }
durationInSeconds?.let { duration(it) }
if (topics.isNotEmpty()) hashtags(topics)
initializer()
}
}
}
@@ -0,0 +1,46 @@
/*
* 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.quartz.nipXXPodcasting20.episode
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.AudioTag
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.DescriptionTag
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.DurationTag
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.EditTag
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.ImageTag
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.PubDateTag
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.TitleTag
import com.vitorpamplona.quartz.podcasts.PodcastAudio
fun TagArrayBuilder<Podcasting20EpisodeEvent>.title(title: String) = addUnique(TitleTag.assemble(title))
fun TagArrayBuilder<Podcasting20EpisodeEvent>.description(description: String) = addUnique(DescriptionTag.assemble(description))
fun TagArrayBuilder<Podcasting20EpisodeEvent>.image(url: String) = addUnique(ImageTag.assemble(url))
fun TagArrayBuilder<Podcasting20EpisodeEvent>.audio(audio: PodcastAudio) = add(AudioTag.assemble(audio))
fun TagArrayBuilder<Podcasting20EpisodeEvent>.pubdate(rfc2822Date: String) = addUnique(PubDateTag.assemble(rfc2822Date))
fun TagArrayBuilder<Podcasting20EpisodeEvent>.duration(durationInSeconds: Long) = addUnique(DurationTag.assemble(durationInSeconds))
fun TagArrayBuilder<Podcasting20EpisodeEvent>.edit(originalEventId: HexKey) = addUnique(EditTag.assemble(originalEventId))
@@ -0,0 +1,58 @@
/*
* 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.quartz.nipXXPodcasting20.episode.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.podcasts.PodcastAudio
import com.vitorpamplona.quartz.utils.ensure
/**
* Podcasting-2.0 episode audio tag: `["audio", "<url>", "<optional_media_type>"]`.
*
* Wire-identical to the NIP-F4 audio tag, so it parses straight into the shared
* [PodcastAudio] holder that the unified [com.vitorpamplona.quartz.podcasts.PodcastEpisode]
* abstraction exposes.
*/
class AudioTag {
companion object {
const val TAG_NAME = "audio"
fun parse(tag: Array<String>): PodcastAudio? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
val mediaType = tag.getOrNull(2)?.takeIf { it.isNotEmpty() }
return PodcastAudio(tag[1], mediaType)
}
fun assemble(
url: String,
mediaType: String? = null,
): Array<String> =
if (mediaType.isNullOrEmpty()) {
arrayOf(TAG_NAME, url)
} else {
arrayOf(TAG_NAME, url, mediaType)
}
fun assemble(audio: PodcastAudio) = assemble(audio.url, audio.mediaType)
}
}
@@ -0,0 +1,39 @@
/*
* 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.quartz.nipXXPodcasting20.episode.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
class DescriptionTag {
companion object {
const val TAG_NAME = "description"
fun parse(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
fun assemble(description: String) = arrayOf(TAG_NAME, description)
}
}
@@ -0,0 +1,40 @@
/*
* 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.quartz.nipXXPodcasting20.episode.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/** Podcasting-2.0 episode duration in whole seconds: `["duration", "3600"]`. */
class DurationTag {
companion object {
const val TAG_NAME = "duration"
fun parse(tag: Array<String>): Long? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1].toLongOrNull()
}
fun assemble(durationInSeconds: Long) = arrayOf(TAG_NAME, durationInSeconds.toString())
}
}
@@ -0,0 +1,45 @@
/*
* 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.quartz.nipXXPodcasting20.episode.tags
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* Podcasting-2.0 edit-history pointer: `["edit", "<original-event-id>"]`. References
* the event id of the original publication when an addressable episode/trailer is
* updated, so clients can reconstruct edit history.
*/
class EditTag {
companion object {
const val TAG_NAME = "edit"
fun parse(tag: Array<String>): HexKey? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
fun assemble(originalEventId: HexKey) = arrayOf(TAG_NAME, originalEventId)
}
}
@@ -0,0 +1,39 @@
/*
* 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.quartz.nipXXPodcasting20.episode.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
class ImageTag {
companion object {
const val TAG_NAME = "image"
fun parse(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
fun assemble(url: String) = arrayOf(TAG_NAME, url)
}
}
@@ -0,0 +1,47 @@
/*
* 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.quartz.nipXXPodcasting20.episode.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* Podcasting-2.0 publication date in RFC2822 format, set once when first published
* and preserved across edits: `["pubdate", "Thu, 04 Nov 2023 12:00:00 GMT"]`.
*
* The string is kept verbatim — it feeds RSS generation, where the exact RFC2822
* spelling matters. Feed ordering relies on the event's `created_at` instead, so
* no date parsing is required here.
*/
class PubDateTag {
companion object {
const val TAG_NAME = "pubdate"
fun parse(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
fun assemble(rfc2822Date: String) = arrayOf(TAG_NAME, rfc2822Date)
}
}
@@ -0,0 +1,39 @@
/*
* 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.quartz.nipXXPodcasting20.episode.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
class TitleTag {
companion object {
const val TAG_NAME = "title"
fun parse(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
fun assemble(title: String) = arrayOf(TAG_NAME, title)
}
}
@@ -0,0 +1,96 @@
/*
* 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.quartz.nipXXPodcasting20.trailer
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
import com.vitorpamplona.quartz.nip31Alts.AltTag
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.PubDateTag
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.TitleTag
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.tags.LengthTag
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.tags.SeasonTag
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.tags.TypeTag
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.tags.UrlTag
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* Podcasting-2.0 draft podcast trailer (`kind:30055`), following the Podcast 2.0
* `<podcast:trailer>` element. Addressable like the episode (`kind:30054`) and
* signed by the human creator's keypair, keyed by its `d` tag.
*/
@Immutable
class Podcasting20TrailerEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
fun title() = tags.firstNotNullOfOrNull(TitleTag::parse)
fun url() = tags.firstNotNullOfOrNull(UrlTag::parse)
/** RFC2822 publication date string, kept verbatim for RSS generation. */
fun pubDate() = tags.firstNotNullOfOrNull(PubDateTag::parse)
fun lengthInBytes() = tags.firstNotNullOfOrNull(LengthTag::parse)
fun mimeType() = tags.firstNotNullOfOrNull(TypeTag::parse)
fun season() = tags.firstNotNullOfOrNull(SeasonTag::parse)
fun alt() = tags.firstNotNullOfOrNull(AltTag::parse)
companion object {
const val KIND = 30055
fun build(
dTag: String,
title: String,
url: String,
pubdate: String,
alt: String = "Podcast trailer: $title",
lengthInBytes: Long? = null,
mimeType: String? = null,
season: Int? = null,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<Podcasting20TrailerEvent>.() -> Unit = {},
) = eventTemplate<Podcasting20TrailerEvent>(KIND, title, createdAt) {
dTag(dTag)
title(title)
url(url)
pubdate(pubdate)
alt(alt)
lengthInBytes?.let { length(it) }
mimeType?.let { type(it) }
season?.let { season(it) }
initializer()
}
}
}
@@ -0,0 +1,45 @@
/*
* 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.quartz.nipXXPodcasting20.trailer
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.EditTag
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.PubDateTag
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.TitleTag
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.tags.LengthTag
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.tags.SeasonTag
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.tags.TypeTag
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.tags.UrlTag
fun TagArrayBuilder<Podcasting20TrailerEvent>.title(title: String) = addUnique(TitleTag.assemble(title))
fun TagArrayBuilder<Podcasting20TrailerEvent>.url(url: String) = addUnique(UrlTag.assemble(url))
fun TagArrayBuilder<Podcasting20TrailerEvent>.pubdate(rfc2822Date: String) = addUnique(PubDateTag.assemble(rfc2822Date))
fun TagArrayBuilder<Podcasting20TrailerEvent>.length(lengthInBytes: Long) = addUnique(LengthTag.assemble(lengthInBytes))
fun TagArrayBuilder<Podcasting20TrailerEvent>.type(mimeType: String) = addUnique(TypeTag.assemble(mimeType))
fun TagArrayBuilder<Podcasting20TrailerEvent>.season(season: Int) = addUnique(SeasonTag.assemble(season))
fun TagArrayBuilder<Podcasting20TrailerEvent>.edit(originalEventId: HexKey) = addUnique(EditTag.assemble(originalEventId))
@@ -0,0 +1,40 @@
/*
* 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.quartz.nipXXPodcasting20.trailer.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/** Podcasting-2.0 trailer file size in bytes: `["length", "1024000"]`. */
class LengthTag {
companion object {
const val TAG_NAME = "length"
fun parse(tag: Array<String>): Long? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1].toLongOrNull()
}
fun assemble(lengthInBytes: Long) = arrayOf(TAG_NAME, lengthInBytes.toString())
}
}
@@ -0,0 +1,40 @@
/*
* 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.quartz.nipXXPodcasting20.trailer.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/** Podcasting-2.0 season number a trailer represents: `["season", "2"]`. */
class SeasonTag {
companion object {
const val TAG_NAME = "season"
fun parse(tag: Array<String>): Int? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1].toIntOrNull()
}
fun assemble(season: Int) = arrayOf(TAG_NAME, season.toString())
}
}
@@ -0,0 +1,40 @@
/*
* 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.quartz.nipXXPodcasting20.trailer.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/** Podcasting-2.0 trailer MIME type: `["type", "audio/mpeg"]`. */
class TypeTag {
companion object {
const val TAG_NAME = "type"
fun parse(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
fun assemble(mimeType: String) = arrayOf(TAG_NAME, mimeType)
}
}
@@ -0,0 +1,40 @@
/*
* 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.quartz.nipXXPodcasting20.trailer.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/** Podcasting-2.0 trailer media URL: `["url", "<media-url>"]`. */
class UrlTag {
companion object {
const val TAG_NAME = "url"
fun parse(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
fun assemble(url: String) = arrayOf(TAG_NAME, url)
}
}
@@ -0,0 +1,38 @@
/*
* 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.quartz.podcasts
import androidx.compose.runtime.Immutable
/**
* Spec-neutral audio reference for a podcast episode, used by the shared
* [PodcastEpisode] abstraction so a UI can play episodes regardless of which
* podcast NIP produced them.
*
* Both NIP-F4 (`kind:54`) and the Podcasting-2.0 draft (`kind:30054`) carry audio
* in identical `["audio", "<url>", "<optional_media_type>"]` tags; each event maps
* its own tag class into this holder.
*/
@Immutable
class PodcastAudio(
val url: String,
val mediaType: String? = null,
)
@@ -0,0 +1,63 @@
/*
* 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.quartz.podcasts
/**
* Spec-neutral view of a single podcast episode.
*
* Two competing podcast drafts publish episodes with incompatible identity models
* and event kinds:
* - **NIP-F4** ([com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent],
* `kind:54`): a regular event where the *podcast itself* is a Nostr keypair.
* - **Podcasting-2.0 draft** ([com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent],
* `kind:30054`): an addressable event where the *human creator* is the keypair
* and episodes are editable via their `d` tag.
*
* The two cannot share a single wire kind, but a client can still render them in
* one list. This interface is that merge point: feeds and UI depend on it instead
* of a concrete event, so both kinds flow into the same podcast/episode list.
*/
interface PodcastEpisode {
/** The episode title shown in listings. */
fun episodeTitle(): String?
/** Cover/episode artwork URL, if any. */
fun episodeImage(): String?
/** Short, plain-text episode description/summary, if any. */
fun episodeDescription(): String?
/**
* One or more audio sources for the episode. Multiple entries typically offer
* the same audio in different containers/codecs (e.g. mp3 + opus).
*/
fun episodeAudio(): List<PodcastAudio>
/** Episode duration in seconds, if the publisher provided it. */
fun episodeDurationInSeconds(): Long?
/**
* Unix timestamp (seconds) used to order episodes in a merged feed. Both drafts
* fall back to the event's `created_at`; the Podcasting-2.0 draft additionally
* carries an RFC2822 `pubdate` tag exposed by its own event class.
*/
fun episodePublishedAt(): Long
}
@@ -309,6 +309,8 @@ import com.vitorpamplona.quartz.nipF4Podcasts.authored.AuthoredPodcastsEvent
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
import com.vitorpamplona.quartz.nipF4Podcasts.favorites.FavoritePodcastsListEvent
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent
interface EventBuilder {
fun build(
@@ -566,6 +568,8 @@ class EventFactory {
PodcastEpisodeEvent.KIND -> PodcastEpisodeEvent(id, pubKey, createdAt, tags, content, sig)
AuthoredPodcastsEvent.KIND -> AuthoredPodcastsEvent(id, pubKey, createdAt, tags, content, sig)
FavoritePodcastsListEvent.KIND -> FavoritePodcastsListEvent(id, pubKey, createdAt, tags, content, sig)
Podcasting20EpisodeEvent.KIND -> Podcasting20EpisodeEvent(id, pubKey, createdAt, tags, content, sig)
Podcasting20TrailerEvent.KIND -> Podcasting20TrailerEvent(id, pubKey, createdAt, tags, content, sig)
ProductEvent.KIND -> ProductEvent(id, pubKey, createdAt, tags, content, sig)
PrivateDmEvent.KIND -> PrivateDmEvent(id, pubKey, createdAt, tags, content, sig)
PrivateOutboxRelayListEvent.KIND -> PrivateOutboxRelayListEvent(id, pubKey, createdAt, tags, content, sig)
@@ -0,0 +1,147 @@
/*
* 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.quartz.nipXXPodcasting20
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.edit
import com.vitorpamplona.quartz.podcasts.PodcastAudio
import com.vitorpamplona.quartz.utils.DeterministicSigner
import com.vitorpamplona.quartz.utils.nsecToKeyPair
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
class Podcasting20EpisodeEventTest {
private val signer =
DeterministicSigner(
"nsec10g0wheggqn9dawlc0yuv6adnat6n09anr7eyykevw2dm8xa5fffs0wsdsr".nsecToKeyPair(),
)
@Test
fun `kind is 30054`() {
assertEquals(30054, Podcasting20EpisodeEvent.KIND)
}
@Test
fun `build round-trips fields and is addressable by d tag`() {
val template =
Podcasting20EpisodeEvent.build(
dTag = "episode-1699123456-abc123def",
title = "The Future of Decentralized Social Media",
audios = listOf(PodcastAudio("https://example.com/episodes/episode-001.mp3", "audio/mpeg")),
pubdate = "Thu, 04 Nov 2023 12:00:00 GMT",
description = "A deep dive into how protocols like Nostr are changing social media",
image = "https://example.com/artwork/episode-001.jpg",
durationInSeconds = 3600,
topics = listOf("technology", "decentralization"),
markdownContent = "In this episode, we discuss decentralized social media.",
)
val event = signer.sign<Podcasting20EpisodeEvent>(template)
assertEquals("episode-1699123456-abc123def", event.dTag())
assertEquals("The Future of Decentralized Social Media", event.title())
assertEquals("A deep dive into how protocols like Nostr are changing social media", event.description())
assertEquals("https://example.com/artwork/episode-001.jpg", event.image())
assertEquals(3600L, event.durationInSeconds())
assertEquals("Thu, 04 Nov 2023 12:00:00 GMT", event.pubDate())
assertEquals("In this episode, we discuss decentralized social media.", event.content)
val audios = event.audios()
assertEquals(1, audios.size)
assertEquals("https://example.com/episodes/episode-001.mp3", audios[0].url)
assertEquals("audio/mpeg", audios[0].mediaType)
assertTrue(event.topics().containsAll(listOf("technology", "decentralization")))
assertEquals("Podcast episode: The Future of Decentralized Social Media", event.alt())
}
@Test
fun `optional fields default to null`() {
val template =
Podcasting20EpisodeEvent.build(
dTag = "ep-2",
title = "No extras",
audios = listOf(PodcastAudio("https://example.com/ep2.mp3")),
pubdate = "Thu, 04 Nov 2023 12:00:00 GMT",
)
val event = signer.sign<Podcasting20EpisodeEvent>(template)
assertNull(event.image())
assertNull(event.description())
assertNull(event.durationInSeconds())
assertNull(event.editsEventId())
}
@Test
fun `edit tag tracks the original event id`() {
val template =
Podcasting20EpisodeEvent.build(
dTag = "ep-3",
title = "Corrected",
audios = listOf(PodcastAudio("https://example.com/ep3.mp3")),
pubdate = "Thu, 04 Nov 2023 12:00:00 GMT",
) {
edit("abababababababababababababababababababababababababababababababab")
}
val event = signer.sign<Podcasting20EpisodeEvent>(template)
assertEquals("abababababababababababababababababababababababababababababababab", event.editsEventId())
}
@Test
fun `parses the spec example json into the typed event via EventFactory`() {
// Verbatim example from derekross/podstr NIP.md (id and sig elided are not
// required for parsing tags; we supply structurally valid placeholders).
val json =
"""
{
"kind": 30054,
"content": "In this episode, we discuss the latest developments in decentralized social media protocols.",
"tags": [
["d", "episode-1699123456-abc123def"],
["title", "The Future of Decentralized Social Media"],
["audio", "https://example.com/episodes/episode-001.mp3", "audio/mpeg"],
["pubdate", "Thu, 04 Nov 2023 12:00:00 GMT"],
["alt", "Podcast episode: The Future of Decentralized Social Media"],
["description", "A deep dive into how protocols like Nostr are changing social media"],
["image", "https://example.com/artwork/episode-001.jpg"],
["duration", "3600"],
["t", "technology"],
["t", "decentralization"],
["t", "social-media"]
],
"created_at": 1699123456,
"pubkey": "0000000000000000000000000000000000000000000000000000000000000001",
"id": "0000000000000000000000000000000000000000000000000000000000000002",
"sig": "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
}
""".trimIndent()
val event = Event.fromJson(json)
assertTrue(event is Podcasting20EpisodeEvent)
assertEquals("The Future of Decentralized Social Media", event.title())
assertEquals(3600L, event.durationInSeconds())
assertEquals(3, event.topics().size)
assertEquals("https://example.com/episodes/episode-001.mp3", event.audios()[0].url)
}
}
@@ -0,0 +1,81 @@
/*
* 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.quartz.nipXXPodcasting20
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent
import com.vitorpamplona.quartz.utils.DeterministicSigner
import com.vitorpamplona.quartz.utils.nsecToKeyPair
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class Podcasting20TrailerEventTest {
private val signer =
DeterministicSigner(
"nsec10g0wheggqn9dawlc0yuv6adnat6n09anr7eyykevw2dm8xa5fffs0wsdsr".nsecToKeyPair(),
)
@Test
fun `kind is 30055`() {
assertEquals(30055, Podcasting20TrailerEvent.KIND)
}
@Test
fun `build round-trips fields`() {
val template =
Podcasting20TrailerEvent.build(
dTag = "trailer-1699123456-xyz789abc",
title = "Season 2 Preview",
url = "https://example.com/trailers/season-2-preview.mp3",
pubdate = "Thu, 04 Nov 2023 12:00:00 GMT",
lengthInBytes = 1024000,
mimeType = "audio/mpeg",
season = 2,
)
val event = signer.sign<Podcasting20TrailerEvent>(template)
assertEquals("trailer-1699123456-xyz789abc", event.dTag())
assertEquals("Season 2 Preview", event.title())
assertEquals("https://example.com/trailers/season-2-preview.mp3", event.url())
assertEquals("Thu, 04 Nov 2023 12:00:00 GMT", event.pubDate())
assertEquals(1024000L, event.lengthInBytes())
assertEquals("audio/mpeg", event.mimeType())
assertEquals(2, event.season())
// Per spec the content SHOULD carry the trailer title.
assertEquals("Season 2 Preview", event.content)
}
@Test
fun `optional fields default to null`() {
val template =
Podcasting20TrailerEvent.build(
dTag = "trailer-min",
title = "Teaser",
url = "https://example.com/teaser.mp3",
pubdate = "Thu, 04 Nov 2023 12:00:00 GMT",
)
val event = signer.sign<Podcasting20TrailerEvent>(template)
assertNull(event.lengthInBytes())
assertNull(event.mimeType())
assertNull(event.season())
}
}
@@ -0,0 +1,88 @@
/*
* 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.quartz.podcasts
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
import com.vitorpamplona.quartz.nipF4Podcasts.episode.tags.AudioTag
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent
import com.vitorpamplona.quartz.utils.DeterministicSigner
import com.vitorpamplona.quartz.utils.nsecToKeyPair
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
/**
* Proves the merge: a NIP-F4 episode (`kind:54`) and a Podcasting-2.0 episode
* (`kind:30054`) — built on different identity models and event kinds — flow into
* a single `List<PodcastEpisode>` and expose the same fields through the shared
* abstraction.
*/
class UnifiedPodcastEpisodeTest {
private val signer =
DeterministicSigner(
"nsec10g0wheggqn9dawlc0yuv6adnat6n09anr7eyykevw2dm8xa5fffs0wsdsr".nsecToKeyPair(),
)
@Test
fun `both kinds satisfy the shared PodcastEpisode abstraction`() {
val f4 =
signer.sign<PodcastEpisodeEvent>(
PodcastEpisodeEvent.build(
title = "F4 Episode",
description = "Published under NIP-F4",
audios = listOf(AudioTag("https://example.com/f4.mp3", "audio/mpeg")),
image = "https://example.com/f4.png",
createdAt = 1000,
),
)
val pc20 =
signer.sign<Podcasting20EpisodeEvent>(
Podcasting20EpisodeEvent.build(
dTag = "ep-1",
title = "Podcasting 2.0 Episode",
audios = listOf(PodcastAudio("https://example.com/pc20.mp3", "audio/mpeg")),
pubdate = "Thu, 04 Nov 2023 12:00:00 GMT",
description = "Published under kind 30054",
image = "https://example.com/pc20.png",
durationInSeconds = 1800,
createdAt = 2000,
),
)
// The whole point: one list, mixed kinds, ordered newest-first.
val unified: List<PodcastEpisode> = listOf(f4, pc20).sortedByDescending { it.episodePublishedAt() }
assertEquals(listOf("Podcasting 2.0 Episode", "F4 Episode"), unified.map { it.episodeTitle() })
unified.forEach { episode ->
assertEquals("audio/mpeg", episode.episodeAudio().single().mediaType)
}
// F4 has no duration tag; the Podcasting-2.0 episode carries one.
val byTitle = unified.associateBy { it.episodeTitle() }
assertNull(byTitle["F4 Episode"]!!.episodeDurationInSeconds())
assertEquals(1800L, byTitle["Podcasting 2.0 Episode"]!!.episodeDurationInSeconds())
assertEquals(1000L, byTitle["F4 Episode"]!!.episodePublishedAt())
assertEquals(2000L, byTitle["Podcasting 2.0 Episode"]!!.episodePublishedAt())
}
}