mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-11 08:47:33 +00:00
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:
+80
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+26
@@ -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())
|
||||
+82
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+35
@@ -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))
|
||||
+67
@@ -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) }
|
||||
}
|
||||
}
|
||||
+39
@@ -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)
|
||||
}
|
||||
}
|
||||
+39
@@ -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)
|
||||
}
|
||||
}
|
||||
+39
@@ -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)
|
||||
}
|
||||
}
|
||||
+156
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+102
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+38
@@ -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))
|
||||
+76
@@ -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) }
|
||||
}
|
||||
}
|
||||
+39
@@ -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)
|
||||
}
|
||||
}
|
||||
+39
@@ -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)
|
||||
}
|
||||
}
|
||||
+39
@@ -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)
|
||||
}
|
||||
}
|
||||
+41
@@ -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)
|
||||
|
||||
+79
@@ -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())
|
||||
}
|
||||
}
|
||||
+157
@@ -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 })
|
||||
}
|
||||
}
|
||||
+116
@@ -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))
|
||||
}
|
||||
}
|
||||
+136
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user