Skip to main content

nula_core/nips/
nip71.rs

1//! [NIP-71] Video Events.
2//!
3//! Four kinds model the same content shape:
4//!
5//! | Kind   | Form         | Target use                           |
6//! |--------|--------------|--------------------------------------|
7//! | `21`   | regular      | long-form / landscape videos         |
8//! | `22`   | regular      | short-form / portrait videos         |
9//! | `34235`| addressable  | long-form videos with a `d` identifier |
10//! | `34236`| addressable  | short-form videos with a `d` identifier |
11//!
12//! # Modelled fields
13//!
14//! - **Required**: `title`, plus at least one `imeta` tag carrying
15//!   the variant's URL and extra metadata. Addressable variants also
16//!   require a `d` identifier.
17//! - **`imeta` tags** reuse [`MediaAttachment`] from NIP-92, which
18//!   already rounds-trips every NIP-94 field and preserves unknown
19//!   keys. NIP-71's two extra fields (`duration`, `bitrate`) ride
20//!   inside that struct's
21//!   [`extra_fields`](MediaAttachment::extra_fields) passthrough, so
22//!   producers that set them round-trip cleanly.
23//! - **Top-level**: `published_at`, `alt`, `content-warning` (spec
24//!   cross-ref to NIP-36), `duration`, `t` hashtags, `p`
25//!   participants (optional relay hint), `r` URLs, `text-track`
26//!   entries, `segment` chapters, and `origin` imported-content
27//!   metadata.
28//!
29//! Unknown tags survive a round-trip through [`Video::extra_tags`].
30//!
31//! [NIP-71]: https://github.com/nostr-protocol/nips/blob/master/71.md
32
33use thiserror::Error;
34
35use crate::event::{
36    Alphabet, Coordinate, Event, EventBuilder, Kind, SingleLetterTag, Tag, TagKind, Tags,
37};
38use crate::key::{PublicKey, PublicKeyError};
39use crate::nips::nip92::{IMETA_TAG, MediaAttachment, MediaAttachmentError};
40use crate::types::{RelayUrl, RelayUrlError, Timestamp, TimestampError, Url, UrlError};
41
42/// `kind: 21` — normal (long-form) video.
43pub const KIND_VIDEO_NORMAL: Kind = Kind::VIDEO_NORMAL;
44
45/// `kind: 22` — short-form video.
46pub const KIND_VIDEO_SHORT: Kind = Kind::VIDEO_SHORT;
47
48/// `kind: 34235` — addressable normal video.
49pub const KIND_VIDEO_NORMAL_ADDRESSABLE: Kind = Kind::VIDEO_NORMAL_ADDRESSABLE;
50
51/// `kind: 34236` — addressable short video.
52pub const KIND_VIDEO_SHORT_ADDRESSABLE: Kind = Kind::VIDEO_SHORT_ADDRESSABLE;
53
54const TITLE_TAG: &str = "title";
55const PUBLISHED_AT_TAG: &str = "published_at";
56const ALT_TAG: &str = "alt";
57const CONTENT_WARNING_TAG: &str = "content-warning";
58const DURATION_TAG: &str = "duration";
59const TEXT_TRACK_TAG: &str = "text-track";
60const SEGMENT_TAG: &str = "segment";
61const ORIGIN_TAG: &str = "origin";
62
63/// Semantic identifier for one of the four NIP-71 kinds.
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
65pub enum VideoKind {
66    /// `kind: 21` — long-form regular event.
67    Normal,
68    /// `kind: 22` — short-form regular event.
69    Short,
70    /// `kind: 34235` — long-form addressable event.
71    NormalAddressable,
72    /// `kind: 34236` — short-form addressable event.
73    ShortAddressable,
74}
75
76impl VideoKind {
77    /// Wire kind for this variant.
78    #[must_use]
79    pub const fn to_kind(self) -> Kind {
80        match self {
81            Self::Normal => KIND_VIDEO_NORMAL,
82            Self::Short => KIND_VIDEO_SHORT,
83            Self::NormalAddressable => KIND_VIDEO_NORMAL_ADDRESSABLE,
84            Self::ShortAddressable => KIND_VIDEO_SHORT_ADDRESSABLE,
85        }
86    }
87
88    /// Map a wire kind back to the semantic identifier.
89    #[must_use]
90    pub const fn from_kind(kind: Kind) -> Option<Self> {
91        match kind.as_u16() {
92            21 => Some(Self::Normal),
93            22 => Some(Self::Short),
94            34_235 => Some(Self::NormalAddressable),
95            34_236 => Some(Self::ShortAddressable),
96            _ => None,
97        }
98    }
99
100    /// `true` when the variant is addressable and therefore
101    /// requires a `d` identifier.
102    #[must_use]
103    pub const fn is_addressable(self) -> bool {
104        matches!(self, Self::NormalAddressable | Self::ShortAddressable)
105    }
106}
107
108/// A `text-track` tag (captions / subtitles / chapters / metadata).
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct TextTrack {
111    /// Spec column 1: value (URL, NIP-19 entity, or opaque
112    /// identifier). Left as `String` because the spec's example
113    /// uses "`<encoded kind 6000 event>`".
114    pub value: String,
115    /// Optional recommended relay URL.
116    pub relay_hint: Option<RelayUrl>,
117}
118
119/// A `segment` tag chapter entry.
120#[derive(Debug, Clone, PartialEq, Eq)]
121pub struct Segment {
122    /// Start timestamp (`HH:MM:SS.sss`).
123    pub start: String,
124    /// End timestamp (`HH:MM:SS.sss`).
125    pub end: String,
126    /// Chapter title.
127    pub title: String,
128    /// Thumbnail URL.
129    pub thumbnail: Option<Url>,
130}
131
132/// A `p` participant tag on a video event.
133#[derive(Debug, Clone, PartialEq, Eq)]
134pub struct VideoParticipant {
135    /// Participant pubkey.
136    pub pubkey: PublicKey,
137    /// Optional recommended relay URL.
138    pub relay_hint: Option<RelayUrl>,
139}
140
141/// An `origin` tag for imported content (spec §"Optional tags for
142/// imported content").
143#[derive(Debug, Clone, PartialEq, Eq)]
144pub struct VideoOrigin {
145    /// Platform identifier (`youtube`, `tiktok`, custom).
146    pub platform: String,
147    /// Platform-external ID.
148    pub external_id: String,
149    /// Optional original URL.
150    pub original_url: Option<Url>,
151    /// Optional extra metadata (free-form).
152    pub metadata: Option<String>,
153}
154
155/// Typed bundle for a NIP-71 video event (any of the four kinds).
156#[derive(Debug, Clone, PartialEq)]
157pub struct Video {
158    /// Semantic kind.
159    pub kind: VideoKind,
160    /// `d` identifier (required for addressable variants).
161    pub identifier: Option<String>,
162    /// `.content` — summary / description.
163    pub content: String,
164    /// `title` tag (required).
165    pub title: String,
166    /// Media variants (at least one `imeta` tag).
167    pub media: Vec<MediaAttachment>,
168    /// `published_at` Unix timestamp.
169    pub published_at: Option<Timestamp>,
170    /// `alt` accessibility description.
171    pub alt: Option<String>,
172    /// `content-warning` reason.
173    pub content_warning: Option<String>,
174    /// Top-level `duration` in seconds (used by the spec's
175    /// addressable example; the same value MAY also ride inside
176    /// `imeta`).
177    pub duration_seconds: Option<f64>,
178    /// `text-track` rows.
179    pub text_tracks: Vec<TextTrack>,
180    /// `segment` chapters.
181    pub segments: Vec<Segment>,
182    /// `t` hashtags (lower-cased).
183    pub hashtags: Vec<String>,
184    /// `p` participants.
185    pub participants: Vec<VideoParticipant>,
186    /// `r` URL references.
187    pub references: Vec<Url>,
188    /// `origin` import metadata.
189    pub origin: Option<VideoOrigin>,
190    /// Forward-compatible passthrough for unknown tags.
191    pub extra_tags: Vec<Tag>,
192}
193
194impl Video {
195    /// Construct a regular (non-addressable) video.
196    #[must_use]
197    pub fn new(kind: VideoKind, title: impl Into<String>, media: MediaAttachment) -> Self {
198        Self {
199            kind,
200            identifier: if kind.is_addressable() {
201                Some(String::new())
202            } else {
203                None
204            },
205            content: String::new(),
206            title: title.into(),
207            media: vec![media],
208            published_at: None,
209            alt: None,
210            content_warning: None,
211            duration_seconds: None,
212            text_tracks: Vec::new(),
213            segments: Vec::new(),
214            hashtags: Vec::new(),
215            participants: Vec::new(),
216            references: Vec::new(),
217            origin: None,
218            extra_tags: Vec::new(),
219        }
220    }
221
222    /// Set the `d` identifier (required for addressable kinds).
223    #[must_use]
224    pub fn identifier(mut self, identifier: impl Into<String>) -> Self {
225        self.identifier = Some(identifier.into());
226        self
227    }
228
229    /// Set the `.content` body.
230    #[must_use]
231    pub fn content(mut self, content: impl Into<String>) -> Self {
232        self.content = content.into();
233        self
234    }
235
236    /// Append another media variant.
237    #[must_use]
238    pub fn media(mut self, media: MediaAttachment) -> Self {
239        self.media.push(media);
240        self
241    }
242
243    /// Set [`Self::published_at`].
244    #[must_use]
245    pub const fn published_at(mut self, ts: Timestamp) -> Self {
246        self.published_at = Some(ts);
247        self
248    }
249
250    /// Set [`Self::alt`].
251    #[must_use]
252    pub fn alt(mut self, alt: impl Into<String>) -> Self {
253        self.alt = Some(alt.into());
254        self
255    }
256
257    /// Set [`Self::content_warning`].
258    #[must_use]
259    pub fn content_warning(mut self, warning: impl Into<String>) -> Self {
260        self.content_warning = Some(warning.into());
261        self
262    }
263
264    /// Set [`Self::duration_seconds`].
265    #[must_use]
266    pub const fn duration_seconds(mut self, secs: f64) -> Self {
267        self.duration_seconds = Some(secs);
268        self
269    }
270
271    /// Append a text track.
272    #[must_use]
273    pub fn text_track(mut self, track: TextTrack) -> Self {
274        self.text_tracks.push(track);
275        self
276    }
277
278    /// Append a segment.
279    #[must_use]
280    pub fn segment(mut self, seg: Segment) -> Self {
281        self.segments.push(seg);
282        self
283    }
284
285    /// Append a hashtag (lower-cased).
286    #[must_use]
287    pub fn hashtag(mut self, tag: impl AsRef<str>) -> Self {
288        self.hashtags.push(tag.as_ref().to_lowercase());
289        self
290    }
291
292    /// Append a participant.
293    #[must_use]
294    pub fn participant(mut self, p: VideoParticipant) -> Self {
295        self.participants.push(p);
296        self
297    }
298
299    /// Append a reference URL.
300    #[must_use]
301    pub fn reference(mut self, url: Url) -> Self {
302        self.references.push(url);
303        self
304    }
305
306    /// Set the origin metadata.
307    #[must_use]
308    pub fn origin(mut self, origin: VideoOrigin) -> Self {
309        self.origin = Some(origin);
310        self
311    }
312
313    /// Build the addressable coordinate. Returns `None` for
314    /// non-addressable kinds.
315    #[must_use]
316    pub fn coordinate(&self, author: PublicKey) -> Option<Coordinate> {
317        if !self.kind.is_addressable() {
318            return None;
319        }
320        let identifier = self.identifier.clone().unwrap_or_default();
321        Some(Coordinate::new(self.kind.to_kind(), author, identifier))
322    }
323
324    /// Parse an NIP-71 event into a typed bundle.
325    ///
326    /// # Errors
327    ///
328    /// - [`VideoError::WrongKind`] for any non-NIP-71 kind.
329    /// - [`VideoError::MissingIdentifier`] on addressable variants
330    ///   missing a `d` tag.
331    /// - [`VideoError::MissingTitle`] when no `title` tag is present.
332    /// - [`VideoError::MissingMedia`] when no `imeta` tags are
333    ///   present (the spec says the primary source of video info is
334    ///   `imeta`).
335    /// - Field-specific errors for malformed columns.
336    pub fn from_event(event: &Event) -> Result<Self, VideoError> {
337        let kind = VideoKind::from_kind(event.kind).ok_or(VideoError::WrongKind(event.kind))?;
338        let identifier = d_value(&event.tags).map(str::to_owned);
339        if kind.is_addressable() && identifier.is_none() {
340            return Err(VideoError::MissingIdentifier);
341        }
342        let mut video = Self {
343            kind,
344            identifier,
345            content: event.content.clone(),
346            title: String::new(),
347            media: Vec::new(),
348            published_at: None,
349            alt: None,
350            content_warning: None,
351            duration_seconds: None,
352            text_tracks: Vec::new(),
353            segments: Vec::new(),
354            hashtags: Vec::new(),
355            participants: Vec::new(),
356            references: Vec::new(),
357            origin: None,
358            extra_tags: Vec::new(),
359        };
360        for tag in &event.tags {
361            absorb_video_tag(tag, &mut video)?;
362        }
363        if video.title.is_empty() {
364            return Err(VideoError::MissingTitle);
365        }
366        if video.media.is_empty() {
367            return Err(VideoError::MissingMedia);
368        }
369        Ok(video)
370    }
371}
372
373fn absorb_video_tag(tag: &Tag, video: &mut Video) -> Result<(), VideoError> {
374    match tag.kind() {
375        TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::D => {}
376        TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::T => {
377            if let Some(raw) = tag.get(1) {
378                video.hashtags.push(raw.to_ascii_lowercase());
379            }
380        }
381        TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::R => {
382            if let Some(raw) = tag.get(1) {
383                video.references.push(Url::parse(raw)?);
384            }
385        }
386        TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::P => {
387            video.participants.push(parse_participant(tag)?);
388        }
389        _ if tag.name() == TITLE_TAG => {
390            video.title = tag.get(1).map(str::to_owned).unwrap_or_default();
391        }
392        _ if tag.name() == PUBLISHED_AT_TAG => {
393            if let Some(raw) = tag.get(1) {
394                video.published_at = Some(raw.parse::<Timestamp>()?);
395            }
396        }
397        _ if tag.name() == ALT_TAG => video.alt = tag.get(1).map(str::to_owned),
398        _ if tag.name() == CONTENT_WARNING_TAG => {
399            video.content_warning = tag.get(1).map(str::to_owned);
400        }
401        _ if tag.name() == DURATION_TAG => {
402            if let Some(raw) = tag.get(1) {
403                video.duration_seconds = Some(
404                    raw.parse::<f64>()
405                        .map_err(|_| VideoError::InvalidDuration(raw.to_owned()))?,
406                );
407            }
408        }
409        _ if tag.name() == TEXT_TRACK_TAG => video.text_tracks.push(parse_text_track(tag)?),
410        _ if tag.name() == SEGMENT_TAG => video.segments.push(parse_segment(tag)?),
411        _ if tag.name() == ORIGIN_TAG => video.origin = Some(parse_origin(tag)?),
412        _ if tag.name() == IMETA_TAG => {
413            video.media.push(MediaAttachment::from_tag(tag)?);
414        }
415        _ => video.extra_tags.push(tag.clone()),
416    }
417    Ok(())
418}
419
420fn parse_participant(tag: &Tag) -> Result<VideoParticipant, VideoError> {
421    let pk_hex = tag.get(1).ok_or(VideoError::MalformedParticipant)?;
422    let pubkey = PublicKey::parse(pk_hex)?;
423    let relay_hint = match tag.get(2) {
424        Some(s) if !s.is_empty() => Some(RelayUrl::parse(s)?),
425        _ => None,
426    };
427    Ok(VideoParticipant { pubkey, relay_hint })
428}
429
430fn parse_text_track(tag: &Tag) -> Result<TextTrack, VideoError> {
431    let value = tag.get(1).ok_or(VideoError::MalformedTextTrack)?.to_owned();
432    let relay_hint = match tag.get(2) {
433        Some(s) if !s.is_empty() => Some(RelayUrl::parse(s)?),
434        _ => None,
435    };
436    Ok(TextTrack { value, relay_hint })
437}
438
439fn parse_segment(tag: &Tag) -> Result<Segment, VideoError> {
440    let start = tag.get(1).ok_or(VideoError::MalformedSegment)?.to_owned();
441    let end = tag.get(2).ok_or(VideoError::MalformedSegment)?.to_owned();
442    let title = tag.get(3).ok_or(VideoError::MalformedSegment)?.to_owned();
443    let thumbnail = match tag.get(4) {
444        Some(s) if !s.is_empty() => Some(Url::parse(s)?),
445        _ => None,
446    };
447    Ok(Segment {
448        start,
449        end,
450        title,
451        thumbnail,
452    })
453}
454
455fn parse_origin(tag: &Tag) -> Result<VideoOrigin, VideoError> {
456    let platform = tag.get(1).ok_or(VideoError::MalformedOrigin)?.to_owned();
457    let external_id = tag.get(2).ok_or(VideoError::MalformedOrigin)?.to_owned();
458    let original_url = match tag.get(3) {
459        Some(s) if !s.is_empty() => Some(Url::parse(s)?),
460        _ => None,
461    };
462    let metadata = tag.get(4).filter(|s| !s.is_empty()).map(str::to_owned);
463    Ok(VideoOrigin {
464        platform,
465        external_id,
466        original_url,
467        metadata,
468    })
469}
470
471fn d_value(tags: &Tags) -> Option<&str> {
472    let head = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::D));
473    tags.find_first(&head).and_then(|tag| tag.get(1))
474}
475
476/// Errors raised by NIP-71 parsers.
477#[derive(Debug, Error)]
478#[non_exhaustive]
479pub enum VideoError {
480    /// Event kind is not one of the four NIP-71 kinds.
481    #[error("unexpected kind for NIP-71 event: {}", .0.as_u16())]
482    WrongKind(Kind),
483    /// Addressable event is missing its `d` tag.
484    #[error("addressable NIP-71 video missing `d` identifier")]
485    MissingIdentifier,
486    /// Required `title` tag is absent.
487    #[error("NIP-71 video missing `title` tag")]
488    MissingTitle,
489    /// Required `imeta` tag(s) are absent.
490    #[error("NIP-71 video missing `imeta` tag")]
491    MissingMedia,
492    /// `p` tag missing pubkey column.
493    #[error("`p` participant tag missing pubkey")]
494    MalformedParticipant,
495    /// `text-track` tag missing value column.
496    #[error("`text-track` tag missing value")]
497    MalformedTextTrack,
498    /// `segment` tag missing one of the required columns.
499    #[error("`segment` tag missing required columns")]
500    MalformedSegment,
501    /// `origin` tag missing one of the required columns.
502    #[error("`origin` tag missing required columns")]
503    MalformedOrigin,
504    /// `duration` tag value could not be parsed as `f64`.
505    #[error("invalid `duration` value: `{0}`")]
506    InvalidDuration(String),
507    /// Wrapped `imeta` parser error.
508    #[error(transparent)]
509    Media(#[from] MediaAttachmentError),
510    /// Wrapped pubkey parser error.
511    #[error(transparent)]
512    InvalidPublicKey(#[from] PublicKeyError),
513    /// Wrapped URL parser error.
514    #[error(transparent)]
515    InvalidUrl(#[from] UrlError),
516    /// Wrapped relay-URL parser error.
517    #[error(transparent)]
518    InvalidRelayUrl(#[from] RelayUrlError),
519    /// Wrapped timestamp parser error.
520    #[error(transparent)]
521    InvalidTimestamp(#[from] TimestampError),
522}
523
524impl EventBuilder {
525    /// Author a NIP-71 video event.
526    ///
527    /// # Errors
528    ///
529    /// Propagates [`MediaAttachmentError`] when any variant in
530    /// [`Video::media`] violates NIP-92 invariants (missing URL or
531    /// no other field). Also returns
532    /// [`VideoError::MissingIdentifier`] when the variant is
533    /// addressable but [`Video::identifier`] is `None`.
534    pub fn video(video: &Video) -> Result<Self, VideoError> {
535        if video.kind.is_addressable() && video.identifier.is_none() {
536            return Err(VideoError::MissingIdentifier);
537        }
538        let mut builder = Self::new(video.kind.to_kind(), video.content.clone());
539        if let Some(identifier) = &video.identifier {
540            builder = builder.tag(Tag::d(identifier));
541        }
542        builder = builder.tag(Tag::with(
543            &TagKind::from_wire(TITLE_TAG),
544            [video.title.clone()],
545        ));
546        if let Some(ts) = video.published_at {
547            builder = builder.tag(Tag::with(
548                &TagKind::from_wire(PUBLISHED_AT_TAG),
549                [ts.as_secs().to_string()],
550            ));
551        }
552        if let Some(alt) = &video.alt {
553            builder = builder.tag(Tag::with(&TagKind::from_wire(ALT_TAG), [alt.clone()]));
554        }
555        if let Some(cw) = &video.content_warning {
556            builder = builder.tag(Tag::with(
557                &TagKind::from_wire(CONTENT_WARNING_TAG),
558                [cw.clone()],
559            ));
560        }
561        if let Some(dur) = video.duration_seconds {
562            builder = builder.tag(Tag::with(
563                &TagKind::from_wire(DURATION_TAG),
564                [dur.to_string()],
565            ));
566        }
567        for media in &video.media {
568            builder = builder.tag(media.to_tag()?);
569        }
570        for track in &video.text_tracks {
571            builder = builder.tag(text_track_tag(track));
572        }
573        for seg in &video.segments {
574            builder = builder.tag(segment_tag(seg));
575        }
576        for hashtag in &video.hashtags {
577            builder = builder.tag(Tag::t(hashtag));
578        }
579        for participant in &video.participants {
580            builder = builder.tag(participant_tag(participant));
581        }
582        for url in &video.references {
583            let head = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::R));
584            builder = builder.tag(Tag::with(&head, [url.as_str().to_owned()]));
585        }
586        if let Some(origin) = &video.origin {
587            builder = builder.tag(origin_tag(origin));
588        }
589        for tag in &video.extra_tags {
590            builder = builder.tag(tag.clone());
591        }
592        Ok(builder)
593    }
594}
595
596fn text_track_tag(track: &TextTrack) -> Tag {
597    let head = TagKind::from_wire(TEXT_TRACK_TAG);
598    track.relay_hint.as_ref().map_or_else(
599        || Tag::with(&head, [track.value.clone()]),
600        |relay| Tag::with(&head, [track.value.clone(), relay.as_str().to_owned()]),
601    )
602}
603
604fn segment_tag(seg: &Segment) -> Tag {
605    let head = TagKind::from_wire(SEGMENT_TAG);
606    seg.thumbnail.as_ref().map_or_else(
607        || {
608            Tag::with(
609                &head,
610                [seg.start.clone(), seg.end.clone(), seg.title.clone()],
611            )
612        },
613        |url| {
614            Tag::with(
615                &head,
616                [
617                    seg.start.clone(),
618                    seg.end.clone(),
619                    seg.title.clone(),
620                    url.as_str().to_owned(),
621                ],
622            )
623        },
624    )
625}
626
627fn participant_tag(p: &VideoParticipant) -> Tag {
628    let head = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::P));
629    p.relay_hint.as_ref().map_or_else(
630        || Tag::with(&head, [p.pubkey.to_hex()]),
631        |relay| Tag::with(&head, [p.pubkey.to_hex(), relay.as_str().to_owned()]),
632    )
633}
634
635fn origin_tag(origin: &VideoOrigin) -> Tag {
636    let head = TagKind::from_wire(ORIGIN_TAG);
637    let mut cols: Vec<String> = vec![origin.platform.clone(), origin.external_id.clone()];
638    if let Some(url) = &origin.original_url {
639        cols.push(url.as_str().to_owned());
640    } else if origin.metadata.is_some() {
641        cols.push(String::new());
642    }
643    if let Some(meta) = &origin.metadata {
644        cols.push(meta.clone());
645    }
646    Tag::with(&head, cols)
647}
648
649#[cfg(test)]
650mod tests {
651    use super::*;
652    use crate::Keys;
653
654    fn keys() -> Keys {
655        Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
656    }
657
658    fn sample_media() -> MediaAttachment {
659        MediaAttachment::new(Url::parse("https://example.com/v.mp4").unwrap())
660            .mime_type("video/mp4")
661            .dim("1920x1080".parse().unwrap())
662    }
663
664    #[test]
665    fn video_kind_round_trip() {
666        for k in [
667            VideoKind::Normal,
668            VideoKind::Short,
669            VideoKind::NormalAddressable,
670            VideoKind::ShortAddressable,
671        ] {
672            assert_eq!(VideoKind::from_kind(k.to_kind()), Some(k));
673        }
674    }
675
676    #[test]
677    fn regular_video_round_trip() {
678        let video = Video::new(VideoKind::Normal, "Demo Video", sample_media())
679            .content("summary")
680            .alt("alt text")
681            .hashtag("ANIMATION")
682            .reference(Url::parse("https://blog.example.com/ep1").unwrap())
683            .participant(VideoParticipant {
684                pubkey: *keys().public_key(),
685                relay_hint: Some(RelayUrl::parse("wss://relay.example/").unwrap()),
686            })
687            .published_at(Timestamp::from_secs(1_700_000_000));
688        let event = EventBuilder::video(&video)
689            .unwrap()
690            .sign_with_keys(&keys())
691            .unwrap();
692        let parsed = Video::from_event(&event).unwrap();
693        assert_eq!(parsed.hashtags, vec!["animation".to_owned()]);
694        assert_eq!(parsed.title, "Demo Video");
695        assert_eq!(parsed.media.len(), 1);
696        assert_eq!(parsed.kind, VideoKind::Normal);
697    }
698
699    #[test]
700    fn addressable_video_round_trip() {
701        let video = Video::new(VideoKind::NormalAddressable, "Addr Video", sample_media())
702            .identifier("ep-1")
703            .duration_seconds(120.5)
704            .content_warning("loud");
705        let event = EventBuilder::video(&video)
706            .unwrap()
707            .sign_with_keys(&keys())
708            .unwrap();
709        let parsed = Video::from_event(&event).unwrap();
710        assert_eq!(parsed.identifier.as_deref(), Some("ep-1"));
711        assert!((parsed.duration_seconds.unwrap() - 120.5).abs() < 1e-6);
712        assert_eq!(parsed.content_warning.as_deref(), Some("loud"));
713    }
714
715    #[test]
716    fn video_missing_media_is_rejected() {
717        let event = EventBuilder::new(KIND_VIDEO_NORMAL, "")
718            .tag(Tag::with(&TagKind::from_wire(TITLE_TAG), ["no media"]))
719            .sign_with_keys(&keys())
720            .unwrap();
721        assert!(matches!(
722            Video::from_event(&event),
723            Err(VideoError::MissingMedia)
724        ));
725    }
726
727    #[test]
728    fn addressable_missing_identifier_is_rejected() {
729        let event = EventBuilder::new(KIND_VIDEO_NORMAL_ADDRESSABLE, "")
730            .tag(Tag::with(&TagKind::from_wire(TITLE_TAG), ["no id"]))
731            .tag(sample_media().to_tag().unwrap())
732            .sign_with_keys(&keys())
733            .unwrap();
734        assert!(matches!(
735            Video::from_event(&event),
736            Err(VideoError::MissingIdentifier)
737        ));
738    }
739
740    #[test]
741    fn segment_round_trip() {
742        let seg = Segment {
743            start: "00:00:00.000".into(),
744            end: "00:00:10.000".into(),
745            title: "Intro".into(),
746            thumbnail: Some(Url::parse("https://example.com/t.jpg").unwrap()),
747        };
748        let video = Video::new(VideoKind::Normal, "seg", sample_media()).segment(seg.clone());
749        let event = EventBuilder::video(&video)
750            .unwrap()
751            .sign_with_keys(&keys())
752            .unwrap();
753        let parsed = Video::from_event(&event).unwrap();
754        assert_eq!(parsed.segments, vec![seg]);
755    }
756
757    #[test]
758    fn origin_round_trip() {
759        let origin = VideoOrigin {
760            platform: "youtube".into(),
761            external_id: "abc123".into(),
762            original_url: Some(Url::parse("https://youtu.be/abc123").unwrap()),
763            metadata: Some(r#"{"duration":"120"}"#.into()),
764        };
765        let video = Video::new(VideoKind::NormalAddressable, "origin", sample_media())
766            .identifier("ep-2")
767            .origin(origin.clone());
768        let event = EventBuilder::video(&video)
769            .unwrap()
770            .sign_with_keys(&keys())
771            .unwrap();
772        let parsed = Video::from_event(&event).unwrap();
773        assert_eq!(parsed.origin, Some(origin));
774    }
775
776    #[test]
777    fn wrong_kind_is_rejected() {
778        let event = EventBuilder::text_note("nope")
779            .sign_with_keys(&keys())
780            .unwrap();
781        assert!(matches!(
782            Video::from_event(&event),
783            Err(VideoError::WrongKind(_))
784        ));
785    }
786}