Skip to main content

mbr_core/
vid.rs

1use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, percent_decode_str, utf8_percent_encode};
2use regex::Regex;
3use std::sync::LazyLock;
4
5// Compile regexes once at startup
6static 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    // Match key="value" pairs, supporting both straight quotes (") and
12    // curly/smart quotes (" " U+201C/U+201D) from pulldown-cmark's smart punctuation
13    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        // 1) match the whole tag {{ vid( … ) }}
54        // 2) capture everything inside the parens as "params"
55        // 3) match individual key="value" pairs
56        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                _ => { /* ignore unknown keys */ }
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    /// Generate HTML for video embed.
102    ///
103    /// - `open_only`: When true, leaves figcaption open for markdown parser to fill
104    /// - `server_mode`: True in server/GUI mode, false in build/CLI mode
105    /// - `transcode_enabled`: True when dynamic transcoding is enabled
106    ///
107    /// When both `server_mode` and `transcode_enabled` are true, generates multiple
108    /// `<source>` tags with media queries for responsive video loading using HLS:
109    /// - Original MP4 for wide screens (>= 1280px) - all browsers
110    /// - HLS 720p for medium screens (>= 640px) - Safari only (native HLS)
111    /// - HLS 480p as fallback for small screens - Safari only
112    /// - Original MP4 as final fallback (no media query) - Chrome/Firefox/Edge on mobile
113    ///
114    /// Note: Time fragments (#t=start,end) only apply to MP4 sources, not HLS.
115    ///
116    /// Every interpolated value is HTML-escaped. The result is emitted as a raw
117    /// `Event::Html` that `html.rs` writes verbatim, and pulldown-cmark hands
118    /// back an image destination exactly as authored — so a destination such as
119    /// `a"onerror="alert(1)"b.mp4` would otherwise break out of the attribute,
120    /// and an everyday `Q&A.mp4` would emit a bare ampersand.
121    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        // `encode_quoted_attribute` escapes both quote characters, so one
131        // encoding is correct for the single- and double-quoted attributes
132        // below.
133        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        // Build source tags based on transcode mode
140        let sources = if server_mode && transcode_enabled {
141            // Generate multiple sources with media queries for responsive loading
142            // HLS variants for Safari, MP4 fallback for other browsers.
143            //
144            // HLS variant URLs *append* to the full video URL, extension
145            // included (`interview.mov-720p.m3u8`). Stripping the extension
146            // would make `interview.mov` and `interview.mp4` share one URL,
147            // and `video_transcode::parse_hls_request` could not tell which
148            // file to transcode back from it.
149            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            // Single source - original behavior
159            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    /// Derive a human-readable caption from the video URL when no explicit caption is provided.
196    /// Extracts the filename, strips the extension, URL-decodes, and replaces hyphens/underscores
197    /// with spaces.
198    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    /// Reverse pulldown-cmark's smart-punctuation quote substitutions on a path.
213    ///
214    /// With ENABLE_SMART_PUNCTUATION active, straight quotes typed in a
215    /// `{{ vid(path="...") }}` shortcode are rewritten to Unicode curly quotes
216    /// before the shortcode is parsed. Filenames on disk use straight ASCII
217    /// quotes, so map curly quotes back before percent-encoding the URL.
218    ///
219    /// Only quotes are reversed: dashes (`--`/`---`) and ellipsis (`...`) are
220    /// left alone because a filename may legitimately contain a literal en/em-dash,
221    /// and reversing those would corrupt the path.
222    fn normalize_smart_quotes(path: &str) -> String {
223        path.replace(['\u{2018}', '\u{2019}'], "'")
224            .replace(['\u{201C}', '\u{201D}'], "\"")
225    }
226
227    /// Convert a time string like "0:30", "1:02:30", or "200" to total seconds as a string.
228    /// Plain numeric values pass through unchanged. Colon-separated values (MM:SS or HH:MM:SS)
229    /// are converted to total seconds for maximum browser compatibility with media fragments.
230    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    /// Split a `#...` fragment off the base URL and, if present, parse it as a
260    /// media time range.
261    ///
262    /// The fragment is stripped **unconditionally** whenever one is present —
263    /// even if it doesn't parse as `t=START[,END]` (or the hyphen-separated
264    /// `t=START-END` variant). Coupling the strip to a successful parse left a
265    /// malformed fragment (e.g. a dash instead of a comma) attached to the
266    /// base URL; every derived attribute built by string concatenation in
267    /// `to_html` (`.cover.jpg`, `.captions.en.vtt`, `.chapters.en.vtt`,
268    /// `-720p.m3u8`) then had that fragment appended *after* it, i.e. inside
269    /// the URL fragment rather than the path. A URL fragment is never sent in
270    /// an HTTP request, so those sidecar fetches actually requested the raw
271    /// video file itself, which the frontend then tried to parse as text —
272    /// pinning the CPU and hanging the page. See `test_to_html_...` below.
273    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        // Simulates text after pulldown-cmark smart punctuation has curled the quote.
346        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        // mbr-video-extras comes before caption text inside figcaption
382        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        // With server_mode=true and transcode_enabled=true, should generate HLS sources
400        let html = vid.to_html(false, true, true);
401        assert!(html.contains("<video"));
402        // Original MP4 source with media query for wide screens
403        assert!(html.contains(r#"src='/videos/foo.mp4#t=10,20' media="(min-width: 1280px)""#));
404        // HLS 720p variant (no time fragment - HLS doesn't support it). The
405        // source extension is kept so the URL identifies exactly one file.
406        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        // HLS 480p variant (no media query - smallest HLS)
409        assert!(html.contains("src='/videos/foo.mp4-480p.m3u8'"));
410        // MP4 fallback (no media query) for non-Safari browsers
411        // Count the number of times the original MP4 appears (should be twice)
412        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        // transcode_enabled=true but server_mode=false should NOT generate multiple sources
430        let html = vid.to_html(false, false, true);
431        // Should only have single source (original behavior)
432        assert!(!html.contains("-720p.m3u8"));
433        assert!(!html.contains("-480p.m3u8"));
434        assert!(html.contains("src='/videos/foo.mp4'"));
435        // No caption provided, so fallback to filename
436        assert!(html.contains("foo"));
437    }
438
439    #[test]
440    fn test_to_html_hls_urls_keep_source_extension() {
441        // A `.mov` embed must not be handed `.mp4`'s transcode: the variant
442        // URLs append to the full file name so each source has its own URLs.
443        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        // pulldown-cmark hands back `![x](a"onerror="alert(1)"b.mp4)` verbatim,
463        // and html.rs writes this string out unmodified.
464        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("&quot;"),
475            "quotes in the destination must be escaped: {html}"
476        );
477        // `&quot;onerror=&quot;` is inert: the quotes that would have closed
478        // the attribute and started a handler are entities. The raw sequence
479        // must not survive anywhere.
480        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        // With no caption the destination is also echoed as figcaption *text*,
489        // where quotes are harmless. What must not happen is the raw form
490        // appearing inside a tag, where it would close an attribute.
491        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        // The `<source>`/`<mbr-video-extras>` attributes are single-quoted, so
510        // an apostrophe in a filename must not terminate them.
511        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("&#x27;"),
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        // Plain output correctness: a bare `&` is not valid in an attribute.
530        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&amp;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("&lt;script&gt;"));
558        assert!(html.contains("&amp; 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        // A fragment that isn't a valid `t=...` expression must still be
658        // stripped from the base URL, not left attached: this is the actual
659        // bug fix. Regression test for the double-video hang.
660        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        // Hyphen is now accepted as an equivalent separator to comma, since
669        // it's an easy, natural mistake to make and previously silently
670        // corrupted the URL instead of failing loudly.
671        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        // Exact case reported in the field: a `#t=START-END` (hyphen)
685        // fragment on a real path with percent-encoded spaces. Before the
686        // fix, `start_stop_from_url` returned the URL untouched (fragment
687        // still attached), which corrupted every derived sidecar URL built
688        // by concatenation in `to_html` and hung the GUI window.
689        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        // Pins the hang fix at the to_html level: a fragment that fails to
706        // parse as a time expression must not survive into any of the
707        // derived attributes built by string concatenation onto `self.url`.
708        // A `#` surviving into `poster=`, `<track src=` or
709        // `<mbr-video-extras src=` means those requests actually fetch the
710        // raw video file (fragments are never sent over HTTP) instead of the
711        // small sidecar file, which is exactly what pinned the CPU and hung
712        // the page in the field.
713        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        // Source tag should have seconds
791        assert!(html.contains("src='/videos/foo.mp4#t=30,200'"));
792        // mbr-video-extras should preserve original human-readable format
793        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")); // spaces should be URL-encoded
809    }
810}