use thiserror::Error;
use crate::event::{
Alphabet, Coordinate, Event, EventBuilder, Kind, SingleLetterTag, Tag, TagKind, Tags,
};
use crate::key::{PublicKey, PublicKeyError};
use crate::nips::nip92::{IMETA_TAG, MediaAttachment, MediaAttachmentError};
use crate::types::{RelayUrl, RelayUrlError, Timestamp, TimestampError, Url, UrlError};
pub const KIND_VIDEO_NORMAL: Kind = Kind::VIDEO_NORMAL;
pub const KIND_VIDEO_SHORT: Kind = Kind::VIDEO_SHORT;
pub const KIND_VIDEO_NORMAL_ADDRESSABLE: Kind = Kind::VIDEO_NORMAL_ADDRESSABLE;
pub const KIND_VIDEO_SHORT_ADDRESSABLE: Kind = Kind::VIDEO_SHORT_ADDRESSABLE;
const TITLE_TAG: &str = "title";
const PUBLISHED_AT_TAG: &str = "published_at";
const ALT_TAG: &str = "alt";
const CONTENT_WARNING_TAG: &str = "content-warning";
const DURATION_TAG: &str = "duration";
const TEXT_TRACK_TAG: &str = "text-track";
const SEGMENT_TAG: &str = "segment";
const ORIGIN_TAG: &str = "origin";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum VideoKind {
Normal,
Short,
NormalAddressable,
ShortAddressable,
}
impl VideoKind {
#[must_use]
pub const fn to_kind(self) -> Kind {
match self {
Self::Normal => KIND_VIDEO_NORMAL,
Self::Short => KIND_VIDEO_SHORT,
Self::NormalAddressable => KIND_VIDEO_NORMAL_ADDRESSABLE,
Self::ShortAddressable => KIND_VIDEO_SHORT_ADDRESSABLE,
}
}
#[must_use]
pub const fn from_kind(kind: Kind) -> Option<Self> {
match kind.as_u16() {
21 => Some(Self::Normal),
22 => Some(Self::Short),
34_235 => Some(Self::NormalAddressable),
34_236 => Some(Self::ShortAddressable),
_ => None,
}
}
#[must_use]
pub const fn is_addressable(self) -> bool {
matches!(self, Self::NormalAddressable | Self::ShortAddressable)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TextTrack {
pub value: String,
pub relay_hint: Option<RelayUrl>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Segment {
pub start: String,
pub end: String,
pub title: String,
pub thumbnail: Option<Url>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VideoParticipant {
pub pubkey: PublicKey,
pub relay_hint: Option<RelayUrl>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VideoOrigin {
pub platform: String,
pub external_id: String,
pub original_url: Option<Url>,
pub metadata: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Video {
pub kind: VideoKind,
pub identifier: Option<String>,
pub content: String,
pub title: String,
pub media: Vec<MediaAttachment>,
pub published_at: Option<Timestamp>,
pub alt: Option<String>,
pub content_warning: Option<String>,
pub duration_seconds: Option<f64>,
pub text_tracks: Vec<TextTrack>,
pub segments: Vec<Segment>,
pub hashtags: Vec<String>,
pub participants: Vec<VideoParticipant>,
pub references: Vec<Url>,
pub origin: Option<VideoOrigin>,
pub extra_tags: Vec<Tag>,
}
impl Video {
#[must_use]
pub fn new(kind: VideoKind, title: impl Into<String>, media: MediaAttachment) -> Self {
Self {
kind,
identifier: if kind.is_addressable() {
Some(String::new())
} else {
None
},
content: String::new(),
title: title.into(),
media: vec![media],
published_at: None,
alt: None,
content_warning: None,
duration_seconds: None,
text_tracks: Vec::new(),
segments: Vec::new(),
hashtags: Vec::new(),
participants: Vec::new(),
references: Vec::new(),
origin: None,
extra_tags: Vec::new(),
}
}
#[must_use]
pub fn identifier(mut self, identifier: impl Into<String>) -> Self {
self.identifier = Some(identifier.into());
self
}
#[must_use]
pub fn content(mut self, content: impl Into<String>) -> Self {
self.content = content.into();
self
}
#[must_use]
pub fn media(mut self, media: MediaAttachment) -> Self {
self.media.push(media);
self
}
#[must_use]
pub const fn published_at(mut self, ts: Timestamp) -> Self {
self.published_at = Some(ts);
self
}
#[must_use]
pub fn alt(mut self, alt: impl Into<String>) -> Self {
self.alt = Some(alt.into());
self
}
#[must_use]
pub fn content_warning(mut self, warning: impl Into<String>) -> Self {
self.content_warning = Some(warning.into());
self
}
#[must_use]
pub const fn duration_seconds(mut self, secs: f64) -> Self {
self.duration_seconds = Some(secs);
self
}
#[must_use]
pub fn text_track(mut self, track: TextTrack) -> Self {
self.text_tracks.push(track);
self
}
#[must_use]
pub fn segment(mut self, seg: Segment) -> Self {
self.segments.push(seg);
self
}
#[must_use]
pub fn hashtag(mut self, tag: impl AsRef<str>) -> Self {
self.hashtags.push(tag.as_ref().to_lowercase());
self
}
#[must_use]
pub fn participant(mut self, p: VideoParticipant) -> Self {
self.participants.push(p);
self
}
#[must_use]
pub fn reference(mut self, url: Url) -> Self {
self.references.push(url);
self
}
#[must_use]
pub fn origin(mut self, origin: VideoOrigin) -> Self {
self.origin = Some(origin);
self
}
#[must_use]
pub fn coordinate(&self, author: PublicKey) -> Option<Coordinate> {
if !self.kind.is_addressable() {
return None;
}
let identifier = self.identifier.clone().unwrap_or_default();
Some(Coordinate::new(self.kind.to_kind(), author, identifier))
}
pub fn from_event(event: &Event) -> Result<Self, VideoError> {
let kind = VideoKind::from_kind(event.kind).ok_or(VideoError::WrongKind(event.kind))?;
let identifier = d_value(&event.tags).map(str::to_owned);
if kind.is_addressable() && identifier.is_none() {
return Err(VideoError::MissingIdentifier);
}
let mut video = Self {
kind,
identifier,
content: event.content.clone(),
title: String::new(),
media: Vec::new(),
published_at: None,
alt: None,
content_warning: None,
duration_seconds: None,
text_tracks: Vec::new(),
segments: Vec::new(),
hashtags: Vec::new(),
participants: Vec::new(),
references: Vec::new(),
origin: None,
extra_tags: Vec::new(),
};
for tag in &event.tags {
absorb_video_tag(tag, &mut video)?;
}
if video.title.is_empty() {
return Err(VideoError::MissingTitle);
}
if video.media.is_empty() {
return Err(VideoError::MissingMedia);
}
Ok(video)
}
}
fn absorb_video_tag(tag: &Tag, video: &mut Video) -> Result<(), VideoError> {
match tag.kind() {
TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::D => {}
TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::T => {
if let Some(raw) = tag.get(1) {
video.hashtags.push(raw.to_ascii_lowercase());
}
}
TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::R => {
if let Some(raw) = tag.get(1) {
video.references.push(Url::parse(raw)?);
}
}
TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::P => {
video.participants.push(parse_participant(tag)?);
}
_ if tag.name() == TITLE_TAG => {
video.title = tag.get(1).map(str::to_owned).unwrap_or_default();
}
_ if tag.name() == PUBLISHED_AT_TAG => {
if let Some(raw) = tag.get(1) {
video.published_at = Some(raw.parse::<Timestamp>()?);
}
}
_ if tag.name() == ALT_TAG => video.alt = tag.get(1).map(str::to_owned),
_ if tag.name() == CONTENT_WARNING_TAG => {
video.content_warning = tag.get(1).map(str::to_owned);
}
_ if tag.name() == DURATION_TAG => {
if let Some(raw) = tag.get(1) {
video.duration_seconds = Some(
raw.parse::<f64>()
.map_err(|_| VideoError::InvalidDuration(raw.to_owned()))?,
);
}
}
_ if tag.name() == TEXT_TRACK_TAG => video.text_tracks.push(parse_text_track(tag)?),
_ if tag.name() == SEGMENT_TAG => video.segments.push(parse_segment(tag)?),
_ if tag.name() == ORIGIN_TAG => video.origin = Some(parse_origin(tag)?),
_ if tag.name() == IMETA_TAG => {
video.media.push(MediaAttachment::from_tag(tag)?);
}
_ => video.extra_tags.push(tag.clone()),
}
Ok(())
}
fn parse_participant(tag: &Tag) -> Result<VideoParticipant, VideoError> {
let pk_hex = tag.get(1).ok_or(VideoError::MalformedParticipant)?;
let pubkey = PublicKey::parse(pk_hex)?;
let relay_hint = match tag.get(2) {
Some(s) if !s.is_empty() => Some(RelayUrl::parse(s)?),
_ => None,
};
Ok(VideoParticipant { pubkey, relay_hint })
}
fn parse_text_track(tag: &Tag) -> Result<TextTrack, VideoError> {
let value = tag.get(1).ok_or(VideoError::MalformedTextTrack)?.to_owned();
let relay_hint = match tag.get(2) {
Some(s) if !s.is_empty() => Some(RelayUrl::parse(s)?),
_ => None,
};
Ok(TextTrack { value, relay_hint })
}
fn parse_segment(tag: &Tag) -> Result<Segment, VideoError> {
let start = tag.get(1).ok_or(VideoError::MalformedSegment)?.to_owned();
let end = tag.get(2).ok_or(VideoError::MalformedSegment)?.to_owned();
let title = tag.get(3).ok_or(VideoError::MalformedSegment)?.to_owned();
let thumbnail = match tag.get(4) {
Some(s) if !s.is_empty() => Some(Url::parse(s)?),
_ => None,
};
Ok(Segment {
start,
end,
title,
thumbnail,
})
}
fn parse_origin(tag: &Tag) -> Result<VideoOrigin, VideoError> {
let platform = tag.get(1).ok_or(VideoError::MalformedOrigin)?.to_owned();
let external_id = tag.get(2).ok_or(VideoError::MalformedOrigin)?.to_owned();
let original_url = match tag.get(3) {
Some(s) if !s.is_empty() => Some(Url::parse(s)?),
_ => None,
};
let metadata = tag.get(4).filter(|s| !s.is_empty()).map(str::to_owned);
Ok(VideoOrigin {
platform,
external_id,
original_url,
metadata,
})
}
fn d_value(tags: &Tags) -> Option<&str> {
let head = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::D));
tags.find_first(&head).and_then(|tag| tag.get(1))
}
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum VideoError {
#[error("unexpected kind for NIP-71 event: {}", .0.as_u16())]
WrongKind(Kind),
#[error("addressable NIP-71 video missing `d` identifier")]
MissingIdentifier,
#[error("NIP-71 video missing `title` tag")]
MissingTitle,
#[error("NIP-71 video missing `imeta` tag")]
MissingMedia,
#[error("`p` participant tag missing pubkey")]
MalformedParticipant,
#[error("`text-track` tag missing value")]
MalformedTextTrack,
#[error("`segment` tag missing required columns")]
MalformedSegment,
#[error("`origin` tag missing required columns")]
MalformedOrigin,
#[error("invalid `duration` value: `{0}`")]
InvalidDuration(String),
#[error(transparent)]
Media(#[from] MediaAttachmentError),
#[error(transparent)]
InvalidPublicKey(#[from] PublicKeyError),
#[error(transparent)]
InvalidUrl(#[from] UrlError),
#[error(transparent)]
InvalidRelayUrl(#[from] RelayUrlError),
#[error(transparent)]
InvalidTimestamp(#[from] TimestampError),
}
impl EventBuilder {
pub fn video(video: &Video) -> Result<Self, VideoError> {
if video.kind.is_addressable() && video.identifier.is_none() {
return Err(VideoError::MissingIdentifier);
}
let mut builder = Self::new(video.kind.to_kind(), video.content.clone());
if let Some(identifier) = &video.identifier {
builder = builder.tag(Tag::d(identifier));
}
builder = builder.tag(Tag::with(
&TagKind::from_wire(TITLE_TAG),
[video.title.clone()],
));
if let Some(ts) = video.published_at {
builder = builder.tag(Tag::with(
&TagKind::from_wire(PUBLISHED_AT_TAG),
[ts.as_secs().to_string()],
));
}
if let Some(alt) = &video.alt {
builder = builder.tag(Tag::with(&TagKind::from_wire(ALT_TAG), [alt.clone()]));
}
if let Some(cw) = &video.content_warning {
builder = builder.tag(Tag::with(
&TagKind::from_wire(CONTENT_WARNING_TAG),
[cw.clone()],
));
}
if let Some(dur) = video.duration_seconds {
builder = builder.tag(Tag::with(
&TagKind::from_wire(DURATION_TAG),
[dur.to_string()],
));
}
for media in &video.media {
builder = builder.tag(media.to_tag()?);
}
for track in &video.text_tracks {
builder = builder.tag(text_track_tag(track));
}
for seg in &video.segments {
builder = builder.tag(segment_tag(seg));
}
for hashtag in &video.hashtags {
builder = builder.tag(Tag::t(hashtag));
}
for participant in &video.participants {
builder = builder.tag(participant_tag(participant));
}
for url in &video.references {
let head = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::R));
builder = builder.tag(Tag::with(&head, [url.as_str().to_owned()]));
}
if let Some(origin) = &video.origin {
builder = builder.tag(origin_tag(origin));
}
for tag in &video.extra_tags {
builder = builder.tag(tag.clone());
}
Ok(builder)
}
}
fn text_track_tag(track: &TextTrack) -> Tag {
let head = TagKind::from_wire(TEXT_TRACK_TAG);
track.relay_hint.as_ref().map_or_else(
|| Tag::with(&head, [track.value.clone()]),
|relay| Tag::with(&head, [track.value.clone(), relay.as_str().to_owned()]),
)
}
fn segment_tag(seg: &Segment) -> Tag {
let head = TagKind::from_wire(SEGMENT_TAG);
seg.thumbnail.as_ref().map_or_else(
|| {
Tag::with(
&head,
[seg.start.clone(), seg.end.clone(), seg.title.clone()],
)
},
|url| {
Tag::with(
&head,
[
seg.start.clone(),
seg.end.clone(),
seg.title.clone(),
url.as_str().to_owned(),
],
)
},
)
}
fn participant_tag(p: &VideoParticipant) -> Tag {
let head = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::P));
p.relay_hint.as_ref().map_or_else(
|| Tag::with(&head, [p.pubkey.to_hex()]),
|relay| Tag::with(&head, [p.pubkey.to_hex(), relay.as_str().to_owned()]),
)
}
fn origin_tag(origin: &VideoOrigin) -> Tag {
let head = TagKind::from_wire(ORIGIN_TAG);
let mut cols: Vec<String> = vec![origin.platform.clone(), origin.external_id.clone()];
if let Some(url) = &origin.original_url {
cols.push(url.as_str().to_owned());
} else if origin.metadata.is_some() {
cols.push(String::new());
}
if let Some(meta) = &origin.metadata {
cols.push(meta.clone());
}
Tag::with(&head, cols)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Keys;
fn keys() -> Keys {
Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
}
fn sample_media() -> MediaAttachment {
MediaAttachment::new(Url::parse("https://example.com/v.mp4").unwrap())
.mime_type("video/mp4")
.dim("1920x1080".parse().unwrap())
}
#[test]
fn video_kind_round_trip() {
for k in [
VideoKind::Normal,
VideoKind::Short,
VideoKind::NormalAddressable,
VideoKind::ShortAddressable,
] {
assert_eq!(VideoKind::from_kind(k.to_kind()), Some(k));
}
}
#[test]
fn regular_video_round_trip() {
let video = Video::new(VideoKind::Normal, "Demo Video", sample_media())
.content("summary")
.alt("alt text")
.hashtag("ANIMATION")
.reference(Url::parse("https://blog.example.com/ep1").unwrap())
.participant(VideoParticipant {
pubkey: *keys().public_key(),
relay_hint: Some(RelayUrl::parse("wss://relay.example/").unwrap()),
})
.published_at(Timestamp::from_secs(1_700_000_000));
let event = EventBuilder::video(&video)
.unwrap()
.sign_with_keys(&keys())
.unwrap();
let parsed = Video::from_event(&event).unwrap();
assert_eq!(parsed.hashtags, vec!["animation".to_owned()]);
assert_eq!(parsed.title, "Demo Video");
assert_eq!(parsed.media.len(), 1);
assert_eq!(parsed.kind, VideoKind::Normal);
}
#[test]
fn addressable_video_round_trip() {
let video = Video::new(VideoKind::NormalAddressable, "Addr Video", sample_media())
.identifier("ep-1")
.duration_seconds(120.5)
.content_warning("loud");
let event = EventBuilder::video(&video)
.unwrap()
.sign_with_keys(&keys())
.unwrap();
let parsed = Video::from_event(&event).unwrap();
assert_eq!(parsed.identifier.as_deref(), Some("ep-1"));
assert!((parsed.duration_seconds.unwrap() - 120.5).abs() < 1e-6);
assert_eq!(parsed.content_warning.as_deref(), Some("loud"));
}
#[test]
fn video_missing_media_is_rejected() {
let event = EventBuilder::new(KIND_VIDEO_NORMAL, "")
.tag(Tag::with(&TagKind::from_wire(TITLE_TAG), ["no media"]))
.sign_with_keys(&keys())
.unwrap();
assert!(matches!(
Video::from_event(&event),
Err(VideoError::MissingMedia)
));
}
#[test]
fn addressable_missing_identifier_is_rejected() {
let event = EventBuilder::new(KIND_VIDEO_NORMAL_ADDRESSABLE, "")
.tag(Tag::with(&TagKind::from_wire(TITLE_TAG), ["no id"]))
.tag(sample_media().to_tag().unwrap())
.sign_with_keys(&keys())
.unwrap();
assert!(matches!(
Video::from_event(&event),
Err(VideoError::MissingIdentifier)
));
}
#[test]
fn segment_round_trip() {
let seg = Segment {
start: "00:00:00.000".into(),
end: "00:00:10.000".into(),
title: "Intro".into(),
thumbnail: Some(Url::parse("https://example.com/t.jpg").unwrap()),
};
let video = Video::new(VideoKind::Normal, "seg", sample_media()).segment(seg.clone());
let event = EventBuilder::video(&video)
.unwrap()
.sign_with_keys(&keys())
.unwrap();
let parsed = Video::from_event(&event).unwrap();
assert_eq!(parsed.segments, vec![seg]);
}
#[test]
fn origin_round_trip() {
let origin = VideoOrigin {
platform: "youtube".into(),
external_id: "abc123".into(),
original_url: Some(Url::parse("https://youtu.be/abc123").unwrap()),
metadata: Some(r#"{"duration":"120"}"#.into()),
};
let video = Video::new(VideoKind::NormalAddressable, "origin", sample_media())
.identifier("ep-2")
.origin(origin.clone());
let event = EventBuilder::video(&video)
.unwrap()
.sign_with_keys(&keys())
.unwrap();
let parsed = Video::from_event(&event).unwrap();
assert_eq!(parsed.origin, Some(origin));
}
#[test]
fn wrong_kind_is_rejected() {
let event = EventBuilder::text_note("nope")
.sign_with_keys(&keys())
.unwrap();
assert!(matches!(
Video::from_event(&event),
Err(VideoError::WrongKind(_))
));
}
}