mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 09:13:23 +00:00
feat(quartz): merge NIP-F4 and Podcasting-2.0 podcast kinds into one episode list
Amethyst's podcast support (NIP-F4) and derekross/podstr use incompatible identity models: NIP-F4 makes each podcast its own keypair with regular kind:54 episodes, while the Podcasting-2.0 draft signs editable, addressable kind:30054 episodes with the human creator's key. The two cannot share a wire kind, but a client can still render them in one list. Add the Podcasting-2.0 episode (kind:30054) and trailer (kind:30055) event classes plus their tags, and introduce a spec-neutral `PodcastEpisode` abstraction (with `PodcastAudio`) that both kind:54 and kind:30054 implement. Feeds and UI can now depend on the shared interface and surface both kind sets in a single, ordered podcast/episode list. NIP-F4 remains Amethyst's publish format; this only adds read/parse support for the Podcasting-2.0 kinds. Register both new kinds in EventFactory and KindNames. Covered by round-trip, spec-example-JSON, and a unified-list test proving both kinds flow through the shared abstraction. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
This commit is contained in:
@@ -300,6 +300,8 @@ import com.vitorpamplona.quartz.nipF4Podcasts.authored.AuthoredPodcastsEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.favorites.FavoritePodcastsListEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent
|
||||
|
||||
/**
|
||||
* Human-readable label and defining NIP for a Nostr event kind.
|
||||
@@ -337,6 +339,8 @@ object KindNames {
|
||||
PodcastMetadataEvent.KIND to KindName("Podcast Show", "F4"),
|
||||
AuthoredPodcastsEvent.KIND to KindName("Authored Podcasts", "F4"),
|
||||
FavoritePodcastsListEvent.KIND to KindName("Favorite Podcasts", "F4"),
|
||||
Podcasting20EpisodeEvent.KIND to KindName("Podcast Episode (Podcasting 2.0)", null),
|
||||
Podcasting20TrailerEvent.KIND to KindName("Podcast Trailer (Podcasting 2.0)", null),
|
||||
AttestationEvent.KIND to KindName("Attestation", null),
|
||||
AttestationRequestEvent.KIND to KindName("Attestation Request", null),
|
||||
AttestorRecommendationEvent.KIND to KindName("Attestor Recommendation", null),
|
||||
|
||||
+17
@@ -30,6 +30,8 @@ import com.vitorpamplona.quartz.nipF4Podcasts.episode.tags.AudioTag
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.episode.tags.DescriptionTag
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.episode.tags.ImageTag
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.episode.tags.TitleTag
|
||||
import com.vitorpamplona.quartz.podcasts.PodcastAudio
|
||||
import com.vitorpamplona.quartz.podcasts.PodcastEpisode
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/**
|
||||
@@ -48,6 +50,7 @@ class PodcastEpisodeEvent(
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : Event(id, pubKey, createdAt, KIND, tags, content, sig),
|
||||
PodcastEpisode,
|
||||
SearchableEvent {
|
||||
override fun indexableContent() = listOfNotNull(title(), description(), content).joinToString("\n")
|
||||
|
||||
@@ -59,6 +62,20 @@ class PodcastEpisodeEvent(
|
||||
|
||||
fun audios() = tags.mapNotNull(AudioTag::parse)
|
||||
|
||||
override fun episodeTitle() = title()
|
||||
|
||||
override fun episodeImage() = image()
|
||||
|
||||
override fun episodeDescription() = description()
|
||||
|
||||
override fun episodeAudio() = audios().map { PodcastAudio(it.url, it.mediaType) }
|
||||
|
||||
// NIP-F4 episodes carry no duration tag; clients derive it from the audio stream.
|
||||
override fun episodeDurationInSeconds(): Long? = null
|
||||
|
||||
// NIP-F4 episodes are regular events ordered by their publication time.
|
||||
override fun episodePublishedAt() = createdAt
|
||||
|
||||
companion object {
|
||||
const val KIND = 54
|
||||
|
||||
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nipXXPodcasting20.episode
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
|
||||
import com.vitorpamplona.quartz.nip31Alts.AltTag
|
||||
import com.vitorpamplona.quartz.nip31Alts.alt
|
||||
import com.vitorpamplona.quartz.nip50Search.SearchableEvent
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.AudioTag
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.DescriptionTag
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.DurationTag
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.EditTag
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.ImageTag
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.PubDateTag
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.TitleTag
|
||||
import com.vitorpamplona.quartz.podcasts.PodcastAudio
|
||||
import com.vitorpamplona.quartz.podcasts.PodcastEpisode
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/**
|
||||
* Podcasting-2.0 draft podcast episode (`kind:30054`), as published by clients like
|
||||
* derekross/podstr. Unlike NIP-F4 (where the podcast is its own keypair and episodes
|
||||
* are regular `kind:54` events), here the **human creator** is the keypair and each
|
||||
* episode is an *addressable* event keyed by its `d` tag, so it can be edited in place.
|
||||
*
|
||||
* Implements the spec-neutral [PodcastEpisode] so it lands in the same merged
|
||||
* episode list as NIP-F4 episodes.
|
||||
*/
|
||||
@Immutable
|
||||
class Podcasting20EpisodeEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig),
|
||||
PodcastEpisode,
|
||||
SearchableEvent {
|
||||
override fun indexableContent() = listOfNotNull(title(), description(), content).joinToString("\n")
|
||||
|
||||
fun title() = tags.firstNotNullOfOrNull(TitleTag::parse)
|
||||
|
||||
fun image() = tags.firstNotNullOfOrNull(ImageTag::parse)
|
||||
|
||||
fun description() = tags.firstNotNullOfOrNull(DescriptionTag::parse)
|
||||
|
||||
fun audios() = tags.mapNotNull(AudioTag::parse)
|
||||
|
||||
fun durationInSeconds() = tags.firstNotNullOfOrNull(DurationTag::parse)
|
||||
|
||||
/** RFC2822 publication date string, kept verbatim for RSS generation. */
|
||||
fun pubDate() = tags.firstNotNullOfOrNull(PubDateTag::parse)
|
||||
|
||||
fun alt() = tags.firstNotNullOfOrNull(AltTag::parse)
|
||||
|
||||
fun topics() = hashtags()
|
||||
|
||||
/** Event id of the original publication when this is an edit, if present. */
|
||||
fun editsEventId() = tags.firstNotNullOfOrNull(EditTag::parse)
|
||||
|
||||
override fun episodeTitle() = title()
|
||||
|
||||
override fun episodeImage() = image()
|
||||
|
||||
override fun episodeDescription() = description()
|
||||
|
||||
override fun episodeAudio() = audios()
|
||||
|
||||
override fun episodeDurationInSeconds() = durationInSeconds()
|
||||
|
||||
override fun episodePublishedAt() = createdAt
|
||||
|
||||
companion object {
|
||||
const val KIND = 30054
|
||||
|
||||
fun build(
|
||||
dTag: String,
|
||||
title: String,
|
||||
audios: List<PodcastAudio>,
|
||||
pubdate: String,
|
||||
alt: String = "Podcast episode: $title",
|
||||
description: String? = null,
|
||||
image: String? = null,
|
||||
durationInSeconds: Long? = null,
|
||||
topics: List<String> = emptyList(),
|
||||
markdownContent: String = "",
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<Podcasting20EpisodeEvent>.() -> Unit = {},
|
||||
) = eventTemplate<Podcasting20EpisodeEvent>(KIND, markdownContent, createdAt) {
|
||||
dTag(dTag)
|
||||
title(title)
|
||||
audios.forEach { audio(it) }
|
||||
pubdate(pubdate)
|
||||
alt(alt)
|
||||
|
||||
description?.let { description(it) }
|
||||
image?.let { image(it) }
|
||||
durationInSeconds?.let { duration(it) }
|
||||
if (topics.isNotEmpty()) hashtags(topics)
|
||||
|
||||
initializer()
|
||||
}
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nipXXPodcasting20.episode
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.AudioTag
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.DescriptionTag
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.DurationTag
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.EditTag
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.ImageTag
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.PubDateTag
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.TitleTag
|
||||
import com.vitorpamplona.quartz.podcasts.PodcastAudio
|
||||
|
||||
fun TagArrayBuilder<Podcasting20EpisodeEvent>.title(title: String) = addUnique(TitleTag.assemble(title))
|
||||
|
||||
fun TagArrayBuilder<Podcasting20EpisodeEvent>.description(description: String) = addUnique(DescriptionTag.assemble(description))
|
||||
|
||||
fun TagArrayBuilder<Podcasting20EpisodeEvent>.image(url: String) = addUnique(ImageTag.assemble(url))
|
||||
|
||||
fun TagArrayBuilder<Podcasting20EpisodeEvent>.audio(audio: PodcastAudio) = add(AudioTag.assemble(audio))
|
||||
|
||||
fun TagArrayBuilder<Podcasting20EpisodeEvent>.pubdate(rfc2822Date: String) = addUnique(PubDateTag.assemble(rfc2822Date))
|
||||
|
||||
fun TagArrayBuilder<Podcasting20EpisodeEvent>.duration(durationInSeconds: Long) = addUnique(DurationTag.assemble(durationInSeconds))
|
||||
|
||||
fun TagArrayBuilder<Podcasting20EpisodeEvent>.edit(originalEventId: HexKey) = addUnique(EditTag.assemble(originalEventId))
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.podcasts.PodcastAudio
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/**
|
||||
* Podcasting-2.0 episode audio tag: `["audio", "<url>", "<optional_media_type>"]`.
|
||||
*
|
||||
* Wire-identical to the NIP-F4 audio tag, so it parses straight into the shared
|
||||
* [PodcastAudio] holder that the unified [com.vitorpamplona.quartz.podcasts.PodcastEpisode]
|
||||
* abstraction exposes.
|
||||
*/
|
||||
class AudioTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "audio"
|
||||
|
||||
fun parse(tag: Array<String>): PodcastAudio? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(tag[1].isNotEmpty()) { return null }
|
||||
val mediaType = tag.getOrNull(2)?.takeIf { it.isNotEmpty() }
|
||||
return PodcastAudio(tag[1], mediaType)
|
||||
}
|
||||
|
||||
fun assemble(
|
||||
url: String,
|
||||
mediaType: String? = null,
|
||||
): Array<String> =
|
||||
if (mediaType.isNullOrEmpty()) {
|
||||
arrayOf(TAG_NAME, url)
|
||||
} else {
|
||||
arrayOf(TAG_NAME, url, mediaType)
|
||||
}
|
||||
|
||||
fun assemble(audio: PodcastAudio) = assemble(audio.url, audio.mediaType)
|
||||
}
|
||||
}
|
||||
+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.nipXXPodcasting20.episode.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
class DescriptionTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "description"
|
||||
|
||||
fun parse(tag: Array<String>): String? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(tag[1].isNotEmpty()) { return null }
|
||||
return tag[1]
|
||||
}
|
||||
|
||||
fun assemble(description: String) = arrayOf(TAG_NAME, description)
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/** Podcasting-2.0 episode duration in whole seconds: `["duration", "3600"]`. */
|
||||
class DurationTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "duration"
|
||||
|
||||
fun parse(tag: Array<String>): Long? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(tag[1].isNotEmpty()) { return null }
|
||||
return tag[1].toLongOrNull()
|
||||
}
|
||||
|
||||
fun assemble(durationInSeconds: Long) = arrayOf(TAG_NAME, durationInSeconds.toString())
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/**
|
||||
* Podcasting-2.0 edit-history pointer: `["edit", "<original-event-id>"]`. References
|
||||
* the event id of the original publication when an addressable episode/trailer is
|
||||
* updated, so clients can reconstruct edit history.
|
||||
*/
|
||||
class EditTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "edit"
|
||||
|
||||
fun parse(tag: Array<String>): HexKey? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(tag[1].isNotEmpty()) { return null }
|
||||
return tag[1]
|
||||
}
|
||||
|
||||
fun assemble(originalEventId: HexKey) = arrayOf(TAG_NAME, originalEventId)
|
||||
}
|
||||
}
|
||||
+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.nipXXPodcasting20.episode.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
class ImageTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "image"
|
||||
|
||||
fun parse(tag: Array<String>): String? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(tag[1].isNotEmpty()) { return null }
|
||||
return tag[1]
|
||||
}
|
||||
|
||||
fun assemble(url: String) = arrayOf(TAG_NAME, url)
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/**
|
||||
* Podcasting-2.0 publication date in RFC2822 format, set once when first published
|
||||
* and preserved across edits: `["pubdate", "Thu, 04 Nov 2023 12:00:00 GMT"]`.
|
||||
*
|
||||
* The string is kept verbatim — it feeds RSS generation, where the exact RFC2822
|
||||
* spelling matters. Feed ordering relies on the event's `created_at` instead, so
|
||||
* no date parsing is required here.
|
||||
*/
|
||||
class PubDateTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "pubdate"
|
||||
|
||||
fun parse(tag: Array<String>): String? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(tag[1].isNotEmpty()) { return null }
|
||||
return tag[1]
|
||||
}
|
||||
|
||||
fun assemble(rfc2822Date: String) = arrayOf(TAG_NAME, rfc2822Date)
|
||||
}
|
||||
}
|
||||
+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.nipXXPodcasting20.episode.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
class TitleTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "title"
|
||||
|
||||
fun parse(tag: Array<String>): String? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(tag[1].isNotEmpty()) { return null }
|
||||
return tag[1]
|
||||
}
|
||||
|
||||
fun assemble(title: String) = arrayOf(TAG_NAME, title)
|
||||
}
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nipXXPodcasting20.trailer
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
|
||||
import com.vitorpamplona.quartz.nip31Alts.AltTag
|
||||
import com.vitorpamplona.quartz.nip31Alts.alt
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.PubDateTag
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.TitleTag
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.tags.LengthTag
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.tags.SeasonTag
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.tags.TypeTag
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.tags.UrlTag
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/**
|
||||
* Podcasting-2.0 draft podcast trailer (`kind:30055`), following the Podcast 2.0
|
||||
* `<podcast:trailer>` element. Addressable like the episode (`kind:30054`) and
|
||||
* signed by the human creator's keypair, keyed by its `d` tag.
|
||||
*/
|
||||
@Immutable
|
||||
class Podcasting20TrailerEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
fun title() = tags.firstNotNullOfOrNull(TitleTag::parse)
|
||||
|
||||
fun url() = tags.firstNotNullOfOrNull(UrlTag::parse)
|
||||
|
||||
/** RFC2822 publication date string, kept verbatim for RSS generation. */
|
||||
fun pubDate() = tags.firstNotNullOfOrNull(PubDateTag::parse)
|
||||
|
||||
fun lengthInBytes() = tags.firstNotNullOfOrNull(LengthTag::parse)
|
||||
|
||||
fun mimeType() = tags.firstNotNullOfOrNull(TypeTag::parse)
|
||||
|
||||
fun season() = tags.firstNotNullOfOrNull(SeasonTag::parse)
|
||||
|
||||
fun alt() = tags.firstNotNullOfOrNull(AltTag::parse)
|
||||
|
||||
companion object {
|
||||
const val KIND = 30055
|
||||
|
||||
fun build(
|
||||
dTag: String,
|
||||
title: String,
|
||||
url: String,
|
||||
pubdate: String,
|
||||
alt: String = "Podcast trailer: $title",
|
||||
lengthInBytes: Long? = null,
|
||||
mimeType: String? = null,
|
||||
season: Int? = null,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<Podcasting20TrailerEvent>.() -> Unit = {},
|
||||
) = eventTemplate<Podcasting20TrailerEvent>(KIND, title, createdAt) {
|
||||
dTag(dTag)
|
||||
title(title)
|
||||
url(url)
|
||||
pubdate(pubdate)
|
||||
alt(alt)
|
||||
|
||||
lengthInBytes?.let { length(it) }
|
||||
mimeType?.let { type(it) }
|
||||
season?.let { season(it) }
|
||||
|
||||
initializer()
|
||||
}
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nipXXPodcasting20.trailer
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.EditTag
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.PubDateTag
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.TitleTag
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.tags.LengthTag
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.tags.SeasonTag
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.tags.TypeTag
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.tags.UrlTag
|
||||
|
||||
fun TagArrayBuilder<Podcasting20TrailerEvent>.title(title: String) = addUnique(TitleTag.assemble(title))
|
||||
|
||||
fun TagArrayBuilder<Podcasting20TrailerEvent>.url(url: String) = addUnique(UrlTag.assemble(url))
|
||||
|
||||
fun TagArrayBuilder<Podcasting20TrailerEvent>.pubdate(rfc2822Date: String) = addUnique(PubDateTag.assemble(rfc2822Date))
|
||||
|
||||
fun TagArrayBuilder<Podcasting20TrailerEvent>.length(lengthInBytes: Long) = addUnique(LengthTag.assemble(lengthInBytes))
|
||||
|
||||
fun TagArrayBuilder<Podcasting20TrailerEvent>.type(mimeType: String) = addUnique(TypeTag.assemble(mimeType))
|
||||
|
||||
fun TagArrayBuilder<Podcasting20TrailerEvent>.season(season: Int) = addUnique(SeasonTag.assemble(season))
|
||||
|
||||
fun TagArrayBuilder<Podcasting20TrailerEvent>.edit(originalEventId: HexKey) = addUnique(EditTag.assemble(originalEventId))
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nipXXPodcasting20.trailer.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/** Podcasting-2.0 trailer file size in bytes: `["length", "1024000"]`. */
|
||||
class LengthTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "length"
|
||||
|
||||
fun parse(tag: Array<String>): Long? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(tag[1].isNotEmpty()) { return null }
|
||||
return tag[1].toLongOrNull()
|
||||
}
|
||||
|
||||
fun assemble(lengthInBytes: Long) = arrayOf(TAG_NAME, lengthInBytes.toString())
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nipXXPodcasting20.trailer.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/** Podcasting-2.0 season number a trailer represents: `["season", "2"]`. */
|
||||
class SeasonTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "season"
|
||||
|
||||
fun parse(tag: Array<String>): Int? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(tag[1].isNotEmpty()) { return null }
|
||||
return tag[1].toIntOrNull()
|
||||
}
|
||||
|
||||
fun assemble(season: Int) = arrayOf(TAG_NAME, season.toString())
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nipXXPodcasting20.trailer.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/** Podcasting-2.0 trailer MIME type: `["type", "audio/mpeg"]`. */
|
||||
class TypeTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "type"
|
||||
|
||||
fun parse(tag: Array<String>): String? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(tag[1].isNotEmpty()) { return null }
|
||||
return tag[1]
|
||||
}
|
||||
|
||||
fun assemble(mimeType: String) = arrayOf(TAG_NAME, mimeType)
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nipXXPodcasting20.trailer.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/** Podcasting-2.0 trailer media URL: `["url", "<media-url>"]`. */
|
||||
class UrlTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "url"
|
||||
|
||||
fun parse(tag: Array<String>): String? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(tag[1].isNotEmpty()) { return null }
|
||||
return tag[1]
|
||||
}
|
||||
|
||||
fun assemble(url: String) = arrayOf(TAG_NAME, url)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.podcasts
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
|
||||
/**
|
||||
* Spec-neutral audio reference for a podcast episode, used by the shared
|
||||
* [PodcastEpisode] abstraction so a UI can play episodes regardless of which
|
||||
* podcast NIP produced them.
|
||||
*
|
||||
* Both NIP-F4 (`kind:54`) and the Podcasting-2.0 draft (`kind:30054`) carry audio
|
||||
* in identical `["audio", "<url>", "<optional_media_type>"]` tags; each event maps
|
||||
* its own tag class into this holder.
|
||||
*/
|
||||
@Immutable
|
||||
class PodcastAudio(
|
||||
val url: String,
|
||||
val mediaType: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.podcasts
|
||||
|
||||
/**
|
||||
* Spec-neutral view of a single podcast episode.
|
||||
*
|
||||
* Two competing podcast drafts publish episodes with incompatible identity models
|
||||
* and event kinds:
|
||||
* - **NIP-F4** ([com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent],
|
||||
* `kind:54`): a regular event where the *podcast itself* is a Nostr keypair.
|
||||
* - **Podcasting-2.0 draft** ([com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent],
|
||||
* `kind:30054`): an addressable event where the *human creator* is the keypair
|
||||
* and episodes are editable via their `d` tag.
|
||||
*
|
||||
* The two cannot share a single wire kind, but a client can still render them in
|
||||
* one list. This interface is that merge point: feeds and UI depend on it instead
|
||||
* of a concrete event, so both kinds flow into the same podcast/episode list.
|
||||
*/
|
||||
interface PodcastEpisode {
|
||||
/** The episode title shown in listings. */
|
||||
fun episodeTitle(): String?
|
||||
|
||||
/** Cover/episode artwork URL, if any. */
|
||||
fun episodeImage(): String?
|
||||
|
||||
/** Short, plain-text episode description/summary, if any. */
|
||||
fun episodeDescription(): String?
|
||||
|
||||
/**
|
||||
* One or more audio sources for the episode. Multiple entries typically offer
|
||||
* the same audio in different containers/codecs (e.g. mp3 + opus).
|
||||
*/
|
||||
fun episodeAudio(): List<PodcastAudio>
|
||||
|
||||
/** Episode duration in seconds, if the publisher provided it. */
|
||||
fun episodeDurationInSeconds(): Long?
|
||||
|
||||
/**
|
||||
* Unix timestamp (seconds) used to order episodes in a merged feed. Both drafts
|
||||
* fall back to the event's `created_at`; the Podcasting-2.0 draft additionally
|
||||
* carries an RFC2822 `pubdate` tag exposed by its own event class.
|
||||
*/
|
||||
fun episodePublishedAt(): Long
|
||||
}
|
||||
@@ -309,6 +309,8 @@ import com.vitorpamplona.quartz.nipF4Podcasts.authored.AuthoredPodcastsEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.favorites.FavoritePodcastsListEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent
|
||||
|
||||
interface EventBuilder {
|
||||
fun build(
|
||||
@@ -566,6 +568,8 @@ class EventFactory {
|
||||
PodcastEpisodeEvent.KIND -> PodcastEpisodeEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
AuthoredPodcastsEvent.KIND -> AuthoredPodcastsEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
FavoritePodcastsListEvent.KIND -> FavoritePodcastsListEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
Podcasting20EpisodeEvent.KIND -> Podcasting20EpisodeEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
Podcasting20TrailerEvent.KIND -> Podcasting20TrailerEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
ProductEvent.KIND -> ProductEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
PrivateDmEvent.KIND -> PrivateDmEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
PrivateOutboxRelayListEvent.KIND -> PrivateOutboxRelayListEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nipXXPodcasting20
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.edit
|
||||
import com.vitorpamplona.quartz.podcasts.PodcastAudio
|
||||
import com.vitorpamplona.quartz.utils.DeterministicSigner
|
||||
import com.vitorpamplona.quartz.utils.nsecToKeyPair
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class Podcasting20EpisodeEventTest {
|
||||
private val signer =
|
||||
DeterministicSigner(
|
||||
"nsec10g0wheggqn9dawlc0yuv6adnat6n09anr7eyykevw2dm8xa5fffs0wsdsr".nsecToKeyPair(),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `kind is 30054`() {
|
||||
assertEquals(30054, Podcasting20EpisodeEvent.KIND)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build round-trips fields and is addressable by d tag`() {
|
||||
val template =
|
||||
Podcasting20EpisodeEvent.build(
|
||||
dTag = "episode-1699123456-abc123def",
|
||||
title = "The Future of Decentralized Social Media",
|
||||
audios = listOf(PodcastAudio("https://example.com/episodes/episode-001.mp3", "audio/mpeg")),
|
||||
pubdate = "Thu, 04 Nov 2023 12:00:00 GMT",
|
||||
description = "A deep dive into how protocols like Nostr are changing social media",
|
||||
image = "https://example.com/artwork/episode-001.jpg",
|
||||
durationInSeconds = 3600,
|
||||
topics = listOf("technology", "decentralization"),
|
||||
markdownContent = "In this episode, we discuss decentralized social media.",
|
||||
)
|
||||
val event = signer.sign<Podcasting20EpisodeEvent>(template)
|
||||
|
||||
assertEquals("episode-1699123456-abc123def", event.dTag())
|
||||
assertEquals("The Future of Decentralized Social Media", event.title())
|
||||
assertEquals("A deep dive into how protocols like Nostr are changing social media", event.description())
|
||||
assertEquals("https://example.com/artwork/episode-001.jpg", event.image())
|
||||
assertEquals(3600L, event.durationInSeconds())
|
||||
assertEquals("Thu, 04 Nov 2023 12:00:00 GMT", event.pubDate())
|
||||
assertEquals("In this episode, we discuss decentralized social media.", event.content)
|
||||
|
||||
val audios = event.audios()
|
||||
assertEquals(1, audios.size)
|
||||
assertEquals("https://example.com/episodes/episode-001.mp3", audios[0].url)
|
||||
assertEquals("audio/mpeg", audios[0].mediaType)
|
||||
|
||||
assertTrue(event.topics().containsAll(listOf("technology", "decentralization")))
|
||||
assertEquals("Podcast episode: The Future of Decentralized Social Media", event.alt())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `optional fields default to null`() {
|
||||
val template =
|
||||
Podcasting20EpisodeEvent.build(
|
||||
dTag = "ep-2",
|
||||
title = "No extras",
|
||||
audios = listOf(PodcastAudio("https://example.com/ep2.mp3")),
|
||||
pubdate = "Thu, 04 Nov 2023 12:00:00 GMT",
|
||||
)
|
||||
val event = signer.sign<Podcasting20EpisodeEvent>(template)
|
||||
|
||||
assertNull(event.image())
|
||||
assertNull(event.description())
|
||||
assertNull(event.durationInSeconds())
|
||||
assertNull(event.editsEventId())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `edit tag tracks the original event id`() {
|
||||
val template =
|
||||
Podcasting20EpisodeEvent.build(
|
||||
dTag = "ep-3",
|
||||
title = "Corrected",
|
||||
audios = listOf(PodcastAudio("https://example.com/ep3.mp3")),
|
||||
pubdate = "Thu, 04 Nov 2023 12:00:00 GMT",
|
||||
) {
|
||||
edit("abababababababababababababababababababababababababababababababab")
|
||||
}
|
||||
val event = signer.sign<Podcasting20EpisodeEvent>(template)
|
||||
|
||||
assertEquals("abababababababababababababababababababababababababababababababab", event.editsEventId())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parses the spec example json into the typed event via EventFactory`() {
|
||||
// Verbatim example from derekross/podstr NIP.md (id and sig elided are not
|
||||
// required for parsing tags; we supply structurally valid placeholders).
|
||||
val json =
|
||||
"""
|
||||
{
|
||||
"kind": 30054,
|
||||
"content": "In this episode, we discuss the latest developments in decentralized social media protocols.",
|
||||
"tags": [
|
||||
["d", "episode-1699123456-abc123def"],
|
||||
["title", "The Future of Decentralized Social Media"],
|
||||
["audio", "https://example.com/episodes/episode-001.mp3", "audio/mpeg"],
|
||||
["pubdate", "Thu, 04 Nov 2023 12:00:00 GMT"],
|
||||
["alt", "Podcast episode: The Future of Decentralized Social Media"],
|
||||
["description", "A deep dive into how protocols like Nostr are changing social media"],
|
||||
["image", "https://example.com/artwork/episode-001.jpg"],
|
||||
["duration", "3600"],
|
||||
["t", "technology"],
|
||||
["t", "decentralization"],
|
||||
["t", "social-media"]
|
||||
],
|
||||
"created_at": 1699123456,
|
||||
"pubkey": "0000000000000000000000000000000000000000000000000000000000000001",
|
||||
"id": "0000000000000000000000000000000000000000000000000000000000000002",
|
||||
"sig": "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val event = Event.fromJson(json)
|
||||
assertTrue(event is Podcasting20EpisodeEvent)
|
||||
assertEquals("The Future of Decentralized Social Media", event.title())
|
||||
assertEquals(3600L, event.durationInSeconds())
|
||||
assertEquals(3, event.topics().size)
|
||||
assertEquals("https://example.com/episodes/episode-001.mp3", event.audios()[0].url)
|
||||
}
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nipXXPodcasting20
|
||||
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent
|
||||
import com.vitorpamplona.quartz.utils.DeterministicSigner
|
||||
import com.vitorpamplona.quartz.utils.nsecToKeyPair
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class Podcasting20TrailerEventTest {
|
||||
private val signer =
|
||||
DeterministicSigner(
|
||||
"nsec10g0wheggqn9dawlc0yuv6adnat6n09anr7eyykevw2dm8xa5fffs0wsdsr".nsecToKeyPair(),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `kind is 30055`() {
|
||||
assertEquals(30055, Podcasting20TrailerEvent.KIND)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build round-trips fields`() {
|
||||
val template =
|
||||
Podcasting20TrailerEvent.build(
|
||||
dTag = "trailer-1699123456-xyz789abc",
|
||||
title = "Season 2 Preview",
|
||||
url = "https://example.com/trailers/season-2-preview.mp3",
|
||||
pubdate = "Thu, 04 Nov 2023 12:00:00 GMT",
|
||||
lengthInBytes = 1024000,
|
||||
mimeType = "audio/mpeg",
|
||||
season = 2,
|
||||
)
|
||||
val event = signer.sign<Podcasting20TrailerEvent>(template)
|
||||
|
||||
assertEquals("trailer-1699123456-xyz789abc", event.dTag())
|
||||
assertEquals("Season 2 Preview", event.title())
|
||||
assertEquals("https://example.com/trailers/season-2-preview.mp3", event.url())
|
||||
assertEquals("Thu, 04 Nov 2023 12:00:00 GMT", event.pubDate())
|
||||
assertEquals(1024000L, event.lengthInBytes())
|
||||
assertEquals("audio/mpeg", event.mimeType())
|
||||
assertEquals(2, event.season())
|
||||
// Per spec the content SHOULD carry the trailer title.
|
||||
assertEquals("Season 2 Preview", event.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `optional fields default to null`() {
|
||||
val template =
|
||||
Podcasting20TrailerEvent.build(
|
||||
dTag = "trailer-min",
|
||||
title = "Teaser",
|
||||
url = "https://example.com/teaser.mp3",
|
||||
pubdate = "Thu, 04 Nov 2023 12:00:00 GMT",
|
||||
)
|
||||
val event = signer.sign<Podcasting20TrailerEvent>(template)
|
||||
|
||||
assertNull(event.lengthInBytes())
|
||||
assertNull(event.mimeType())
|
||||
assertNull(event.season())
|
||||
}
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.podcasts
|
||||
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.episode.tags.AudioTag
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent
|
||||
import com.vitorpamplona.quartz.utils.DeterministicSigner
|
||||
import com.vitorpamplona.quartz.utils.nsecToKeyPair
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
|
||||
/**
|
||||
* Proves the merge: a NIP-F4 episode (`kind:54`) and a Podcasting-2.0 episode
|
||||
* (`kind:30054`) — built on different identity models and event kinds — flow into
|
||||
* a single `List<PodcastEpisode>` and expose the same fields through the shared
|
||||
* abstraction.
|
||||
*/
|
||||
class UnifiedPodcastEpisodeTest {
|
||||
private val signer =
|
||||
DeterministicSigner(
|
||||
"nsec10g0wheggqn9dawlc0yuv6adnat6n09anr7eyykevw2dm8xa5fffs0wsdsr".nsecToKeyPair(),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `both kinds satisfy the shared PodcastEpisode abstraction`() {
|
||||
val f4 =
|
||||
signer.sign<PodcastEpisodeEvent>(
|
||||
PodcastEpisodeEvent.build(
|
||||
title = "F4 Episode",
|
||||
description = "Published under NIP-F4",
|
||||
audios = listOf(AudioTag("https://example.com/f4.mp3", "audio/mpeg")),
|
||||
image = "https://example.com/f4.png",
|
||||
createdAt = 1000,
|
||||
),
|
||||
)
|
||||
|
||||
val pc20 =
|
||||
signer.sign<Podcasting20EpisodeEvent>(
|
||||
Podcasting20EpisodeEvent.build(
|
||||
dTag = "ep-1",
|
||||
title = "Podcasting 2.0 Episode",
|
||||
audios = listOf(PodcastAudio("https://example.com/pc20.mp3", "audio/mpeg")),
|
||||
pubdate = "Thu, 04 Nov 2023 12:00:00 GMT",
|
||||
description = "Published under kind 30054",
|
||||
image = "https://example.com/pc20.png",
|
||||
durationInSeconds = 1800,
|
||||
createdAt = 2000,
|
||||
),
|
||||
)
|
||||
|
||||
// The whole point: one list, mixed kinds, ordered newest-first.
|
||||
val unified: List<PodcastEpisode> = listOf(f4, pc20).sortedByDescending { it.episodePublishedAt() }
|
||||
|
||||
assertEquals(listOf("Podcasting 2.0 Episode", "F4 Episode"), unified.map { it.episodeTitle() })
|
||||
|
||||
unified.forEach { episode ->
|
||||
assertEquals("audio/mpeg", episode.episodeAudio().single().mediaType)
|
||||
}
|
||||
|
||||
// F4 has no duration tag; the Podcasting-2.0 episode carries one.
|
||||
val byTitle = unified.associateBy { it.episodeTitle() }
|
||||
assertNull(byTitle["F4 Episode"]!!.episodeDurationInSeconds())
|
||||
assertEquals(1800L, byTitle["Podcasting 2.0 Episode"]!!.episodeDurationInSeconds())
|
||||
|
||||
assertEquals(1000L, byTitle["F4 Episode"]!!.episodePublishedAt())
|
||||
assertEquals(2000L, byTitle["Podcasting 2.0 Episode"]!!.episodePublishedAt())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user