1use 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
42pub const KIND_VIDEO_NORMAL: Kind = Kind::VIDEO_NORMAL;
44
45pub const KIND_VIDEO_SHORT: Kind = Kind::VIDEO_SHORT;
47
48pub const KIND_VIDEO_NORMAL_ADDRESSABLE: Kind = Kind::VIDEO_NORMAL_ADDRESSABLE;
50
51pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
65pub enum VideoKind {
66 Normal,
68 Short,
70 NormalAddressable,
72 ShortAddressable,
74}
75
76impl VideoKind {
77 #[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 #[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 #[must_use]
103 pub const fn is_addressable(self) -> bool {
104 matches!(self, Self::NormalAddressable | Self::ShortAddressable)
105 }
106}
107
108#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct TextTrack {
111 pub value: String,
115 pub relay_hint: Option<RelayUrl>,
117}
118
119#[derive(Debug, Clone, PartialEq, Eq)]
121pub struct Segment {
122 pub start: String,
124 pub end: String,
126 pub title: String,
128 pub thumbnail: Option<Url>,
130}
131
132#[derive(Debug, Clone, PartialEq, Eq)]
134pub struct VideoParticipant {
135 pub pubkey: PublicKey,
137 pub relay_hint: Option<RelayUrl>,
139}
140
141#[derive(Debug, Clone, PartialEq, Eq)]
144pub struct VideoOrigin {
145 pub platform: String,
147 pub external_id: String,
149 pub original_url: Option<Url>,
151 pub metadata: Option<String>,
153}
154
155#[derive(Debug, Clone, PartialEq)]
157pub struct Video {
158 pub kind: VideoKind,
160 pub identifier: Option<String>,
162 pub content: String,
164 pub title: String,
166 pub media: Vec<MediaAttachment>,
168 pub published_at: Option<Timestamp>,
170 pub alt: Option<String>,
172 pub content_warning: Option<String>,
174 pub duration_seconds: Option<f64>,
178 pub text_tracks: Vec<TextTrack>,
180 pub segments: Vec<Segment>,
182 pub hashtags: Vec<String>,
184 pub participants: Vec<VideoParticipant>,
186 pub references: Vec<Url>,
188 pub origin: Option<VideoOrigin>,
190 pub extra_tags: Vec<Tag>,
192}
193
194impl Video {
195 #[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 #[must_use]
224 pub fn identifier(mut self, identifier: impl Into<String>) -> Self {
225 self.identifier = Some(identifier.into());
226 self
227 }
228
229 #[must_use]
231 pub fn content(mut self, content: impl Into<String>) -> Self {
232 self.content = content.into();
233 self
234 }
235
236 #[must_use]
238 pub fn media(mut self, media: MediaAttachment) -> Self {
239 self.media.push(media);
240 self
241 }
242
243 #[must_use]
245 pub const fn published_at(mut self, ts: Timestamp) -> Self {
246 self.published_at = Some(ts);
247 self
248 }
249
250 #[must_use]
252 pub fn alt(mut self, alt: impl Into<String>) -> Self {
253 self.alt = Some(alt.into());
254 self
255 }
256
257 #[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 #[must_use]
266 pub const fn duration_seconds(mut self, secs: f64) -> Self {
267 self.duration_seconds = Some(secs);
268 self
269 }
270
271 #[must_use]
273 pub fn text_track(mut self, track: TextTrack) -> Self {
274 self.text_tracks.push(track);
275 self
276 }
277
278 #[must_use]
280 pub fn segment(mut self, seg: Segment) -> Self {
281 self.segments.push(seg);
282 self
283 }
284
285 #[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 #[must_use]
294 pub fn participant(mut self, p: VideoParticipant) -> Self {
295 self.participants.push(p);
296 self
297 }
298
299 #[must_use]
301 pub fn reference(mut self, url: Url) -> Self {
302 self.references.push(url);
303 self
304 }
305
306 #[must_use]
308 pub fn origin(mut self, origin: VideoOrigin) -> Self {
309 self.origin = Some(origin);
310 self
311 }
312
313 #[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 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#[derive(Debug, Error)]
478#[non_exhaustive]
479pub enum VideoError {
480 #[error("unexpected kind for NIP-71 event: {}", .0.as_u16())]
482 WrongKind(Kind),
483 #[error("addressable NIP-71 video missing `d` identifier")]
485 MissingIdentifier,
486 #[error("NIP-71 video missing `title` tag")]
488 MissingTitle,
489 #[error("NIP-71 video missing `imeta` tag")]
491 MissingMedia,
492 #[error("`p` participant tag missing pubkey")]
494 MalformedParticipant,
495 #[error("`text-track` tag missing value")]
497 MalformedTextTrack,
498 #[error("`segment` tag missing required columns")]
500 MalformedSegment,
501 #[error("`origin` tag missing required columns")]
503 MalformedOrigin,
504 #[error("invalid `duration` value: `{0}`")]
506 InvalidDuration(String),
507 #[error(transparent)]
509 Media(#[from] MediaAttachmentError),
510 #[error(transparent)]
512 InvalidPublicKey(#[from] PublicKeyError),
513 #[error(transparent)]
515 InvalidUrl(#[from] UrlError),
516 #[error(transparent)]
518 InvalidRelayUrl(#[from] RelayUrlError),
519 #[error(transparent)]
521 InvalidTimestamp(#[from] TimestampError),
522}
523
524impl EventBuilder {
525 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}