1use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, percent_decode_str, utf8_percent_encode};
2use regex::Regex;
3use std::sync::LazyLock;
4
5static TAG_RE: LazyLock<Regex> = LazyLock::new(|| {
7 Regex::new(r#"(?x)^\s*\{\{\s*vid\s*\((?P<params>.*?)\)\s*\}\}\s*$"#)
8 .expect("Invalid TAG_RE regex pattern")
9});
10static KV_RE: LazyLock<Regex> = LazyLock::new(|| {
11 Regex::new(
14 r#"\b(?P<key>\w+)\s*=\s*["'""\u{201C}\u{201D}](?P<val>[^'""]*?)["'""\u{201C}\u{201D}]"#,
15 )
16 .expect("Invalid KV_RE regex pattern")
17});
18static EXTENSION_RE: LazyLock<Regex> = LazyLock::new(|| {
19 Regex::new(r"\.([0-9a-zA-Z]+)([?#].*)?$").expect("Invalid EXTENSION_RE regex pattern")
20});
21static TIME_RE: LazyLock<Regex> = LazyLock::new(|| {
22 Regex::new(r"^t=([0-9]+(:[0-9]+)*)([,-]([0-9]+(:[0-9]+)*))?$")
23 .expect("Invalid TIME_RE regex pattern")
24});
25
26#[derive(Debug, PartialEq, Default)]
27pub struct Vid {
28 pub url: String,
29 pub ext: Option<String>,
30 pub start: Option<String>,
31 pub end: Option<String>,
32 pub caption: Option<String>,
33}
34
35impl Vid {
36 pub fn from_url_and_title(url: &str, title: &str) -> Option<Self> {
37 let (start, end, url) = Self::start_stop_from_url(url);
38 let ext = Self::extension_from_url(url);
39 match ext.as_deref() {
40 Some("mp4") | Some("mpg") | Some("avi") | Some("ogv") | Some("ogg") | Some("m4v")
41 | Some("mkv") | Some("mov") | Some("m3u8") => Some(Self {
42 url: url.to_string(),
43 ext,
44 start,
45 end,
46 caption: Some(title.to_string()),
47 }),
48 _ => None,
49 }
50 }
51
52 pub fn from_vid(input: &str) -> Option<Self> {
53 let caps = TAG_RE.captures(input)?;
57 let params_str = &caps["params"];
58
59 let mut vid: Vid = Default::default();
60 let mut path: Option<String> = None;
61
62 for kv in KV_RE.captures_iter(params_str) {
63 let key = &kv["key"];
64 let val = &kv["val"];
65 match key {
66 "path" => path = Some(val.to_string()),
67 "start" => vid.start = Some(val.to_string()),
68 "end" => vid.end = Some(val.to_string()),
69 "caption" => vid.caption = Some(val.to_string()),
70 _ => { }
71 }
72 }
73
74 const CUSTOM_ENCODE_SET: &AsciiSet =
75 &NON_ALPHANUMERIC.remove(b'.').remove(b'/').remove(b'?');
76
77 match path {
78 Some(p) => {
79 let p = Self::normalize_smart_quotes(&p);
80 vid.url = utf8_percent_encode(format!("/videos/{p}").as_str(), CUSTOM_ENCODE_SET)
81 .to_string();
82 vid.ext = Self::extension_from_url(&vid.url);
83 Some(vid)
84 }
85 None => None,
86 }
87 }
88
89 pub fn to_mime_type(&self) -> String {
90 match self.ext.as_deref() {
91 Some("m4v") => "video/mpeg".to_string(),
92 Some("m3u8") => "application/vnd.apple.mpegurl".to_string(),
93 Some("mov") => "video/quicktime".to_string(),
94 Some("avi") => "video/x-msvideo".to_string(),
95 Some("ogg") | Some("ogv") => "video/ogg".to_string(),
96 Some(ext) => format!("video/{ext}"),
97 None => "x".to_string(),
98 }
99 }
100
101 pub fn to_html(&self, open_only: bool, server_mode: bool, transcode_enabled: bool) -> String {
122 let mut time = "".to_string();
123 if let Some(start) = self.start.as_ref() {
124 time = format!("#t={}", Self::time_str_to_seconds(start));
125 if let Some(end) = self.end.as_ref() {
126 time = format!("{},{}", time, Self::time_str_to_seconds(end));
127 }
128 }
129
130 let media_url = format!("{}{}", self.url, time);
134 let src_attr = html_escape::encode_quoted_attribute(&media_url);
135 let url_attr = html_escape::encode_quoted_attribute(&self.url);
136 let mime = self.to_mime_type();
137 let mime_attr = html_escape::encode_quoted_attribute(&mime);
138
139 let sources = if server_mode && transcode_enabled {
141 let hls_mime = "application/vnd.apple.mpegurl";
150
151 format!(
152 r#"<source src='{src_attr}' media="(min-width: 1280px)" type="{mime_attr}">
153 <source src='{url_attr}-720p.m3u8' media="(min-width: 640px)" type="{hls_mime}">
154 <source src='{url_attr}-480p.m3u8' type="{hls_mime}">
155 <source src='{src_attr}' type="{mime_attr}">"#,
156 )
157 } else {
158 format!("<source src='{src_attr}' type='{mime_attr}'>")
160 };
161
162 let caption = self
163 .caption
164 .clone()
165 .unwrap_or_else(|| Self::fallback_caption(&self.url));
166 let caption_text = html_escape::encode_text(&caption);
167 let start_attr = html_escape::encode_quoted_attribute(self.start.as_deref().unwrap_or(""));
168 let end_attr = html_escape::encode_quoted_attribute(self.end.as_deref().unwrap_or(""));
169 let close = if open_only {
170 String::new()
171 } else {
172 Self::html_close()
173 };
174
175 format!(
176 r#"
177 <figure>
178 <video controls preload="none" playsinline poster="{url_attr}.cover.jpg">
179 {sources}
180 <track kind="captions" label="English captions" src="{url_attr}.captions.en.vtt" srclang="en" language="en-US" default type="vtt" data-type="vtt" />
181 <track kind="chapters" language="en-US" label="Chapters" src="{url_attr}.chapters.en.vtt" srclang="en" default type="vtt" data-type="vtt" />
182 </video>
183 <figcaption>
184 <mbr-video-extras src='{url_attr}' start='{start_attr}' end='{end_attr}'></mbr-video-extras>
185 {caption_text}
186 {close}
187 "#,
188 )
189 }
190
191 pub fn html_close() -> String {
192 "</figcaption></figure>".to_string()
193 }
194
195 fn fallback_caption(url: &str) -> String {
199 let filename = url.rsplit('/').next().unwrap_or(url);
200 let stem = match filename.rsplit_once('.') {
201 Some((base, _)) => base,
202 None => filename,
203 };
204 let decoded = percent_decode_str(stem).decode_utf8_lossy();
205 decoded.replace(['-', '_'], " ")
206 }
207
208 fn extension_from_url(url: &str) -> Option<String> {
209 EXTENSION_RE.captures(url).map(|cap| cap[1].to_string())
210 }
211
212 fn normalize_smart_quotes(path: &str) -> String {
223 path.replace(['\u{2018}', '\u{2019}'], "'")
224 .replace(['\u{201C}', '\u{201D}'], "\"")
225 }
226
227 fn time_str_to_seconds(time: &str) -> String {
231 let parts: Vec<&str> = time.split(':').collect();
232 match parts.len() {
233 1 => time.to_string(),
234 2 => {
235 let minutes: f64 = parts[0].parse().unwrap_or(0.0);
236 let seconds: f64 = parts[1].parse().unwrap_or(0.0);
237 let total = minutes * 60.0 + seconds;
238 if total.fract() == 0.0 {
239 format!("{}", total as u64)
240 } else {
241 format!("{total}")
242 }
243 }
244 3 => {
245 let hours: f64 = parts[0].parse().unwrap_or(0.0);
246 let minutes: f64 = parts[1].parse().unwrap_or(0.0);
247 let seconds: f64 = parts[2].parse().unwrap_or(0.0);
248 let total = hours * 3600.0 + minutes * 60.0 + seconds;
249 if total.fract() == 0.0 {
250 format!("{}", total as u64)
251 } else {
252 format!("{total}")
253 }
254 }
255 _ => time.to_string(),
256 }
257 }
258
259 fn start_stop_from_url(url: &str) -> (Option<String>, Option<String>, &str) {
274 match url.rsplit_once('#') {
275 Some((base, fragment)) => match TIME_RE.captures(fragment) {
276 Some(cap) => (
277 cap.get(1).map(|t| t.as_str().to_string()),
278 cap.get(4).map(|t| t.as_str().to_string()),
279 base,
280 ),
281 None => (None, None, base),
282 },
283 None => (None, None, url),
284 }
285 }
286}
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291
292 #[test]
293 fn test_from_url_and_title_valid() {
294 let url = "video.mp4";
295 let title = "A video";
296 let vid = Vid::from_url_and_title(url, title).unwrap();
297 assert_eq!(vid.url, url);
298 assert_eq!(vid.ext.as_deref(), Some("mp4"));
299 assert_eq!(vid.caption.as_deref(), Some(title));
300 assert!(vid.start.is_none());
301 assert!(vid.end.is_none());
302 }
303
304 #[test]
305 fn test_from_url_and_title_invalid() {
306 let url = "image.png";
307 let title = "Not a video";
308 assert!(Vid::from_url_and_title(url, title).is_none());
309 }
310
311 #[test]
312 fn test_from_url_and_title_mov() {
313 let url = "video.mov";
314 let title = "QuickTime video";
315 let vid = Vid::from_url_and_title(url, title).unwrap();
316 assert_eq!(vid.url, url);
317 assert_eq!(vid.ext.as_deref(), Some("mov"));
318 assert_eq!(vid.caption.as_deref(), Some(title));
319 assert_eq!(vid.to_mime_type(), "video/quicktime");
320 }
321
322 #[test]
323 fn test_from_url_and_title_with_time() {
324 let url = "video.mp4#t=10,20";
325 let title = "Timed video";
326 let vid = Vid::from_url_and_title(url, title).unwrap();
327 assert_eq!(vid.url, "video.mp4");
328 assert_eq!(vid.ext.as_deref(), Some("mp4"));
329 assert_eq!(vid.start.as_deref(), Some("10"));
330 assert_eq!(vid.end.as_deref(), Some("20"));
331 }
332
333 #[test]
334 fn test_from_vid_valid() {
335 let input = r#"{{ vid(path="foo.mp4", start="10", end="20", caption="Test") }}"#;
336 let vid = Vid::from_vid(input).unwrap();
337 assert!(vid.url.contains("/videos/foo.mp4"));
338 assert_eq!(vid.start.as_deref(), Some("10"));
339 assert_eq!(vid.end.as_deref(), Some("20"));
340 assert_eq!(vid.caption.as_deref(), Some("Test"));
341 }
342
343 #[test]
344 fn test_from_vid_normalizes_curly_apostrophe() {
345 let input = "{{ vid(path=\"World\u{2019}s Best.mp4\") }}";
347 let vid = Vid::from_vid(input).unwrap();
348 assert!(vid.url.contains("World%27s%20Best.mp4"));
349 assert!(!vid.url.contains("%E2%80%99"));
350 }
351
352 #[test]
353 fn test_from_vid_invalid() {
354 let input = r#"{{ notvid(path="foo.mp4") }}"#;
355 assert!(Vid::from_vid(input).is_none());
356 }
357
358 #[test]
359 fn test_from_vid_missing_path() {
360 let input = r#"{{ vid(caption="No path") }}"#;
361 assert!(Vid::from_vid(input).is_none());
362 }
363
364 #[test]
365 fn test_to_html() {
366 let vid = Vid {
367 url: "/videos/foo.mp4".to_string(),
368 ext: Some("mp4".to_string()),
369 start: Some("10".to_string()),
370 end: Some("20".to_string()),
371 caption: Some("Caption".to_string()),
372 };
373 let html = vid.to_html(false, false, false);
374 assert!(html.contains("<video"));
375 assert!(html.contains("src='/videos/foo.mp4#t=10,20'"));
376 assert!(html.contains("Caption"));
377 assert!(html.contains(
378 "<mbr-video-extras src='/videos/foo.mp4' start='10' end='20'></mbr-video-extras>"
379 ));
380 assert!(html.contains("</figcaption></figure>"));
381 let extras_pos = html.find("<mbr-video-extras").unwrap();
383 let caption_pos = html.find("Caption").unwrap();
384 assert!(
385 extras_pos < caption_pos,
386 "extras should appear before caption text"
387 );
388 }
389
390 #[test]
391 fn test_to_html_with_transcode_enabled() {
392 let vid = Vid {
393 url: "/videos/foo.mp4".to_string(),
394 ext: Some("mp4".to_string()),
395 start: Some("10".to_string()),
396 end: Some("20".to_string()),
397 caption: Some("Caption".to_string()),
398 };
399 let html = vid.to_html(false, true, true);
401 assert!(html.contains("<video"));
402 assert!(html.contains(r#"src='/videos/foo.mp4#t=10,20' media="(min-width: 1280px)""#));
404 assert!(html.contains(r#"src='/videos/foo.mp4-720p.m3u8' media="(min-width: 640px)""#));
407 assert!(html.contains(r#"type="application/vnd.apple.mpegurl""#));
408 assert!(html.contains("src='/videos/foo.mp4-480p.m3u8'"));
410 assert_eq!(
413 html.matches("/videos/foo.mp4#t=10,20").count(),
414 2,
415 "Original MP4 should appear twice: once for wide screens, once as fallback"
416 );
417 assert!(html.contains("Caption"));
418 }
419
420 #[test]
421 fn test_to_html_transcode_requires_server_mode() {
422 let vid = Vid {
423 url: "/videos/foo.mp4".to_string(),
424 ext: Some("mp4".to_string()),
425 start: None,
426 end: None,
427 caption: None,
428 };
429 let html = vid.to_html(false, false, true);
431 assert!(!html.contains("-720p.m3u8"));
433 assert!(!html.contains("-480p.m3u8"));
434 assert!(html.contains("src='/videos/foo.mp4'"));
435 assert!(html.contains("foo"));
437 }
438
439 #[test]
440 fn test_to_html_hls_urls_keep_source_extension() {
441 let vid = Vid {
444 url: "/videos/interview.mov".to_string(),
445 ext: Some("mov".to_string()),
446 start: None,
447 end: None,
448 caption: None,
449 };
450 let html = vid.to_html(false, true, true);
451
452 assert!(html.contains("src='/videos/interview.mov-720p.m3u8'"));
453 assert!(html.contains("src='/videos/interview.mov-480p.m3u8'"));
454 assert!(
455 !html.contains("interview-720p.m3u8"),
456 "the extension-stripped URL collides with a same-stem .mp4 sibling"
457 );
458 }
459
460 #[test]
461 fn test_to_html_escapes_hostile_destination() {
462 let vid = Vid {
465 url: r#"a"onerror="alert(1)"b.mp4"#.to_string(),
466 ext: Some("mp4".to_string()),
467 start: None,
468 end: None,
469 caption: Some("Safe caption".to_string()),
470 };
471 let html = vid.to_html(false, false, false);
472
473 assert!(
474 html.contains("""),
475 "quotes in the destination must be escaped: {html}"
476 );
477 assert!(
481 !html.contains(r#""onerror=""#),
482 "an escaped destination cannot introduce an event handler: {html}"
483 );
484 }
485
486 #[test]
487 fn test_to_html_hostile_destination_stays_inside_attributes() {
488 let vid = Vid {
492 url: r#"a"onerror="alert(1)"b.mp4"#.to_string(),
493 ext: Some("mp4".to_string()),
494 start: None,
495 end: None,
496 caption: None,
497 };
498 let html = vid.to_html(false, false, false);
499
500 let caption_start = html.find("<figcaption>").expect("figcaption is emitted");
501 assert!(
502 !html[..caption_start].contains(r#""onerror"#),
503 "no tag attribute may carry an unescaped quote: {html}"
504 );
505 }
506
507 #[test]
508 fn test_to_html_escapes_single_quote_in_destination() {
509 let vid = Vid {
512 url: "/videos/Rubik's Cube.mp4".to_string(),
513 ext: Some("mp4".to_string()),
514 start: None,
515 end: None,
516 caption: None,
517 };
518 let html = vid.to_html(false, false, false);
519
520 assert!(
521 html.contains("'"),
522 "apostrophe must be escaped: {html}"
523 );
524 assert!(!html.contains("src='/videos/Rubik's"));
525 }
526
527 #[test]
528 fn test_to_html_escapes_ampersand_in_filename() {
529 let vid = Vid {
531 url: "/videos/Q&A.mp4".to_string(),
532 ext: Some("mp4".to_string()),
533 start: None,
534 end: None,
535 caption: None,
536 };
537 let html = vid.to_html(false, false, false);
538
539 assert!(html.contains("/videos/Q&A.mp4"));
540 assert!(
541 !html.contains("Q&A.mp4"),
542 "no unescaped ampersand may reach the output: {html}"
543 );
544 }
545
546 #[test]
547 fn test_to_html_escapes_caption() {
548 let vid = Vid {
549 url: "/videos/foo.mp4".to_string(),
550 ext: Some("mp4".to_string()),
551 start: None,
552 end: None,
553 caption: Some("<script>alert(1)</script> & more".to_string()),
554 };
555 let html = vid.to_html(false, false, false);
556
557 assert!(html.contains("<script>"));
558 assert!(html.contains("& more"));
559 assert!(!html.contains("<script>"));
560 }
561
562 #[test]
563 fn test_to_html_no_caption_uses_fallback() {
564 let vid = Vid {
565 url: "/videos/my-cool-video.mp4".to_string(),
566 ext: Some("mp4".to_string()),
567 start: None,
568 end: None,
569 caption: None,
570 };
571 let html = vid.to_html(false, false, false);
572 assert!(
573 html.contains("my cool video"),
574 "fallback caption should replace hyphens with spaces"
575 );
576 }
577
578 #[test]
579 fn test_fallback_caption_simple() {
580 assert_eq!(Vid::fallback_caption("/videos/foo.mp4"), "foo");
581 }
582
583 #[test]
584 fn test_fallback_caption_hyphens_and_underscores() {
585 assert_eq!(
586 Vid::fallback_caption("/videos/my-cool_video.mp4"),
587 "my cool video"
588 );
589 }
590
591 #[test]
592 fn test_fallback_caption_url_encoded() {
593 assert_eq!(
594 Vid::fallback_caption("/videos/Rubik%27s%20Cube.mp4"),
595 "Rubik's Cube"
596 );
597 }
598
599 #[test]
600 fn test_fallback_caption_subdirectory() {
601 assert_eq!(
602 Vid::fallback_caption("/videos/Eric%20Jones/Eric%20Jones%20-%20Metal%203.mp4"),
603 "Eric Jones Metal 3"
604 );
605 }
606
607 #[test]
608 fn test_extension_from_url() {
609 assert_eq!(
610 Vid::extension_from_url("foo/bar/video.mp4"),
611 Some("mp4".to_string())
612 );
613 assert_eq!(
614 Vid::extension_from_url("foo/bar/video.mp4?query=1"),
615 Some("mp4".to_string())
616 );
617 assert_eq!(Vid::extension_from_url("foo/bar/video"), None);
618 }
619
620 #[test]
621 fn test_mimetype_from_url() {
622 let title = "Whatever";
623 let url = "x/y/video.mp4#t=10,20";
624 let vid = Vid::from_url_and_title(url, title).unwrap();
625 let url2 = "x/y/video.ogv#t=10,20";
626 let vid2 = Vid::from_url_and_title(url2, title).unwrap();
627
628 assert_eq!(vid.to_mime_type(), "video/mp4");
629 assert_eq!(vid2.to_mime_type(), "video/ogg");
630 }
631
632 #[test]
633 fn test_start_stop_from_url() {
634 let (start, end, url) = Vid::start_stop_from_url("foo.mp4#t=10,20");
635 assert_eq!(start, Some("10".to_string()));
636 assert_eq!(end, Some("20".to_string()));
637 assert_eq!(url, "foo.mp4");
638
639 let (start, end, url) = Vid::start_stop_from_url("foo.mp4#t=10:10:10,20:20:20");
640 assert_eq!(start, Some("10:10:10".to_string()));
641 assert_eq!(end, Some("20:20:20".to_string()));
642 assert_eq!(url, "foo.mp4");
643
644 let (start, end, url) = Vid::start_stop_from_url("foo.mp4#t=10");
645 assert_eq!(start, Some("10".to_string()));
646 assert!(end.is_none());
647 assert_eq!(url, "foo.mp4");
648
649 let (start, end, url) = Vid::start_stop_from_url("foo.mp4");
650 assert!(start.is_none());
651 assert!(end.is_none());
652 assert_eq!(url, "foo.mp4");
653 }
654
655 #[test]
656 fn test_start_stop_from_url_strips_unparseable_fragment() {
657 let (start, end, url) = Vid::start_stop_from_url("foo.mp4#nonsense");
661 assert!(start.is_none());
662 assert!(end.is_none());
663 assert_eq!(url, "foo.mp4");
664 }
665
666 #[test]
667 fn test_start_stop_from_url_hyphen_separator() {
668 let (start, end, url) = Vid::start_stop_from_url("foo.mp4#t=10-20");
672 assert_eq!(start, Some("10".to_string()));
673 assert_eq!(end, Some("20".to_string()));
674 assert_eq!(url, "foo.mp4");
675
676 let (start, end, url) = Vid::start_stop_from_url("foo.mp4#t=1:14:43-1:15:55");
677 assert_eq!(start, Some("1:14:43".to_string()));
678 assert_eq!(end, Some("1:15:55".to_string()));
679 assert_eq!(url, "foo.mp4");
680 }
681
682 #[test]
683 fn test_start_stop_from_url_garrett_thomas_regression() {
684 let vid = Vid::from_url_and_title(
690 "/videos/Garrett%20Thomas/gt-opus-hd.mp4#t=4483-4555",
691 "caption",
692 )
693 .unwrap();
694 assert_eq!(vid.start.as_deref(), Some("4483"));
695 assert_eq!(vid.end.as_deref(), Some("4555"));
696 assert!(
697 !vid.url.contains('#'),
698 "no fragment should survive into the base URL: {}",
699 vid.url
700 );
701 }
702
703 #[test]
704 fn test_to_html_unparseable_fragment_does_not_leak_into_derived_attrs() {
705 let (_, _, base) = Vid::start_stop_from_url("/videos/foo.mp4#garbage");
714 let vid = Vid {
715 url: base.to_string(),
716 ext: Some("mp4".to_string()),
717 start: None,
718 end: None,
719 caption: Some("Caption".to_string()),
720 };
721 let html = vid.to_html(false, false, false);
722
723 let poster = html
724 .split("poster=\"")
725 .nth(1)
726 .and_then(|s| s.split('"').next())
727 .expect("poster attribute present");
728 assert!(!poster.contains('#'), "poster leaked a fragment: {poster}");
729
730 for track_src in html
731 .split("<track")
732 .skip(1)
733 .filter_map(|s| s.split("src=\"").nth(1).and_then(|s| s.split('"').next()))
734 {
735 assert!(
736 !track_src.contains('#'),
737 "track src leaked a fragment: {track_src}"
738 );
739 }
740
741 let extras_src = html
742 .split("<mbr-video-extras src='")
743 .nth(1)
744 .and_then(|s| s.split('\'').next())
745 .expect("mbr-video-extras src attribute present");
746 assert!(
747 !extras_src.contains('#'),
748 "mbr-video-extras src leaked a fragment: {extras_src}"
749 );
750 }
751
752 #[test]
753 fn test_time_str_to_seconds_plain() {
754 assert_eq!(Vid::time_str_to_seconds("30"), "30");
755 assert_eq!(Vid::time_str_to_seconds("200"), "200");
756 assert_eq!(Vid::time_str_to_seconds("0"), "0");
757 }
758
759 #[test]
760 fn test_time_str_to_seconds_mmss() {
761 assert_eq!(Vid::time_str_to_seconds("0:30"), "30");
762 assert_eq!(Vid::time_str_to_seconds("3:20"), "200");
763 assert_eq!(Vid::time_str_to_seconds("1:00"), "60");
764 assert_eq!(Vid::time_str_to_seconds("10:05"), "605");
765 }
766
767 #[test]
768 fn test_time_str_to_seconds_hhmmss() {
769 assert_eq!(Vid::time_str_to_seconds("1:02:30"), "3750");
770 assert_eq!(Vid::time_str_to_seconds("0:00:30"), "30");
771 assert_eq!(Vid::time_str_to_seconds("2:00:00"), "7200");
772 }
773
774 #[test]
775 fn test_time_str_to_seconds_fractional() {
776 assert_eq!(Vid::time_str_to_seconds("1:30.5"), "90.5");
777 assert_eq!(Vid::time_str_to_seconds("0:0:30.5"), "30.5");
778 }
779
780 #[test]
781 fn test_to_html_normalizes_colon_times_to_seconds() {
782 let vid = Vid {
783 url: "/videos/foo.mp4".to_string(),
784 ext: Some("mp4".to_string()),
785 start: Some("0:30".to_string()),
786 end: Some("3:20".to_string()),
787 caption: Some("Caption".to_string()),
788 };
789 let html = vid.to_html(false, false, false);
790 assert!(html.contains("src='/videos/foo.mp4#t=30,200'"));
792 assert!(html.contains("start='0:30'"));
794 assert!(html.contains("end='3:20'"));
795 }
796}
797
798#[cfg(test)]
799mod markdown_integration_tests {
800 use super::*;
801
802 #[test]
803 fn test_from_vid_with_spaces_in_path() {
804 let input = r#"{{ vid(path="Eric Jones/Eric Jones - Metal 3.mp4")}}"#;
805 let vid = Vid::from_vid(input).unwrap();
806 println!("URL: {}", vid.url);
807 assert!(vid.url.contains("/videos/"));
808 assert!(vid.url.contains("Eric%20Jones")); }
810}