Skip to main content

mimick/
media_kinds.rs

1//! Central registry of supported media extensions, MIME types, and asset kinds.
2//!
3//! All file-extension lookups in the upload, watch, and library-view paths
4//! resolve through this module so the three previously hand-maintained tables
5//! cannot drift apart.
6
7use phf::{phf_map, phf_set};
8use std::path::Path;
9
10/// Discriminant representing whether a media file is an image or a video.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum AssetKind {
13    /// Asset is a static or animated image.
14    Image,
15    /// Asset is a video or motion stream.
16    Video,
17}
18
19/// Static set of all supported file extensions for quick membership lookups.
20/// Mirrors Immich's server-accepted formats; MPO is intentionally excluded.
21pub static SUPPORTED: phf::Set<&'static str> = phf_set! {
22    "3fr", "3gp", "3gpp", "ari", "arw", "avi", "avif", "bmp", "cap", "cin",
23    "cr2", "cr3", "crw", "dcr", "dng", "erf", "fff", "flv", "gif", "heic",
24    "heif", "hif", "iiq", "insp", "insv", "jp2", "jpe", "jpeg", "jpg", "jxl",
25    "k25", "kdc", "m2t", "m2ts", "m4v", "mkv", "mov", "mp4", "mpe", "mpeg",
26    "mpg", "mrw", "mts", "mxf", "nef", "nrw", "orf", "ori", "pef",
27    "png", "psd", "raf", "raw", "rw2", "rwl", "sr2", "srf", "srw", "svg",
28    "tif", "tiff", "ts", "vob", "webm", "webp", "wmv", "x3f",
29};
30
31/// Camera RAW extensions -- subset of SUPPORTED that needs RAW-specific
32/// decoding (libraw) instead of pixbuf or image-rs.
33pub static RAW_EXTENSIONS: phf::Set<&'static str> = phf_set! {
34    "3fr", "ari", "arw", "cap", "cin", "cr2", "cr3", "crw", "dcr", "dng",
35    "erf", "fff", "iiq", "k25", "kdc", "mrw", "nef", "nrw", "orf", "ori",
36    "pef", "raf", "raw", "rw2", "rwl", "sr2", "srf", "srw", "x3f",
37};
38
39/// Compile-time mapping from lowercased file extensions to standard MIME types.
40static MIME_BY_EXT: phf::Map<&'static str, &'static str> = phf_map! {
41    "avif" => "image/avif",
42    "bmp" => "image/bmp",
43    "gif" => "image/gif",
44    "heic" => "image/heic",
45    "heif" => "image/heif",
46    "hif" => "image/heif",
47    "insp" => "image/jpeg",
48    "jpe" => "image/jpeg",
49    "jpeg" => "image/jpeg",
50    "jpg" => "image/jpeg",
51    "jp2" => "image/jp2",
52    "jxl" => "image/jxl",
53    "png" => "image/png",
54    "psd" => "image/vnd.adobe.photoshop",
55    "svg" => "image/svg+xml",
56    "tif" => "image/tiff",
57    "tiff" => "image/tiff",
58    "webp" => "image/webp",
59    "3fr" => "image/x-hasselblad-3fr",
60    "ari" => "image/x-arriflex-ari",
61    "arw" => "image/x-sony-arw",
62    "cap" => "image/x-phaseone-cap",
63    "cin" => "image/cineon",
64    "cr2" => "image/x-canon-cr2",
65    "cr3" => "image/x-canon-cr3",
66    "crw" => "image/x-canon-crw",
67    "dcr" => "image/x-kodak-dcr",
68    "dng" => "image/x-adobe-dng",
69    "erf" => "image/x-epson-erf",
70    "fff" => "image/x-hasselblad-fff",
71    "iiq" => "image/x-phaseone-iiq",
72    "k25" => "image/x-kodak-k25",
73    "kdc" => "image/x-kodak-kdc",
74    "mrw" => "image/x-minolta-mrw",
75    "nef" => "image/x-nikon-nef",
76    "nrw" => "image/x-nikon-nrw",
77    "orf" => "image/x-olympus-orf",
78    "ori" => "image/x-olympus-orf",
79    "pef" => "image/x-pentax-pef",
80    "raf" => "image/x-fuji-raf",
81    "raw" => "image/x-panasonic-raw",
82    "rw2" => "image/x-panasonic-rw2",
83    "rwl" => "image/x-leica-rwl",
84    "sr2" => "image/x-sony-sr2",
85    "srf" => "image/x-sony-sr2",
86    "srw" => "image/x-samsung-srw",
87    "x3f" => "image/x-sigma-x3f",
88    "3gp" => "video/3gpp",
89    "3gpp" => "video/3gpp",
90    "avi" => "video/x-msvideo",
91    "flv" => "video/x-flv",
92    "insv" => "video/mp4",
93    "mp4" => "video/mp4",
94    "m2t" => "video/mp2t",
95    "m2ts" => "video/mp2t",
96    "mts" => "video/mp2t",
97    "ts" => "video/mp2t",
98    "m4v" => "video/x-m4v",
99    "mkv" => "video/x-matroska",
100    "mpe" => "video/mpeg",
101    "mpeg" => "video/mpeg",
102    "mpg" => "video/mpeg",
103    "mov" => "video/quicktime",
104    "mxf" => "application/mxf",
105    "vob" => "video/dvd",
106    "webm" => "video/webm",
107    "wmv" => "video/x-ms-wmv",
108};
109
110/// Lowercase the path's extension and return whether it is an accepted media file.
111pub fn is_supported_path(path: &Path) -> bool {
112    if path.is_dir() {
113        return false;
114    }
115    extension_lower(path)
116        .map(|ext| SUPPORTED.contains(ext.as_str()))
117        .unwrap_or(false)
118}
119
120/// Check if a lowercased extension is present in the supported set.
121pub fn is_supported_ext(ext: &str) -> bool {
122    SUPPORTED.contains(ext)
123}
124
125/// Whether the extension is a camera RAW format.
126pub fn is_raw_ext(ext: &str) -> bool {
127    RAW_EXTENSIONS.contains(ext)
128}
129
130/// Whether the path's extension is a camera RAW format.
131pub fn is_raw_path(path: &Path) -> bool {
132    extension_lower(path)
133        .map(|ext| RAW_EXTENSIONS.contains(ext.as_str()))
134        .unwrap_or(false)
135}
136
137/// MIME type for a known extension (already lowercased).
138pub fn mime_for(ext: &str) -> Option<&'static str> {
139    MIME_BY_EXT.get(ext).copied()
140}
141
142/// MIME type for any path; falls back to `application/octet-stream` for unknowns.
143pub fn mime_for_path(path: &Path) -> &'static str {
144    extension_lower(path)
145        .and_then(|ext| mime_for(&ext))
146        .unwrap_or("application/octet-stream")
147}
148
149/// Determine the media kind based on the prefix of the MIME type.
150pub fn asset_kind(mime: &str) -> AssetKind {
151    if mime.starts_with("video/") {
152        AssetKind::Video
153    } else {
154        AssetKind::Image
155    }
156}
157
158/// Whether the path's extension maps to a video MIME type.
159pub fn is_video_path(path: &Path) -> bool {
160    matches!(
161        extension_lower(path).and_then(|ext| mime_for(&ext)),
162        Some(mime) if mime.starts_with("video/")
163    )
164}
165
166/// Extract the extension from a path and return it lowercased.
167fn extension_lower(path: &Path) -> Option<String> {
168    path.extension()
169        .map(|ext| ext.to_string_lossy().to_lowercase())
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    #[test]
177    fn every_supported_extension_has_a_mime() {
178        for ext in SUPPORTED.iter() {
179            assert!(
180                MIME_BY_EXT.contains_key(ext),
181                "extension `.{}` is supported but has no MIME mapping",
182                ext
183            );
184        }
185    }
186
187    #[test]
188    fn mime_for_path_handles_uppercase_and_unknowns() {
189        assert_eq!(mime_for_path(Path::new("photo.PNG")), "image/png");
190        assert_eq!(mime_for_path(Path::new("photo.jpe")), "image/jpeg");
191        assert_eq!(
192            mime_for_path(Path::new("doc.unknown")),
193            "application/octet-stream"
194        );
195        assert_eq!(
196            mime_for_path(Path::new("noext")),
197            "application/octet-stream"
198        );
199    }
200
201    #[test]
202    fn asset_kind_buckets_video_prefix() {
203        assert_eq!(asset_kind("video/mp4"), AssetKind::Video);
204        assert_eq!(asset_kind("image/jpeg"), AssetKind::Image);
205    }
206
207    #[test]
208    fn is_raw_path_detects_raw_extensions() {
209        assert!(is_raw_path(Path::new("photo.NEF")));
210        assert!(is_raw_path(Path::new("photo.cr3")));
211        assert!(is_raw_path(Path::new("photo.DNG")));
212        assert!(is_raw_path(Path::new("/some/path/IMG.ARW")));
213        assert!(!is_raw_path(Path::new("photo.jpg")));
214        assert!(!is_raw_path(Path::new("video.mp4")));
215        assert!(!is_raw_path(Path::new("noext")));
216    }
217
218    #[test]
219    fn desktop_file_mime_types_match() {
220        use std::collections::BTreeSet;
221        let desktop = include_str!("../setup/dev.nicx.mimick.desktop");
222        let mime_line = desktop
223            .lines()
224            .find(|l| l.starts_with("MimeType="))
225            .expect("No MimeType= line in .desktop file");
226        let declared: BTreeSet<&str> = mime_line
227            .strip_prefix("MimeType=")
228            .unwrap()
229            .split(';')
230            .filter(|s| !s.is_empty())
231            .collect();
232        let source: BTreeSet<&str> = MIME_BY_EXT.values().copied().collect();
233        assert_eq!(
234            declared, source,
235            "MimeType= in .desktop file does not match MIME_BY_EXT in media_kinds.rs"
236        );
237    }
238}