From b47cdf5b96bf2ae3c8015772a63cad65d72a4ae9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 28 May 2026 20:10:39 +0000 Subject: [PATCH] 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. --- .../authored/AuthoredPodcastsEvent.kt | 80 +++++++++ .../authored/TagArrayBuilderExt.kt | 26 +++ .../episode/PodcastEpisodeEvent.kt | 82 +++++++++ .../episode/TagArrayBuilderExt.kt | 35 ++++ .../nipF4Podcasts/episode/tags/AudioTag.kt | 67 ++++++++ .../episode/tags/DescriptionTag.kt | 39 +++++ .../nipF4Podcasts/episode/tags/ImageTag.kt | 39 +++++ .../nipF4Podcasts/episode/tags/TitleTag.kt | 39 +++++ .../favorites/FavoritePodcastsListEvent.kt | 156 +++++++++++++++++ .../metadata/PodcastMetadataEvent.kt | 102 ++++++++++++ .../metadata/TagArrayBuilderExt.kt | 38 +++++ .../nipF4Podcasts/metadata/tags/AuthorTag.kt | 76 +++++++++ .../metadata/tags/DescriptionTag.kt | 39 +++++ .../nipF4Podcasts/metadata/tags/ImageTag.kt | 39 +++++ .../nipF4Podcasts/metadata/tags/TitleTag.kt | 39 +++++ .../nipF4Podcasts/metadata/tags/WebsiteTag.kt | 41 +++++ .../quartz/utils/EventFactory.kt | 8 + .../AuthoredPodcastsEventTest.kt | 79 +++++++++ .../FavoritePodcastsListEventTest.kt | 157 ++++++++++++++++++ .../nipF4Podcasts/PodcastEpisodeEventTest.kt | 116 +++++++++++++ .../nipF4Podcasts/PodcastMetadataEventTest.kt | 136 +++++++++++++++ 21 files changed, 1433 insertions(+) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/authored/AuthoredPodcastsEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/authored/TagArrayBuilderExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/PodcastEpisodeEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/TagArrayBuilderExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/tags/AudioTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/tags/DescriptionTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/tags/ImageTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/tags/TitleTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/favorites/FavoritePodcastsListEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/PodcastMetadataEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/TagArrayBuilderExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/tags/AuthorTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/tags/DescriptionTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/tags/ImageTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/tags/TitleTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/tags/WebsiteTag.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/AuthoredPodcastsEventTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/FavoritePodcastsListEventTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/PodcastEpisodeEventTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/PodcastMetadataEventTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/authored/AuthoredPodcastsEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/authored/AuthoredPodcastsEvent.kt new file mode 100644 index 0000000000..abf626b368 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/authored/AuthoredPodcastsEvent.kt @@ -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>, + 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, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + alt(ALT_DESCRIPTION) + podcasts.forEach { podcast(it) } + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/authored/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/authored/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..5c366ded4f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/authored/TagArrayBuilderExt.kt @@ -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.podcast(podcast: UserTag) = add(podcast.toTagArray()) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/PodcastEpisodeEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/PodcastEpisodeEvent.kt new file mode 100644 index 0000000000..5afb2fd4dd --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/PodcastEpisodeEvent.kt @@ -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>, + 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, + markdownContent: String = "", + image: String? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, markdownContent, createdAt) { + alt("$ALT_DESCRIPTION_PREFIX: $title") + + title(title) + description(description) + image?.let { image(it) } + audios.forEach { audio(it) } + + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..7d98b5560f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/TagArrayBuilderExt.kt @@ -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.title(title: String) = addUnique(TitleTag.assemble(title)) + +fun TagArrayBuilder.image(url: String) = addUnique(ImageTag.assemble(url)) + +fun TagArrayBuilder.description(description: String) = addUnique(DescriptionTag.assemble(description)) + +fun TagArrayBuilder.audio(audio: AudioTag) = add(AudioTag.assemble(audio)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/tags/AudioTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/tags/AudioTag.kt new file mode 100644 index 0000000000..d1aec68bde --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/tags/AudioTag.kt @@ -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", "", ""]`. + * + * 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): 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 = + 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) = audios.map { assemble(it) } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/tags/DescriptionTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/tags/DescriptionTag.kt new file mode 100644 index 0000000000..de1eb3154a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/tags/DescriptionTag.kt @@ -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? { + 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) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/tags/ImageTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/tags/ImageTag.kt new file mode 100644 index 0000000000..9057dbcbd1 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/tags/ImageTag.kt @@ -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? { + 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) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/tags/TitleTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/tags/TitleTag.kt new file mode 100644 index 0000000000..fcd3bca442 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/tags/TitleTag.kt @@ -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? { + 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) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/favorites/FavoritePodcastsListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/favorites/FavoritePodcastsListEvent.kt new file mode 100644 index 0000000000..409e1ba484 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/favorites/FavoritePodcastsListEvent.kt @@ -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>, + 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 = emptyList(), + privateFavorites: List = 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) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/PodcastMetadataEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/PodcastMetadataEvent.kt new file mode 100644 index 0000000000..44ad1fd43b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/PodcastMetadataEvent.kt @@ -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>, + 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 = emptyList(), + authors: List = emptyList(), + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + alt(ALT_DESCRIPTION) + + title(title) + image(image) + description(description) + + websites.forEach { website(it) } + authors.forEach { author(it) } + + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..5948af9138 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/TagArrayBuilderExt.kt @@ -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.title(title: String) = addUnique(TitleTag.assemble(title)) + +fun TagArrayBuilder.image(url: String) = addUnique(ImageTag.assemble(url)) + +fun TagArrayBuilder.description(description: String) = addUnique(DescriptionTag.assemble(description)) + +fun TagArrayBuilder.website(url: String) = add(WebsiteTag.assemble(url)) + +fun TagArrayBuilder.author(author: AuthorTag) = add(AuthorTag.assemble(author)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/tags/AuthorTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/tags/AuthorTag.kt new file mode 100644 index 0000000000..23deb816ce --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/tags/AuthorTag.kt @@ -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", "", ""]`. + * + * 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) = tag.has(1) && tag[0] == TAG_NAME && tag[1].length == 64 + + fun parse(tag: Array): 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 = + 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) = authors.map { assemble(it) } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/tags/DescriptionTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/tags/DescriptionTag.kt new file mode 100644 index 0000000000..41e7707c99 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/tags/DescriptionTag.kt @@ -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? { + 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) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/tags/ImageTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/tags/ImageTag.kt new file mode 100644 index 0000000000..d00c29ecc9 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/tags/ImageTag.kt @@ -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? { + 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) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/tags/TitleTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/tags/TitleTag.kt new file mode 100644 index 0000000000..32bfbd2817 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/tags/TitleTag.kt @@ -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? { + 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) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/tags/WebsiteTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/tags/WebsiteTag.kt new file mode 100644 index 0000000000..95f187f6f7 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/tags/WebsiteTag.kt @@ -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? { + 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) = urls.map { assemble(it) } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index 08ffa2caef..1042cf1483 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -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) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/AuthoredPodcastsEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/AuthoredPodcastsEventTest.kt new file mode 100644 index 0000000000..98cba1a688 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/AuthoredPodcastsEventTest.kt @@ -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(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(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(template) + + assertEquals(setOf(podcast1, podcast2), event.linkedPubKeys().toSet()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/FavoritePodcastsListEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/FavoritePodcastsListEventTest.kt new file mode 100644 index 0000000000..cf177d88ae --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/FavoritePodcastsListEventTest.kt @@ -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 }) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/PodcastEpisodeEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/PodcastEpisodeEventTest.kt new file mode 100644 index 0000000000..e841e19c7a --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/PodcastEpisodeEventTest.kt @@ -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(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(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(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(template) + + val alt = event.tags.first { it[0] == "alt" } + assertTrue(alt[1].endsWith("Ep Title")) + assertTrue(alt[1].startsWith(PodcastEpisodeEvent.ALT_DESCRIPTION_PREFIX)) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/PodcastMetadataEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/PodcastMetadataEventTest.kt new file mode 100644 index 0000000000..1d9bd5bf91 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/PodcastMetadataEventTest.kt @@ -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(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(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(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(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(template) + + assertEquals("", event.dTag()) + assertEquals(10154, event.address().kind) + assertEquals(event.pubKey, event.address().pubKeyHex) + } +}