mod native;
pub use native::NativeMediaPlugin;
use std::io::Cursor;
pub const MEDIA_TYPE_ID: i8 = 25;
pub const MEDIA_EXTENSIONS: &[&str] = &[
".jpg", ".jpeg", ".png", ".webp", ".gif", ".tif", ".tiff", ".bmp",
".mp3", ".flac", ".ogg", ".oga", ".opus", ".wav", ".m4a", ".aac",
".mp4", ".m4v", ".mov", ".mkv", ".webm",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MediaKind {
Image,
Audio,
Video,
}
impl MediaKind {
pub fn as_str(self) -> &'static str {
match self {
MediaKind::Image => "image",
MediaKind::Audio => "audio",
MediaKind::Video => "video",
}
}
}
#[derive(Debug, Default, Clone, PartialEq)]
pub struct MediaMeta {
pub kind: Option<MediaKind>,
pub format: Option<String>,
pub width: Option<u32>,
pub height: Option<u32>,
pub color: Option<String>,
pub duration_ms: Option<u32>,
pub sample_rate: Option<u32>,
pub channels: Option<u32>,
pub bitrate: Option<u32>,
pub audio_codec: Option<String>,
pub video_codec: Option<String>,
pub framerate: Option<String>,
pub title: Option<String>,
pub artist: Option<String>,
pub album: Option<String>,
pub camera: Option<String>,
pub datetime: Option<String>,
pub orientation: Option<u32>,
pub gps: Option<String>,
}
fn ext_of(path: &str) -> Option<String> {
let fname = path.rsplit(['/', '\\']).next().unwrap_or(path);
let dot = fname.rfind('.')?;
Some(fname[dot..].to_ascii_lowercase())
}
pub fn is_media_path(path: &str) -> bool {
match ext_of(path) {
Some(e) => MEDIA_EXTENSIONS.contains(&e.as_str()),
None => false,
}
}
pub fn extract_media_metadata(path: &str, data: &[u8]) -> Option<MediaMeta> {
let ext = ext_of(path)?;
match ext.as_str() {
".jpg" | ".jpeg" | ".png" | ".webp" | ".gif" | ".tif" | ".tiff" | ".bmp" => {
extract_image(data)
}
".mp3" | ".flac" | ".ogg" | ".oga" | ".opus" | ".wav" | ".m4a" | ".aac" => {
extract_audio(data)
}
".mp4" | ".m4v" | ".mov" => extract_mp4(data),
".mkv" | ".webm" => extract_matroska(data, &ext),
_ => None,
}
}
fn extract_image(data: &[u8]) -> Option<MediaMeta> {
use image::ImageDecoder;
let reader = image::ImageReader::new(Cursor::new(data)).with_guessed_format().ok()?;
let format = reader.format();
let decoder = reader.into_decoder().ok()?;
let (w, h) = decoder.dimensions();
let color = decoder.color_type();
drop(decoder);
let mut m = MediaMeta {
kind: Some(MediaKind::Image),
format: format.map(|f| format!("{f:?}").to_ascii_lowercase()),
width: Some(w),
height: Some(h),
color: Some(format!("{color:?}").to_ascii_lowercase()),
..Default::default()
};
read_exif_into(data, &mut m);
Some(m)
}
fn read_exif_into(data: &[u8], m: &mut MediaMeta) {
use exif::{In, Tag};
let exif = match exif::Reader::new().read_from_container(&mut Cursor::new(data)) {
Ok(e) => e,
Err(_) => return,
};
let text = |tag: Tag| -> Option<String> {
exif.get_field(tag, In::PRIMARY).map(|f| f.display_value().to_string().trim().to_string())
};
let nonempty = |s: Option<String>| s.filter(|x| !x.is_empty());
let make = nonempty(text(Tag::Make));
let model = nonempty(text(Tag::Model));
m.camera = match (make, model) {
(Some(a), Some(b)) if b.starts_with(&a) => Some(b),
(Some(a), Some(b)) => Some(format!("{a} {b}")),
(Some(a), None) => Some(a),
(None, Some(b)) => Some(b),
(None, None) => None,
};
m.datetime = nonempty(text(Tag::DateTimeOriginal)).or_else(|| nonempty(text(Tag::DateTime)));
m.orientation = exif
.get_field(Tag::Orientation, In::PRIMARY)
.and_then(|f| f.value.get_uint(0));
let lat = nonempty(text(Tag::GPSLatitude));
let lon = nonempty(text(Tag::GPSLongitude));
if lat.is_some() || lon.is_some() {
let latr = nonempty(text(Tag::GPSLatitudeRef)).unwrap_or_default();
let lonr = nonempty(text(Tag::GPSLongitudeRef)).unwrap_or_default();
m.gps = Some(format!(
"{} {} / {} {}",
lat.unwrap_or_default(),
latr,
lon.unwrap_or_default(),
lonr
));
}
}
fn extract_audio(data: &[u8]) -> Option<MediaMeta> {
use lofty::prelude::*;
use lofty::probe::Probe;
let tagged =
Probe::new(Cursor::new(data)).guess_file_type().ok()?.read().ok()?;
let props = tagged.properties();
let codec = format!("{:?}", tagged.file_type()).to_ascii_lowercase();
let mut m = MediaMeta {
kind: Some(MediaKind::Audio),
format: Some(codec.clone()),
audio_codec: Some(codec),
duration_ms: u32::try_from(props.duration().as_millis()).ok(),
sample_rate: props.sample_rate(),
channels: props.channels().map(u32::from),
bitrate: props.audio_bitrate().or_else(|| props.overall_bitrate()),
..Default::default()
};
if let Some(tag) = tagged.primary_tag().or_else(|| tagged.first_tag()) {
m.title = tag.title().map(|c| c.into_owned());
m.artist = tag.artist().map(|c| c.into_owned());
m.album = tag.album().map(|c| c.into_owned());
}
Some(m)
}
fn extract_mp4(data: &[u8]) -> Option<MediaMeta> {
use mp4::TrackType;
let size = data.len() as u64;
let reader = mp4::Mp4Reader::read_header(Cursor::new(data), size).ok()?;
let mut m = MediaMeta {
kind: Some(MediaKind::Video),
format: Some("mp4".to_string()),
duration_ms: u32::try_from(reader.duration().as_millis()).ok(),
..Default::default()
};
for track in reader.tracks().values() {
match track.track_type() {
Ok(TrackType::Video) => {
m.width = Some(u32::from(track.width()));
m.height = Some(u32::from(track.height()));
m.video_codec =
track.media_type().ok().map(|t| t.to_string().to_ascii_lowercase());
let fr = track.frame_rate();
if fr.is_finite() && fr > 0.0 {
m.framerate = Some(format!("{fr:.3}"));
}
let br = track.bitrate();
if br > 0 {
m.bitrate = Some(br);
}
}
Ok(TrackType::Audio) => {
m.audio_codec =
track.media_type().ok().map(|t| t.to_string().to_ascii_lowercase());
if let Ok(freq) = track.sample_freq_index() {
m.sample_rate = Some(freq.freq());
}
}
_ => {}
}
}
Some(m)
}
fn extract_matroska(data: &[u8], ext: &str) -> Option<MediaMeta> {
use matroska::{Matroska, Settings};
let mkv = Matroska::open(Cursor::new(data)).ok()?;
let format = if ext == ".webm" { "webm" } else { "matroska" };
let mut m = MediaMeta {
kind: Some(MediaKind::Video),
format: Some(format.to_string()),
duration_ms: mkv.info.duration.and_then(|d| u32::try_from(d.as_millis()).ok()),
title: mkv.info.title.clone(),
..Default::default()
};
if let Some(v) = mkv.video_tracks().next() {
m.video_codec = Some(v.codec_id.to_ascii_lowercase());
if let Settings::Video(vid) = &v.settings {
m.width = u32::try_from(vid.pixel_width).ok();
m.height = u32::try_from(vid.pixel_height).ok();
}
if let Some(dd) = v.default_duration {
let nanos = dd.as_nanos();
if nanos > 0 {
let fps = 1.0e9 / nanos as f64;
m.framerate = Some(format!("{fps:.3}"));
}
}
}
if let Some(a) = mkv.audio_tracks().next() {
m.audio_codec = Some(a.codec_id.to_ascii_lowercase());
if let Settings::Audio(au) = &a.settings {
m.sample_rate = Some(au.sample_rate as u32);
m.channels = u32::try_from(au.channels).ok();
}
}
Some(m)
}
#[cfg(test)]
mod tests;