feat(quartz): add NIP-F4 podcast event support

Implements the four event kinds defined by NIP-F4 so Quartz can parse and
build native Nostr podcasts: kind:10154 show metadata, kind:10064 author
counter-claim, kind:54 episode, and kind:10054 favorite-podcasts list. All
four are registered in EventFactory so the existing JSON deserialization
pipeline returns typed instances. Tag classes mirror the per-event package
layout used by the experimental music module.
This commit is contained in:
Claude
2026-05-28 20:10:39 +00:00
parent d2e5364074
commit b47cdf5b96
21 changed files with 1433 additions and 0 deletions
@@ -0,0 +1,80 @@
/*
* 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.nipF4Podcasts.authored
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* NIP-F4 author counter-claim (`kind:10064`). A user authors this to assert which
* podcast pubkeys they actually create — used by clients to confirm that the
* authorship claim made by a [com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent]
* is reciprocated by the alleged author, and to discover all podcasts authored
* by a given user.
*/
@Immutable
class AuthoredPodcastsEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig),
PubKeyHintProvider {
fun authored() = tags.mapNotNull(UserTag::parse)
fun authoredKeys() = tags.mapNotNull(UserTag::parseKey)
fun authors(podcastPubKey: HexKey) = tags.any { UserTag.isTagged(it, podcastPubKey) }
override fun linkedPubKeys() = tags.mapNotNull(UserTag::parseKey)
override fun pubKeyHints() = tags.mapNotNull(UserTag::parseAsHint)
companion object {
const val KIND = 10064
const val ALT_DESCRIPTION = "List of authored podcasts"
fun createAddress(pubKey: HexKey) = Address(KIND, pubKey, FIXED_D_TAG)
fun createAddressATag(pubKey: HexKey) = ATag(KIND, pubKey, FIXED_D_TAG, null)
fun build(
podcasts: List<UserTag>,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<AuthoredPodcastsEvent>.() -> Unit = {},
) = eventTemplate<AuthoredPodcastsEvent>(KIND, "", createdAt) {
alt(ALT_DESCRIPTION)
podcasts.forEach { podcast(it) }
initializer()
}
}
}
@@ -0,0 +1,26 @@
/*
* 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.nipF4Podcasts.authored
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag
fun TagArrayBuilder<AuthoredPodcastsEvent>.podcast(podcast: UserTag) = add(podcast.toTagArray())
@@ -0,0 +1,82 @@
/*
* 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.nipF4Podcasts.episode
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Event
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.nip31Alts.alt
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.utils.TimeUtils
/**
* NIP-F4 podcast episode (`kind:54`), regular event authored by the podcast pubkey.
*
* `content` is the markdown-formatted episode notes. Title, image, short description,
* and one or more audio URLs live in tags so clients can render listings without
* parsing markdown.
*/
@Immutable
class PodcastEpisodeEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
fun title() = tags.firstNotNullOfOrNull(TitleTag::parse)
fun image() = tags.firstNotNullOfOrNull(ImageTag::parse)
fun description() = tags.firstNotNullOfOrNull(DescriptionTag::parse)
fun audios() = tags.mapNotNull(AudioTag::parse)
companion object {
const val KIND = 54
const val ALT_DESCRIPTION_PREFIX = "Podcast episode"
fun build(
title: String,
description: String,
audios: List<AudioTag>,
markdownContent: String = "",
image: String? = null,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<PodcastEpisodeEvent>.() -> Unit = {},
) = eventTemplate<PodcastEpisodeEvent>(KIND, markdownContent, createdAt) {
alt("$ALT_DESCRIPTION_PREFIX: $title")
title(title)
description(description)
image?.let { image(it) }
audios.forEach { audio(it) }
initializer()
}
}
}
@@ -0,0 +1,35 @@
/*
* 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.nipF4Podcasts.episode
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
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
fun TagArrayBuilder<PodcastEpisodeEvent>.title(title: String) = addUnique(TitleTag.assemble(title))
fun TagArrayBuilder<PodcastEpisodeEvent>.image(url: String) = addUnique(ImageTag.assemble(url))
fun TagArrayBuilder<PodcastEpisodeEvent>.description(description: String) = addUnique(DescriptionTag.assemble(description))
fun TagArrayBuilder<PodcastEpisodeEvent>.audio(audio: AudioTag) = add(AudioTag.assemble(audio))
@@ -0,0 +1,67 @@
/*
* 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.nipF4Podcasts.episode.tags
import androidx.compose.runtime.Stable
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* NIP-F4 episode audio tag: `["audio", "<url>", "<optional_media_type>"]`.
*
* Multiple `audio` tags are allowed — typically used to provide the same episode
* in different containers/codecs (e.g. mp3 + opus). When present, [mediaType] is
* a MIME type the client can use to pick a stream it can decode without HEAD-ing
* the URL first.
*/
@Stable
class AudioTag(
val url: String,
val mediaType: String? = null,
) {
fun toTagArray() = assemble(url, mediaType)
companion object {
const val TAG_NAME = "audio"
fun parse(tag: Array<String>): AudioTag? {
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 AudioTag(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: AudioTag) = assemble(audio.url, audio.mediaType)
fun assemble(audios: List<AudioTag>) = audios.map { assemble(it) }
}
}
@@ -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.nipF4Podcasts.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,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.nipF4Podcasts.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,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.nipF4Podcasts.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,156 @@
/*
* 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.nipF4Podcasts.favorites
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.core.fastAny
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import com.vitorpamplona.quartz.nip31Alts.AltTag
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent
import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag
import com.vitorpamplona.quartz.nip51Lists.remove
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* NIP-F4 user favorite-podcasts list (`kind:10054`). Per the spec this advertises
* the podcasts the user listens to as a soft public recommendation. Items are
* `p` tags pointing at podcast pubkeys.
*
* Follows the NIP-51 convention of also allowing private items stored encrypted
* in `content`, so a user can keep guilty pleasures off the public list while
* still using the same event kind for their own subscription state.
*/
@Immutable
class FavoritePodcastsListEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
fun publicFavorites() = tags.mapNotNull(UserTag::parse)
suspend fun privateFavorites(signer: NostrSigner) = privateTags(signer)?.mapNotNull(UserTag::parse)
companion object {
const val KIND = 10054
const val ALT = "Favorite podcasts list"
fun createAddress(pubKey: HexKey) = Address(KIND, pubKey, "")
suspend fun create(
publicFavorites: List<UserTag> = emptyList(),
privateFavorites: List<UserTag> = emptyList(),
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): FavoritePodcastsListEvent =
resign(
content = PrivateTagsInContent.encryptNip44(privateFavorites.map { it.toTagArray() }.toTypedArray(), signer),
tags = publicFavorites.map { it.toTagArray() }.toTypedArray(),
signer = signer,
createdAt = createdAt,
)
suspend fun add(
earlierVersion: FavoritePodcastsListEvent,
podcast: UserTag,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): FavoritePodcastsListEvent =
if (isPrivate) {
val privateTags =
earlierVersion.privateTags(signer)
?: throw SignerExceptions.UnauthorizedDecryptionException()
resign(
tags = earlierVersion.tags,
privateTags = privateTags.remove(podcast.toTagIdOnly()) + podcast.toTagArray(),
signer = signer,
createdAt = createdAt,
)
} else {
resign(
content = earlierVersion.content,
tags = earlierVersion.tags.remove(podcast.toTagIdOnly()) + podcast.toTagArray(),
signer = signer,
createdAt = createdAt,
)
}
suspend fun remove(
earlierVersion: FavoritePodcastsListEvent,
podcast: UserTag,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): FavoritePodcastsListEvent {
val idOnly = podcast.toTagIdOnly()
val privateTags = earlierVersion.privateTags(signer)
return if (privateTags != null) {
resign(
privateTags = privateTags.remove(idOnly),
tags = earlierVersion.tags.remove(idOnly),
signer = signer,
createdAt = createdAt,
)
} else {
resign(
content = earlierVersion.content,
tags = earlierVersion.tags.remove(idOnly),
signer = signer,
createdAt = createdAt,
)
}
}
suspend fun resign(
tags: TagArray,
privateTags: TagArray,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
) = resign(
content = PrivateTagsInContent.encryptNip44(privateTags, signer),
tags = tags,
signer = signer,
createdAt = createdAt,
)
suspend fun resign(
content: String,
tags: TagArray,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): FavoritePodcastsListEvent {
val newTags =
if (tags.fastAny(AltTag::match)) {
tags
} else {
tags + AltTag.assemble(ALT)
}
return signer.sign(createdAt, KIND, newTags, content)
}
}
}
@@ -0,0 +1,102 @@
/*
* 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.nipF4Podcasts.metadata
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent
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.aTag.ATag
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.tags.AuthorTag
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.tags.DescriptionTag
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.tags.ImageTag
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.tags.TitleTag
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.tags.WebsiteTag
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* NIP-F4 show-level metadata for a podcast. Authored by the podcast's own pubkey
* (each podcast is its own Nostr keypair). Replaceable: each new event of this
* kind replaces the previous version for that pubkey.
*
* Podcast-specific clients SHOULD read from [PodcastMetadataEvent] directly and
* ignore the podcast's `kind:0` profile, per spec — the show name and cover art
* shipped here are the canonical ones for podcast UIs even if the same pubkey
* also publishes a microblogging profile.
*/
@Immutable
class PodcastMetadataEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
fun title() = tags.firstNotNullOfOrNull(TitleTag::parse)
fun image() = tags.firstNotNullOfOrNull(ImageTag::parse)
fun description() = tags.firstNotNullOfOrNull(DescriptionTag::parse)
fun websites() = tags.mapNotNull(WebsiteTag::parse)
/**
* Returns claimed authors and their roles. The spec warns these claims are
* unverified — a podcast can name anyone as author. Before surfacing an author
* to users, cross-check with an [com.vitorpamplona.quartz.nipF4Podcasts.authored.AuthoredPodcastsEvent]
* counter-claim signed by that author's own key.
*/
fun claimedAuthors() = tags.mapNotNull(AuthorTag::parse)
companion object {
const val KIND = 10154
const val ALT_DESCRIPTION = "Podcast metadata"
fun createAddress(pubKey: HexKey) = Address(KIND, pubKey, FIXED_D_TAG)
fun createAddressATag(pubKey: HexKey) = ATag(KIND, pubKey, FIXED_D_TAG, null)
fun build(
title: String,
image: String,
description: String,
websites: List<String> = emptyList(),
authors: List<AuthorTag> = emptyList(),
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<PodcastMetadataEvent>.() -> Unit = {},
) = eventTemplate<PodcastMetadataEvent>(KIND, "", createdAt) {
alt(ALT_DESCRIPTION)
title(title)
image(image)
description(description)
websites.forEach { website(it) }
authors.forEach { author(it) }
initializer()
}
}
}
@@ -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.nipF4Podcasts.metadata
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.tags.AuthorTag
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.tags.DescriptionTag
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.tags.ImageTag
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.tags.TitleTag
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.tags.WebsiteTag
fun TagArrayBuilder<PodcastMetadataEvent>.title(title: String) = addUnique(TitleTag.assemble(title))
fun TagArrayBuilder<PodcastMetadataEvent>.image(url: String) = addUnique(ImageTag.assemble(url))
fun TagArrayBuilder<PodcastMetadataEvent>.description(description: String) = addUnique(DescriptionTag.assemble(description))
fun TagArrayBuilder<PodcastMetadataEvent>.website(url: String) = add(WebsiteTag.assemble(url))
fun TagArrayBuilder<PodcastMetadataEvent>.author(author: AuthorTag) = add(AuthorTag.assemble(author))
@@ -0,0 +1,76 @@
/*
* 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.nipF4Podcasts.metadata.tags
import androidx.compose.runtime.Stable
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* NIP-F4 author p-tag: `["p", "<podcast-author-pubkey>", "<role>"]`.
*
* Role is optional and, per spec, may be `"host"`, `"cohost"`, or `"editor"`.
* Unknown role strings are preserved verbatim so future role values round-trip;
* clients should validate against [Role] before displaying.
*
* NOTE: this tag overloads `p`'s third slot for role, NOT the usual relay hint.
* Don't reuse this parser on p-tags from other event kinds.
*/
@Stable
class AuthorTag(
val pubKey: HexKey,
val role: String? = null,
) {
fun toTagArray() = assemble(pubKey, role)
companion object {
const val TAG_NAME = "p"
const val ROLE_HOST = "host"
const val ROLE_COHOST = "cohost"
const val ROLE_EDITOR = "editor"
fun isTagged(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].length == 64
fun parse(tag: Array<String>): AuthorTag? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].length == 64) { return null }
val role = tag.getOrNull(2)?.takeIf { it.isNotEmpty() }
return AuthorTag(tag[1], role)
}
fun assemble(
pubKey: HexKey,
role: String? = null,
): Array<String> =
if (role.isNullOrEmpty()) {
arrayOf(TAG_NAME, pubKey)
} else {
arrayOf(TAG_NAME, pubKey, role)
}
fun assemble(author: AuthorTag) = assemble(author.pubKey, author.role)
fun assemble(authors: List<AuthorTag>) = authors.map { assemble(it) }
}
}
@@ -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.nipF4Podcasts.metadata.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,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.nipF4Podcasts.metadata.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,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.nipF4Podcasts.metadata.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,41 @@
/*
* 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.nipF4Podcasts.metadata.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
class WebsiteTag {
companion object {
const val TAG_NAME = "website"
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)
fun assemble(urls: List<String>) = urls.map { assemble(it) }
}
}
@@ -291,6 +291,10 @@ import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent
import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
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
interface EventBuilder {
fun build(
@@ -534,6 +538,10 @@ class EventFactory {
ZapPollEvent.KIND -> ZapPollEvent(id, pubKey, createdAt, tags, content, sig)
PollEvent.KIND -> PollEvent(id, pubKey, createdAt, tags, content, sig)
PollResponseEvent.KIND -> PollResponseEvent(id, pubKey, createdAt, tags, content, sig)
PodcastMetadataEvent.KIND -> PodcastMetadataEvent(id, pubKey, createdAt, tags, content, sig)
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)
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,79 @@
/*
* 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.nipF4Podcasts
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag
import com.vitorpamplona.quartz.nipF4Podcasts.authored.AuthoredPodcastsEvent
import com.vitorpamplona.quartz.utils.DeterministicSigner
import com.vitorpamplona.quartz.utils.nsecToKeyPair
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class AuthoredPodcastsEventTest {
private val signer =
DeterministicSigner(
"nsec10g0wheggqn9dawlc0yuv6adnat6n09anr7eyykevw2dm8xa5fffs0wsdsr".nsecToKeyPair(),
)
private val podcast1 = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"
private val podcast2 = "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245"
private val unrelated = "0000000000000000000000000000000000000000000000000000000000000001"
@Test
fun `kind is 10064`() {
assertEquals(10064, AuthoredPodcastsEvent.KIND)
}
@Test
fun `build round-trips podcast pubkeys`() {
val template =
AuthoredPodcastsEvent.build(
podcasts = listOf(UserTag(podcast1), UserTag(podcast2)),
)
val event = signer.sign<AuthoredPodcastsEvent>(template)
assertEquals(listOf(podcast1, podcast2), event.authoredKeys())
assertTrue(event.authors(podcast1))
assertTrue(event.authors(podcast2))
assertFalse(event.authors(unrelated))
}
@Test
fun `is replaceable with empty d-tag`() {
val template = AuthoredPodcastsEvent.build(emptyList())
val event = signer.sign<AuthoredPodcastsEvent>(template)
assertEquals("", event.dTag())
}
@Test
fun `linkedPubKeys returns all authored podcast keys`() {
val template =
AuthoredPodcastsEvent.build(
podcasts = listOf(UserTag(podcast1), UserTag(podcast2)),
)
val event = signer.sign<AuthoredPodcastsEvent>(template)
assertEquals(setOf(podcast1, podcast2), event.linkedPubKeys().toSet())
}
}
@@ -0,0 +1,157 @@
/*
* 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.nipF4Podcasts
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag
import com.vitorpamplona.quartz.nipF4Podcasts.favorites.FavoritePodcastsListEvent
import com.vitorpamplona.quartz.utils.nsecToKeyPair
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
class FavoritePodcastsListEventTest {
private val signer =
NostrSignerInternal(
"nsec10g0wheggqn9dawlc0yuv6adnat6n09anr7eyykevw2dm8xa5fffs0wsdsr".nsecToKeyPair(),
)
private val podcast1 = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"
private val podcast2 = "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245"
@Test
fun `kind is 10054`() {
assertEquals(10054, FavoritePodcastsListEvent.KIND)
}
@Test
fun `create with public favorites round-trips`() =
runTest {
val event =
FavoritePodcastsListEvent.create(
publicFavorites = listOf(UserTag(podcast1), UserTag(podcast2)),
signer = signer,
createdAt = 1_700_000_000,
)
val pubs = event.publicFavorites()
assertEquals(2, pubs.size)
assertEquals(podcast1, pubs[0].pubKey)
assertEquals(podcast2, pubs[1].pubKey)
}
@Test
fun `create with private favorites hides them from public tags`() =
runTest {
val event =
FavoritePodcastsListEvent.create(
privateFavorites = listOf(UserTag(podcast1)),
signer = signer,
createdAt = 1_700_000_000,
)
assertFalse(
event.tags.any { it.size >= 2 && it[0] == "p" && it[1] == podcast1 },
"Private favorite must not be exposed as a public p-tag",
)
assertTrue(event.content.isNotEmpty(), "Content must hold encrypted private tags")
val priv = assertNotNull(event.privateFavorites(signer))
assertEquals(1, priv.size)
assertEquals(podcast1, priv[0].pubKey)
}
@Test
fun `add public favorite appends to list and dedupes by pubkey`() =
runTest {
val first =
FavoritePodcastsListEvent.create(
publicFavorites = listOf(UserTag(podcast1)),
signer = signer,
createdAt = 1_700_000_000,
)
val second =
FavoritePodcastsListEvent.add(
earlierVersion = first,
podcast = UserTag(podcast2),
isPrivate = false,
signer = signer,
createdAt = 1_700_000_001,
)
assertEquals(
listOf(podcast1, podcast2),
second.publicFavorites().map { it.pubKey },
)
// Adding podcast1 again must not double it.
val third =
FavoritePodcastsListEvent.add(
earlierVersion = second,
podcast = UserTag(podcast1),
isPrivate = false,
signer = signer,
createdAt = 1_700_000_002,
)
assertEquals(
listOf(podcast2, podcast1),
third.publicFavorites().map { it.pubKey },
)
}
@Test
fun `remove drops the matching pubkey from public tags`() =
runTest {
val first =
FavoritePodcastsListEvent.create(
publicFavorites = listOf(UserTag(podcast1), UserTag(podcast2)),
signer = signer,
createdAt = 1_700_000_000,
)
val second =
FavoritePodcastsListEvent.remove(
earlierVersion = first,
podcast = UserTag(podcast1),
signer = signer,
createdAt = 1_700_000_001,
)
assertEquals(listOf(podcast2), second.publicFavorites().map { it.pubKey })
}
@Test
fun `alt tag is always present`() =
runTest {
val event =
FavoritePodcastsListEvent.create(
publicFavorites = listOf(UserTag(podcast1)),
signer = signer,
createdAt = 1_700_000_000,
)
assertTrue(event.tags.any { it[0] == "alt" && it[1] == FavoritePodcastsListEvent.ALT })
}
}
@@ -0,0 +1,116 @@
/*
* 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.nipF4Podcasts
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
import com.vitorpamplona.quartz.nipF4Podcasts.episode.tags.AudioTag
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 PodcastEpisodeEventTest {
private val signer =
DeterministicSigner(
"nsec10g0wheggqn9dawlc0yuv6adnat6n09anr7eyykevw2dm8xa5fffs0wsdsr".nsecToKeyPair(),
)
@Test
fun `kind is 54`() {
assertEquals(54, PodcastEpisodeEvent.KIND)
}
@Test
fun `build round-trips fields and content`() {
val markdown = "# Episode notes\n\nSome **bold** text."
val template =
PodcastEpisodeEvent.build(
title = "Ep 1",
description = "First episode",
audios = listOf(AudioTag("https://example.com/ep1.mp3", "audio/mpeg")),
markdownContent = markdown,
image = "https://example.com/ep1.png",
)
val event = signer.sign<PodcastEpisodeEvent>(template)
assertEquals("Ep 1", event.title())
assertEquals("First episode", event.description())
assertEquals("https://example.com/ep1.png", event.image())
assertEquals(markdown, event.content)
val audios = event.audios()
assertEquals(1, audios.size)
assertEquals("https://example.com/ep1.mp3", audios[0].url)
assertEquals("audio/mpeg", audios[0].mediaType)
}
@Test
fun `image is optional`() {
val template =
PodcastEpisodeEvent.build(
title = "Ep 2",
description = "No artwork",
audios = listOf(AudioTag("https://example.com/ep2.mp3")),
)
val event = signer.sign<PodcastEpisodeEvent>(template)
assertNull(event.image())
}
@Test
fun `multiple audio tags are preserved with optional media type`() {
val template =
PodcastEpisodeEvent.build(
title = "Ep 3",
description = "Multi-codec",
audios =
listOf(
AudioTag("https://example.com/ep3.mp3", "audio/mpeg"),
AudioTag("https://example.com/ep3.opus", "audio/opus"),
AudioTag("https://example.com/ep3.unknown"),
),
)
val event = signer.sign<PodcastEpisodeEvent>(template)
val audios = event.audios()
assertEquals(3, audios.size)
assertEquals("audio/mpeg", audios[0].mediaType)
assertEquals("audio/opus", audios[1].mediaType)
assertNull(audios[2].mediaType)
}
@Test
fun `alt tag includes title`() {
val template =
PodcastEpisodeEvent.build(
title = "Ep Title",
description = "x",
audios = listOf(AudioTag("https://example.com/x.mp3")),
)
val event = signer.sign<PodcastEpisodeEvent>(template)
val alt = event.tags.first { it[0] == "alt" }
assertTrue(alt[1].endsWith("Ep Title"))
assertTrue(alt[1].startsWith(PodcastEpisodeEvent.ALT_DESCRIPTION_PREFIX))
}
}
@@ -0,0 +1,136 @@
/*
* 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.nipF4Podcasts
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.tags.AuthorTag
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 PodcastMetadataEventTest {
private val signer =
DeterministicSigner(
"nsec10g0wheggqn9dawlc0yuv6adnat6n09anr7eyykevw2dm8xa5fffs0wsdsr".nsecToKeyPair(),
)
private val authorPubKey1 = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"
private val authorPubKey2 = "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245"
@Test
fun `kind is 10154`() {
assertEquals(10154, PodcastMetadataEvent.KIND)
}
@Test
fun `build round-trips required fields`() {
val template =
PodcastMetadataEvent.build(
title = "Show Title",
image = "https://example.com/cover.png",
description = "A show about things.",
)
val event = signer.sign<PodcastMetadataEvent>(template)
assertEquals("Show Title", event.title())
assertEquals("https://example.com/cover.png", event.image())
assertEquals("A show about things.", event.description())
assertTrue(event.websites().isEmpty())
assertTrue(event.claimedAuthors().isEmpty())
}
@Test
fun `build round-trips multiple websites and authors with roles`() {
val template =
PodcastMetadataEvent.build(
title = "Show",
image = "https://example.com/cover.png",
description = "Desc",
websites = listOf("https://show.example", "https://show.example/feed"),
authors =
listOf(
AuthorTag(authorPubKey1, AuthorTag.ROLE_HOST),
AuthorTag(authorPubKey2, AuthorTag.ROLE_COHOST),
),
)
val event = signer.sign<PodcastMetadataEvent>(template)
assertEquals(
listOf("https://show.example", "https://show.example/feed"),
event.websites(),
)
val authors = event.claimedAuthors()
assertEquals(2, authors.size)
assertEquals(authorPubKey1, authors[0].pubKey)
assertEquals(AuthorTag.ROLE_HOST, authors[0].role)
assertEquals(authorPubKey2, authors[1].pubKey)
assertEquals(AuthorTag.ROLE_COHOST, authors[1].role)
}
@Test
fun `author tag without role parses with null role`() {
val template =
PodcastMetadataEvent.build(
title = "Show",
image = "https://example.com/cover.png",
description = "Desc",
authors = listOf(AuthorTag(authorPubKey1, null)),
)
val event = signer.sign<PodcastMetadataEvent>(template)
val authors = event.claimedAuthors()
assertEquals(1, authors.size)
assertEquals(authorPubKey1, authors[0].pubKey)
assertNull(authors[0].role)
}
@Test
fun `alt tag is emitted`() {
val template =
PodcastMetadataEvent.build(
title = "Show",
image = "https://example.com/cover.png",
description = "Desc",
)
val event = signer.sign<PodcastMetadataEvent>(template)
assertTrue(event.tags.any { it[0] == "alt" && it[1] == PodcastMetadataEvent.ALT_DESCRIPTION })
}
@Test
fun `is replaceable with empty d-tag`() {
val template =
PodcastMetadataEvent.build(
title = "Show",
image = "https://example.com/cover.png",
description = "Desc",
)
val event = signer.sign<PodcastMetadataEvent>(template)
assertEquals("", event.dTag())
assertEquals(10154, event.address().kind)
assertEquals(event.pubKey, event.address().pubKeyHex)
}
}