Skip to main content

mbr_core/
media.rs

1//! Media embedding detection and HTML generation for image syntax extensions.
2//!
3//! This module handles the `![caption](url)` markdown syntax when the URL points to
4//! media files (video, audio, PDF) or embeddable content (YouTube).
5//!
6//! It also owns [`MediaViewerType`], the kind of `/.mbr/{videos,pdfs,audio,images}/`
7//! viewer page a media file opens in, shared by the server, the static build and
8//! launch-URL construction.
9
10use crate::audio::Audio;
11use crate::vid::Vid;
12use regex::Regex;
13use std::borrow::Cow;
14use std::sync::LazyLock;
15
16const PDF_EMBED_HEIGHT: &str = "600px";
17
18static EXTENSION_RE: LazyLock<Regex> = LazyLock::new(|| {
19    Regex::new(r"\.([0-9a-zA-Z]+)([?#].*)?$").expect("Invalid EXTENSION_RE regex pattern")
20});
21
22static YOUTUBE_RE: LazyLock<Regex> = LazyLock::new(|| {
23    Regex::new(
24        r"(?:youtube(?:-nocookie)?\.com/watch\?.*v=|youtu\.be/|youtube(?:-nocookie)?\.com/embed/|youtube(?:-nocookie)?\.com/v/)([a-zA-Z0-9_-]{11})",
25    )
26    .expect("Invalid YOUTUBE_RE regex pattern")
27});
28
29/// Represents different types of media that can be embedded via image syntax
30#[derive(Debug, PartialEq)]
31pub enum MediaEmbed {
32    /// Video files (mp4, webm, etc.) - uses HTML5 video
33    Video(Vid),
34    /// Audio files (mp3, wav, etc.) - uses HTML5 audio
35    Audio(Audio),
36    /// YouTube videos - uses iframe embed
37    YouTube {
38        video_id: String,
39        caption: Option<String>,
40    },
41    /// PDF documents - uses object tag with fallback link
42    Pdf {
43        url: String,
44        caption: Option<String>,
45    },
46}
47
48impl MediaEmbed {
49    /// Create a MediaEmbed for bare URLs (no caption)
50    ///
51    /// This is used by the oembed system to detect media files from bare URLs
52    /// in markdown text before attempting OpenGraph fetching.
53    pub fn from_bare_url(url: &str) -> Option<Self> {
54        Self::from_url_and_title(url, "")
55    }
56
57    /// Try to detect media type from URL and create appropriate embed
58    ///
59    /// Priority order:
60    /// 1. YouTube URLs (checked first since they might not have extensions)
61    /// 2. Video files by extension
62    /// 3. Audio files by extension
63    /// 4. PDF files by extension
64    ///
65    /// Returns None if the URL doesn't match any known media type
66    pub fn from_url_and_title(url: &str, title: &str) -> Option<Self> {
67        // Check YouTube first (doesn't rely on extension)
68        if let Some(video_id) = Self::extract_youtube_id(url) {
69            return Some(MediaEmbed::YouTube {
70                video_id,
71                caption: if title.is_empty() {
72                    None
73                } else {
74                    Some(title.to_string())
75                },
76            });
77        }
78
79        // Check by extension
80        if let Some(ext) = Self::extension_from_url(url) {
81            let ext_lower = ext.to_lowercase();
82
83            // Video extensions (handled by Vid)
84            if let Some(vid) = Vid::from_url_and_title(url, title) {
85                return Some(MediaEmbed::Video(vid));
86            }
87
88            // Audio extensions
89            if let Some(audio) = Audio::from_url_and_title(url, title) {
90                return Some(MediaEmbed::Audio(audio));
91            }
92
93            // PDF
94            if ext_lower == "pdf" {
95                return Some(MediaEmbed::Pdf {
96                    url: url.to_string(),
97                    caption: if title.is_empty() {
98                        None
99                    } else {
100                        Some(title.to_string())
101                    },
102                });
103            }
104        }
105
106        None
107    }
108
109    /// Generate opening HTML for the media embed
110    ///
111    /// - `open_only`: When true, leaves figcaption open for markdown parser to fill
112    /// - `server_mode`: True in server/GUI mode, false in build/CLI mode
113    /// - `transcode_enabled`: True when dynamic transcoding is enabled
114    pub fn to_html(&self, open_only: bool, server_mode: bool, transcode_enabled: bool) -> String {
115        match self {
116            MediaEmbed::Video(vid) => vid.to_html(open_only, server_mode, transcode_enabled),
117            MediaEmbed::Audio(audio) => audio.to_html(open_only),
118            MediaEmbed::YouTube { video_id, caption } => {
119                Self::youtube_to_html(video_id, caption.as_deref(), open_only)
120            }
121            MediaEmbed::Pdf { url, caption } => {
122                Self::pdf_to_html(url, caption.as_deref(), open_only)
123            }
124        }
125    }
126
127    /// Generate closing HTML tags
128    pub fn html_close(&self) -> String {
129        match self {
130            MediaEmbed::Video(_) => Vid::html_close(),
131            MediaEmbed::Audio(_) => Audio::html_close().to_string(),
132            MediaEmbed::YouTube { .. } | MediaEmbed::Pdf { .. } => {
133                "</figcaption></figure>".to_string()
134            }
135        }
136    }
137
138    fn extract_youtube_id(url: &str) -> Option<String> {
139        YOUTUBE_RE
140            .captures(url)
141            .and_then(|caps| caps.get(1))
142            .map(|id| id.as_str().to_string())
143    }
144
145    fn extension_from_url(url: &str) -> Option<String> {
146        EXTENSION_RE.captures(url).map(|cap| cap[1].to_string())
147    }
148
149    /// Escape a caption for HTML element-text context (`<figcaption>`).
150    ///
151    /// Captions come from the markdown link title, which pulldown-cmark hands
152    /// over unescaped.
153    fn escaped_caption(caption: Option<&str>) -> Cow<'_, str> {
154        caption.map(html_escape::encode_text).unwrap_or_default()
155    }
156
157    fn youtube_to_html(video_id: &str, caption: Option<&str>, open_only: bool) -> String {
158        format!(
159            r#"
160            <figure class="video-embed youtube-embed">
161                <iframe
162                    width="{yt_width}"
163                    height="{yt_height}"
164                    src="https://www.youtube-nocookie.com/embed/{video_id}"
165                    title="YouTube video player"
166                    frameborder="0"
167                    allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
168                    referrerpolicy="strict-origin-when-cross-origin"
169                    allowfullscreen>
170                </iframe>
171                <figcaption>{caption}{close}"#,
172            yt_width = crate::constants::YOUTUBE_EMBED_WIDTH,
173            yt_height = crate::constants::YOUTUBE_EMBED_HEIGHT,
174            video_id = html_escape::encode_double_quoted_attribute(video_id),
175            caption = Self::escaped_caption(caption),
176            close = if open_only {
177                ""
178            } else {
179                "</figcaption></figure>"
180            }
181        )
182    }
183
184    fn pdf_to_html(url: &str, caption: Option<&str>, open_only: bool) -> String {
185        // Graceful degradation: object tag with fallback download link
186        // The data-pdf-url attribute allows JavaScript enhancement (e.g., PDF.js)
187        //
188        // The URL is the raw markdown link destination (pulldown-cmark does not
189        // escape it, and neither does link_transform), so it must be escaped for
190        // double-quoted attribute context or an `&` corrupts the output and a
191        // `"` breaks out of the attribute entirely.
192        let escaped_url = html_escape::encode_double_quoted_attribute(url);
193        format!(
194            r#"
195            <figure class="pdf-embed" data-pdf-url="{url}">
196                <object data="{url}" type="application/pdf" width="100%" height="{pdf_height}">
197                    <p class="pdf-fallback">
198                        PDF cannot be displayed inline.
199                        <a href="{url}" download data-pdf-fallback>Download PDF</a>
200                    </p>
201                </object>
202                <figcaption>{caption}{close}"#,
203            url = escaped_url,
204            pdf_height = PDF_EMBED_HEIGHT,
205            caption = Self::escaped_caption(caption),
206            close = if open_only {
207                ""
208            } else {
209                "</figcaption></figure>"
210            }
211        )
212    }
213}
214
215/// Type of media for the viewer page.
216///
217/// Used to route requests to the appropriate media viewer template
218/// at `/.mbr/videos/`, `/.mbr/pdfs/`, `/.mbr/audio/`, or `/.mbr/images/`.
219#[derive(Debug, Clone, Copy, PartialEq, Eq)]
220pub enum MediaViewerType {
221    Video,
222    Pdf,
223    Audio,
224    Image,
225}
226
227impl MediaViewerType {
228    /// Parse from route path.
229    ///
230    /// # Examples
231    ///
232    /// ```ignore
233    /// assert_eq!(MediaViewerType::from_route("/.mbr/videos/"), Some(MediaViewerType::Video));
234    /// assert_eq!(MediaViewerType::from_route("/.mbr/pdfs/"), Some(MediaViewerType::Pdf));
235    /// assert_eq!(MediaViewerType::from_route("/.mbr/audio/"), Some(MediaViewerType::Audio));
236    /// assert_eq!(MediaViewerType::from_route("/.mbr/images/"), Some(MediaViewerType::Image));
237    /// assert_eq!(MediaViewerType::from_route("/some/other/path"), None);
238    /// ```
239    #[must_use]
240    pub fn from_route(path: &str) -> Option<Self> {
241        match path {
242            "/.mbr/videos/" => Some(Self::Video),
243            "/.mbr/pdfs/" => Some(Self::Pdf),
244            "/.mbr/audio/" => Some(Self::Audio),
245            "/.mbr/images/" => Some(Self::Image),
246            _ => None,
247        }
248    }
249
250    /// Template name for this media type.
251    #[must_use]
252    pub const fn template_name(&self) -> &'static str {
253        "media_viewer.html"
254    }
255
256    /// Human-readable label for this media type.
257    #[must_use]
258    pub const fn label(&self) -> &'static str {
259        match self {
260            Self::Video => "Video",
261            Self::Pdf => "PDF",
262            Self::Audio => "Audio",
263            Self::Image => "Image",
264        }
265    }
266
267    /// Lowercase string representation for template context.
268    #[must_use]
269    pub const fn as_str(&self) -> &'static str {
270        match self {
271            Self::Video => "video",
272            Self::Pdf => "pdf",
273            Self::Audio => "audio",
274            Self::Image => "image",
275        }
276    }
277
278    /// Determine media type from a file extension (case-insensitive).
279    ///
280    /// Returns `None` for unrecognized extensions.
281    #[must_use]
282    pub fn from_extension(ext: &str) -> Option<Self> {
283        match ext.to_ascii_lowercase().as_str() {
284            // Video
285            "mp4" | "m4v" | "mov" | "webm" | "flv" | "mpg" | "mpeg" | "avi" | "3gp" | "wmv"
286            | "mkv" | "ts" | "mts" | "m2ts" | "vob" | "divx" | "xvid" | "asf" | "rm" | "rmvb"
287            | "f4v" | "ogv" => Some(Self::Video),
288            // Audio
289            "mp3" | "wav" | "ogg" | "flac" | "aac" | "m4a" | "aiff" | "aif" | "oga" | "opus"
290            | "wma" => Some(Self::Audio),
291            // Image
292            "jpg" | "jpeg" | "png" | "webp" | "gif" | "bmp" | "tif" | "tiff" | "svg" => {
293                Some(Self::Image)
294            }
295            // PDF
296            "pdf" => Some(Self::Pdf),
297            _ => None,
298        }
299    }
300
301    /// Determine media type from a file path by inspecting its extension.
302    ///
303    /// Returns `None` if the path has no extension or the extension is unrecognized.
304    #[must_use]
305    pub fn from_path(path: &std::path::Path) -> Option<Self> {
306        path.extension()
307            .and_then(|ext| ext.to_str())
308            .and_then(Self::from_extension)
309    }
310
311    /// Returns the server route path for this media viewer type.
312    #[must_use]
313    pub const fn route_path(&self) -> &'static str {
314        match self {
315            Self::Video => "/.mbr/videos/",
316            Self::Pdf => "/.mbr/pdfs/",
317            Self::Audio => "/.mbr/audio/",
318            Self::Image => "/.mbr/images/",
319        }
320    }
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326    use std::path::Path;
327
328    // YouTube detection tests
329    #[test]
330    fn test_youtube_watch_url() {
331        let embed =
332            MediaEmbed::from_url_and_title("https://www.youtube.com/watch?v=dQw4w9WgXcQ", "Title");
333        assert!(matches!(
334            embed,
335            Some(MediaEmbed::YouTube { video_id, .. }) if video_id == "dQw4w9WgXcQ"
336        ));
337    }
338
339    #[test]
340    fn test_youtube_short_url() {
341        let embed = MediaEmbed::from_url_and_title("https://youtu.be/dQw4w9WgXcQ", "");
342        assert!(matches!(
343            embed,
344            Some(MediaEmbed::YouTube { video_id, caption }) if video_id == "dQw4w9WgXcQ" && caption.is_none()
345        ));
346    }
347
348    #[test]
349    fn test_youtube_embed_url() {
350        let embed =
351            MediaEmbed::from_url_and_title("https://www.youtube.com/embed/dQw4w9WgXcQ", "Caption");
352        assert!(matches!(
353            embed,
354            Some(MediaEmbed::YouTube { video_id, caption }) if video_id == "dQw4w9WgXcQ" && caption == Some("Caption".to_string())
355        ));
356    }
357
358    #[test]
359    fn test_youtube_with_extra_params() {
360        let embed = MediaEmbed::from_url_and_title(
361            "https://www.youtube-nocookie.com/watch?v=dQw4w9WgXcQ&t=30s",
362            "",
363        );
364        assert!(matches!(
365            embed,
366            Some(MediaEmbed::YouTube { video_id, .. }) if video_id == "dQw4w9WgXcQ"
367        ));
368    }
369
370    // Video detection tests
371    #[test]
372    fn test_video_mp4() {
373        let embed = MediaEmbed::from_url_and_title("video.mp4", "My Video");
374        assert!(matches!(embed, Some(MediaEmbed::Video(_))));
375    }
376
377    #[test]
378    fn test_video_webm_not_detected_by_vid() {
379        // webm is not in Vid's list, so it won't be detected as video
380        // This is existing behavior - webm goes through as audio since Audio supports it
381        let embed = MediaEmbed::from_url_and_title("video.webm", "");
382        assert!(matches!(embed, Some(MediaEmbed::Audio(_))));
383    }
384
385    // Audio detection tests
386    #[test]
387    fn test_audio_mp3() {
388        let embed = MediaEmbed::from_url_and_title("podcast.mp3", "Episode 1");
389        assert!(matches!(embed, Some(MediaEmbed::Audio(_))));
390    }
391
392    #[test]
393    fn test_audio_wav() {
394        let embed = MediaEmbed::from_url_and_title("sound.wav", "");
395        assert!(matches!(embed, Some(MediaEmbed::Audio(_))));
396    }
397
398    // PDF detection tests
399    #[test]
400    fn test_pdf() {
401        let embed = MediaEmbed::from_url_and_title("document.pdf", "Important Doc");
402        assert!(matches!(
403            embed,
404            Some(MediaEmbed::Pdf { url, caption }) if url == "document.pdf" && caption == Some("Important Doc".to_string())
405        ));
406    }
407
408    #[test]
409    fn test_pdf_with_path() {
410        let embed = MediaEmbed::from_url_and_title("/docs/report.pdf", "");
411        assert!(matches!(
412            embed,
413            Some(MediaEmbed::Pdf { url, caption }) if url == "/docs/report.pdf" && caption.is_none()
414        ));
415    }
416
417    #[test]
418    fn test_pdf_case_insensitive() {
419        let embed = MediaEmbed::from_url_and_title("document.PDF", "");
420        assert!(matches!(embed, Some(MediaEmbed::Pdf { .. })));
421    }
422
423    // Non-media files
424    #[test]
425    fn test_image_not_detected() {
426        assert!(MediaEmbed::from_url_and_title("photo.jpg", "").is_none());
427        assert!(MediaEmbed::from_url_and_title("image.png", "").is_none());
428        assert!(MediaEmbed::from_url_and_title("graphic.gif", "").is_none());
429    }
430
431    #[test]
432    fn test_unknown_extension() {
433        assert!(MediaEmbed::from_url_and_title("file.xyz", "").is_none());
434    }
435
436    #[test]
437    fn test_no_extension() {
438        assert!(MediaEmbed::from_url_and_title("https://example.com/page", "").is_none());
439    }
440
441    // HTML generation tests
442    #[test]
443    fn test_youtube_html() {
444        let embed = MediaEmbed::YouTube {
445            video_id: "abc123xyz".to_string(),
446            caption: Some("Test Video".to_string()),
447        };
448        let html = embed.to_html(false, false, false);
449        assert!(html.contains("youtube-embed"));
450        assert!(html.contains("https://www.youtube-nocookie.com/embed/abc123xyz"));
451        assert!(html.contains("<figcaption>Test Video</figcaption>"));
452    }
453
454    #[test]
455    fn test_pdf_html() {
456        let embed = MediaEmbed::Pdf {
457            url: "/docs/test.pdf".to_string(),
458            caption: Some("My PDF".to_string()),
459        };
460        let html = embed.to_html(false, false, false);
461        assert!(html.contains("pdf-embed"));
462        assert!(html.contains(r#"data="/docs/test.pdf""#));
463        assert!(html.contains(r#"type="application/pdf""#));
464        assert!(html.contains("data-pdf-fallback"));
465        assert!(html.contains("<figcaption>My PDF</figcaption>"));
466    }
467
468    #[test]
469    fn test_pdf_html_open_only() {
470        let embed = MediaEmbed::Pdf {
471            url: "doc.pdf".to_string(),
472            caption: None,
473        };
474        let html = embed.to_html(true, false, false);
475        assert!(html.contains("<object"));
476        assert!(!html.contains("</figcaption></figure>"));
477    }
478
479    #[test]
480    fn test_youtube_v_url() {
481        let embed = MediaEmbed::from_url_and_title("https://www.youtube.com/v/dQw4w9WgXcQ", "");
482        assert!(matches!(
483            embed,
484            Some(MediaEmbed::YouTube { video_id, .. }) if video_id == "dQw4w9WgXcQ"
485        ));
486    }
487
488    #[test]
489    fn test_youtube_without_www() {
490        let embed = MediaEmbed::from_url_and_title("https://youtube.com/watch?v=dQw4w9WgXcQ", "");
491        assert!(matches!(
492            embed,
493            Some(MediaEmbed::YouTube { video_id, .. }) if video_id == "dQw4w9WgXcQ"
494        ));
495    }
496
497    #[test]
498    fn test_youtube_invalid_id_length() {
499        let embed = MediaEmbed::from_url_and_title("https://www.youtube.com/watch?v=short", "");
500        assert!(embed.is_none());
501    }
502
503    #[test]
504    fn test_youtube_not_youtube() {
505        let embed = MediaEmbed::from_url_and_title("https://example.com/watch?v=dQw4w9WgXcQ", "");
506        assert!(embed.is_none());
507    }
508
509    #[test]
510    fn test_youtube_nocookie_embed_url() {
511        let embed = MediaEmbed::from_url_and_title(
512            "https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ",
513            "Caption",
514        );
515        assert!(matches!(
516            embed,
517            Some(MediaEmbed::YouTube { video_id, caption }) if video_id == "dQw4w9WgXcQ" && caption == Some("Caption".to_string())
518        ));
519    }
520
521    #[test]
522    fn test_youtube_nocookie_v_url() {
523        let embed =
524            MediaEmbed::from_url_and_title("https://www.youtube-nocookie.com/v/dQw4w9WgXcQ", "");
525        assert!(matches!(
526            embed,
527            Some(MediaEmbed::YouTube { video_id, .. }) if video_id == "dQw4w9WgXcQ"
528        ));
529    }
530
531    /// Regression: a PDF link destination containing a double quote must not be
532    /// able to close `data-pdf-url`/`data`/`href` and inject new attributes.
533    #[test]
534    fn test_pdf_html_escapes_hostile_url() {
535        let embed =
536            MediaEmbed::from_url_and_title(r#"a"onerror="alert(1)"b.pdf"#, "").expect("pdf embed");
537        let html = embed.to_html(false, false, false);
538        assert!(
539            html.contains("&quot;"),
540            "double quotes must be escaped: {html}"
541        );
542        // The hostile destination must stay inside each attribute value; the
543        // injected text may appear only as escaped data, never as an attribute.
544        assert_eq!(
545            html.matches(r#"a&quot;onerror=&quot;alert(1)&quot;b.pdf"#)
546                .count(),
547            3,
548            "data-pdf-url, object data, and href must all be escaped: {html}"
549        );
550        assert!(
551            !html.contains(r#""onerror=""#),
552            "must not emit an injected attribute: {html}"
553        );
554    }
555
556    /// Regression: a bare `&` in a filename is invalid in an attribute value
557    /// and must be encoded in all three places the URL is interpolated.
558    #[test]
559    fn test_pdf_html_escapes_ampersand_in_url() {
560        let embed = MediaEmbed::from_url_and_title("/docs/Q&A-report.pdf", "").expect("pdf embed");
561        let html = embed.to_html(false, false, false);
562        assert!(
563            !html.contains("Q&A-report"),
564            "bare ampersand emitted: {html}"
565        );
566        assert_eq!(
567            html.matches("/docs/Q&amp;A-report.pdf").count(),
568            3,
569            "data-pdf-url, object data, and href must all be encoded: {html}"
570        );
571    }
572
573    /// Regression: captions land in element-text context and must be escaped.
574    #[test]
575    fn test_pdf_html_escapes_caption() {
576        let embed = MediaEmbed::from_url_and_title("doc.pdf", "<script>alert(1)</script> & more")
577            .expect("pdf embed");
578        let html = embed.to_html(false, false, false);
579        assert!(
580            html.contains("&lt;script&gt;alert(1)&lt;/script&gt; &amp; more"),
581            "caption must be escaped: {html}"
582        );
583        assert!(
584            !html.contains("<script>"),
585            "must not emit a raw script tag: {html}"
586        );
587    }
588
589    /// Escaping must not double-encode ordinary destinations or captions.
590    #[test]
591    fn test_pdf_html_ordinary_values_round_trip_unchanged() {
592        let embed =
593            MediaEmbed::from_url_and_title("/docs/my_report-v2.pdf", "My PDF").expect("pdf embed");
594        let html = embed.to_html(false, false, false);
595        assert!(html.contains(r#"data-pdf-url="/docs/my_report-v2.pdf""#));
596        assert!(html.contains(r#"data="/docs/my_report-v2.pdf""#));
597        assert!(html.contains(r#"href="/docs/my_report-v2.pdf""#));
598        assert!(html.contains("<figcaption>My PDF</figcaption>"));
599        assert!(!html.contains("&amp;"), "nothing to escape here: {html}");
600    }
601
602    /// Regression: the YouTube video id and caption are interpolated too.
603    #[test]
604    fn test_youtube_html_escapes_video_id_and_caption() {
605        let embed = MediaEmbed::YouTube {
606            video_id: r#"x"onload="alert(1)"#.to_string(),
607            caption: Some("<b>hi</b> & bye".to_string()),
608        };
609        let html = embed.to_html(false, false, false);
610        assert!(
611            html.contains("&quot;"),
612            "double quotes must be escaped: {html}"
613        );
614        assert!(
615            html.contains(
616                r#"src="https://www.youtube-nocookie.com/embed/x&quot;onload=&quot;alert(1)""#
617            ),
618            "iframe src must be fully escaped: {html}"
619        );
620        assert!(
621            !html.contains(r#""onload=""#),
622            "must not emit an injected attribute: {html}"
623        );
624        assert!(html.contains("&lt;b&gt;hi&lt;/b&gt; &amp; bye"));
625    }
626
627    #[test]
628    fn test_html_close() {
629        let youtube = MediaEmbed::YouTube {
630            video_id: "x".to_string(),
631            caption: None,
632        };
633        let pdf = MediaEmbed::Pdf {
634            url: "x.pdf".to_string(),
635            caption: None,
636        };
637        assert_eq!(youtube.html_close(), "</figcaption></figure>");
638        assert_eq!(pdf.html_close(), "</figcaption></figure>");
639    }
640
641    // ==================== MediaViewerType Tests ====================
642
643    #[test]
644    fn test_media_viewer_type_from_route_videos() {
645        assert_eq!(
646            MediaViewerType::from_route("/.mbr/videos/"),
647            Some(MediaViewerType::Video)
648        );
649    }
650
651    #[test]
652    fn test_media_viewer_type_from_route_pdfs() {
653        assert_eq!(
654            MediaViewerType::from_route("/.mbr/pdfs/"),
655            Some(MediaViewerType::Pdf)
656        );
657    }
658
659    #[test]
660    fn test_media_viewer_type_from_route_audio() {
661        assert_eq!(
662            MediaViewerType::from_route("/.mbr/audio/"),
663            Some(MediaViewerType::Audio)
664        );
665    }
666
667    #[test]
668    fn test_media_viewer_type_from_route_images() {
669        assert_eq!(
670            MediaViewerType::from_route("/.mbr/images/"),
671            Some(MediaViewerType::Image)
672        );
673    }
674
675    #[test]
676    fn test_media_viewer_type_from_route_invalid() {
677        assert_eq!(MediaViewerType::from_route("/some/other/path"), None);
678        assert_eq!(MediaViewerType::from_route("/.mbr/videos"), None); // missing trailing slash
679        assert_eq!(MediaViewerType::from_route("/.mbr/unknown/"), None);
680    }
681
682    #[test]
683    fn test_media_viewer_type_template_name() {
684        assert_eq!(MediaViewerType::Video.template_name(), "media_viewer.html");
685        assert_eq!(MediaViewerType::Pdf.template_name(), "media_viewer.html");
686        assert_eq!(MediaViewerType::Audio.template_name(), "media_viewer.html");
687    }
688
689    #[test]
690    fn test_media_viewer_type_label() {
691        assert_eq!(MediaViewerType::Video.label(), "Video");
692        assert_eq!(MediaViewerType::Pdf.label(), "PDF");
693        assert_eq!(MediaViewerType::Audio.label(), "Audio");
694    }
695
696    #[test]
697    fn test_media_viewer_type_as_str() {
698        assert_eq!(MediaViewerType::Video.as_str(), "video");
699        assert_eq!(MediaViewerType::Pdf.as_str(), "pdf");
700        assert_eq!(MediaViewerType::Audio.as_str(), "audio");
701    }
702
703    #[test]
704    fn test_media_viewer_type_from_extension_video() {
705        for ext in &[
706            "mp4", "m4v", "mov", "webm", "flv", "mpg", "mpeg", "avi", "3gp", "wmv", "mkv", "ts",
707            "mts", "m2ts", "vob", "divx", "xvid", "asf", "rm", "rmvb", "f4v", "ogv",
708        ] {
709            assert_eq!(
710                MediaViewerType::from_extension(ext),
711                Some(MediaViewerType::Video),
712                "Expected Video for extension '{ext}'"
713            );
714        }
715    }
716
717    #[test]
718    fn test_media_viewer_type_from_extension_audio() {
719        for ext in &[
720            "mp3", "wav", "ogg", "flac", "aac", "m4a", "aiff", "aif", "oga", "opus", "wma",
721        ] {
722            assert_eq!(
723                MediaViewerType::from_extension(ext),
724                Some(MediaViewerType::Audio),
725                "Expected Audio for extension '{ext}'"
726            );
727        }
728    }
729
730    #[test]
731    fn test_media_viewer_type_from_extension_image() {
732        for ext in &[
733            "jpg", "jpeg", "png", "webp", "gif", "bmp", "tif", "tiff", "svg",
734        ] {
735            assert_eq!(
736                MediaViewerType::from_extension(ext),
737                Some(MediaViewerType::Image),
738                "Expected Image for extension '{ext}'"
739            );
740        }
741    }
742
743    #[test]
744    fn test_media_viewer_type_from_extension_pdf() {
745        assert_eq!(
746            MediaViewerType::from_extension("pdf"),
747            Some(MediaViewerType::Pdf)
748        );
749    }
750
751    #[test]
752    fn test_media_viewer_type_from_extension_case_insensitive() {
753        assert_eq!(
754            MediaViewerType::from_extension("MP4"),
755            Some(MediaViewerType::Video)
756        );
757        assert_eq!(
758            MediaViewerType::from_extension("Pdf"),
759            Some(MediaViewerType::Pdf)
760        );
761        assert_eq!(
762            MediaViewerType::from_extension("JPG"),
763            Some(MediaViewerType::Image)
764        );
765    }
766
767    #[test]
768    fn test_media_viewer_type_from_extension_unknown() {
769        assert_eq!(MediaViewerType::from_extension("md"), None);
770        assert_eq!(MediaViewerType::from_extension("html"), None);
771        assert_eq!(MediaViewerType::from_extension("rs"), None);
772        assert_eq!(MediaViewerType::from_extension(""), None);
773    }
774
775    #[test]
776    fn test_media_viewer_type_from_path() {
777        assert_eq!(
778            MediaViewerType::from_path(Path::new("videos/demo.mp4")),
779            Some(MediaViewerType::Video)
780        );
781        assert_eq!(
782            MediaViewerType::from_path(Path::new("music/song.mp3")),
783            Some(MediaViewerType::Audio)
784        );
785        assert_eq!(
786            MediaViewerType::from_path(Path::new("images/photo.jpg")),
787            Some(MediaViewerType::Image)
788        );
789        assert_eq!(
790            MediaViewerType::from_path(Path::new("docs/paper.pdf")),
791            Some(MediaViewerType::Pdf)
792        );
793        assert_eq!(MediaViewerType::from_path(Path::new("readme.md")), None);
794        assert_eq!(MediaViewerType::from_path(Path::new("noext")), None);
795    }
796
797    #[test]
798    fn test_media_viewer_type_route_path() {
799        assert_eq!(MediaViewerType::Video.route_path(), "/.mbr/videos/");
800        assert_eq!(MediaViewerType::Pdf.route_path(), "/.mbr/pdfs/");
801        assert_eq!(MediaViewerType::Audio.route_path(), "/.mbr/audio/");
802        assert_eq!(MediaViewerType::Image.route_path(), "/.mbr/images/");
803    }
804
805    #[test]
806    fn test_media_viewer_type_route_path_roundtrips_with_from_route() {
807        for media_type in &[
808            MediaViewerType::Video,
809            MediaViewerType::Pdf,
810            MediaViewerType::Audio,
811            MediaViewerType::Image,
812        ] {
813            assert_eq!(
814                MediaViewerType::from_route(media_type.route_path()),
815                Some(*media_type),
816                "route_path -> from_route roundtrip failed for {media_type:?}"
817            );
818        }
819    }
820}