use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
use std::process::Command;
use std::sync::Arc;
use exif::{In, Reader as ExifReader, Tag};
use hex;
use image::{self, GenericImageView};
use img_hash::{HasherConfig, ImageHash};
use infer;
use log;
use mime_guess::MimeGuess;
use crate::audio_fingerprint;
use crate::file_utils::{DuplicateSet, FileInfo};
use crate::video_fingerprint;
pub fn is_ffmpeg_available() -> bool {
Command::new("ffmpeg").arg("-version").output().is_ok()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum MediaKind {
Image,
Video,
Audio,
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ResolutionPreference {
Highest,
Lowest,
ClosestTo(u32, u32), }
impl std::fmt::Display for ResolutionPreference {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Highest => write!(f, "highest"),
Self::Lowest => write!(f, "lowest"),
Self::ClosestTo(w, h) => write!(f, "closest to {}x{}", w, h),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FormatPreference {
pub formats: Vec<String>, }
impl Default for FormatPreference {
fn default() -> Self {
Self {
formats: vec![
"raw".to_string(),
"arw".to_string(),
"cr2".to_string(),
"nef".to_string(),
"orf".to_string(),
"rw2".to_string(),
"png".to_string(),
"tiff".to_string(),
"bmp".to_string(),
"jpg".to_string(),
"jpeg".to_string(),
"mp4".to_string(),
"mov".to_string(),
"mp3".to_string(),
"flac".to_string(),
"wav".to_string(),
],
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MediaDedupOptions {
pub enabled: bool,
pub resolution_preference: ResolutionPreference,
pub format_preference: FormatPreference,
pub similarity_threshold: u32, }
impl Default for MediaDedupOptions {
fn default() -> Self {
Self {
enabled: false,
resolution_preference: ResolutionPreference::Highest,
format_preference: FormatPreference::default(),
similarity_threshold: 90, }
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MediaMetadata {
pub kind: MediaKind,
pub width: Option<u32>,
pub height: Option<u32>,
pub format: String,
pub duration: Option<f64>, pub bitrate: Option<u32>,
pub perceptual_hash: Option<String>,
pub fingerprint: Option<Vec<u8>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MediaFileInfo {
pub file_info: FileInfo,
pub metadata: Option<MediaMetadata>,
}
impl From<FileInfo> for MediaFileInfo {
fn from(file_info: FileInfo) -> Self {
Self {
file_info,
metadata: None,
}
}
}
pub fn detect_media_type(path: &Path) -> MediaKind {
if let Ok(content) = std::fs::read(path) {
if let Some(info) = infer::get(&content) {
match info.mime_type() {
m if m.starts_with("image/") => return MediaKind::Image,
m if m.starts_with("video/") => return MediaKind::Video,
m if m.starts_with("audio/") => return MediaKind::Audio,
_ => {}
}
}
}
if let Some(extension) = path.extension() {
if let Some(ext_str) = extension.to_str() {
let mime = MimeGuess::from_ext(ext_str).first_or_octet_stream();
let type_str = mime.type_().as_str();
if type_str.starts_with("image") {
return MediaKind::Image;
} else if type_str.starts_with("video") {
return MediaKind::Video;
} else if type_str.starts_with("audio") {
return MediaKind::Audio;
}
}
}
MediaKind::Unknown
}
pub fn extract_image_metadata(path: &Path) -> Result<MediaMetadata> {
let format = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("unknown")
.to_lowercase();
let img = image::open(path).with_context(|| format!("Failed to open image: {:?}", path))?;
let (width, height) = img.dimensions();
let hasher = HasherConfig::new().to_hasher();
let img_hash_img = {
let rgba8 = img.to_rgba8();
let width = rgba8.width();
let height = rgba8.height();
let raw_pixels = rgba8.into_raw();
let buffer = img_hash::image::ImageBuffer::from_raw(width, height, raw_pixels)
.expect("Failed to convert image buffer");
img_hash::image::DynamicImage::ImageRgba8(buffer)
};
let hash = hasher.hash_image(&img_hash_img);
let hash_str = hex::encode(hash.as_bytes());
let _bitrate: Option<u32> = None;
if let Ok(file) = std::fs::File::open(path) {
if let Ok(exif) = ExifReader::new().read_from_container(&mut std::io::BufReader::new(&file))
{
if let Some(field) = exif.get_field(Tag::XResolution, In::PRIMARY) {
if let Some(width) = field.value.get_uint(0) {
log::debug!("Image resolution: {}", width);
}
}
}
}
Ok(MediaMetadata {
kind: MediaKind::Image,
width: Some(width),
height: Some(height),
format,
duration: None, bitrate: None,
perceptual_hash: Some(hash_str),
fingerprint: None, })
}
pub fn extract_video_metadata(path: &Path) -> Result<MediaMetadata> {
let format = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("unknown")
.to_lowercase();
if !is_ffmpeg_available() {
return Err(anyhow::anyhow!(
"ffmpeg is required for video processing but is not available"
));
}
let (width, height, duration, bitrate) = video_fingerprint::extract_video_metadata(path)?;
let fingerprint = video_fingerprint::fingerprint_video(path)?;
Ok(MediaMetadata {
kind: MediaKind::Video,
width,
height,
format,
duration,
bitrate,
perceptual_hash: None,
fingerprint: Some(fingerprint),
})
}
pub fn extract_audio_metadata(path: &Path) -> Result<MediaMetadata> {
let format = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("unknown")
.to_lowercase();
if !is_ffmpeg_available() {
let chromaprint_available = Command::new("fpcalc").arg("-version").output().is_ok();
if !chromaprint_available {
return Err(anyhow::anyhow!(
"Neither ffmpeg nor chromaprint is available for audio processing"
));
}
}
let fingerprint = audio_fingerprint::fingerprint_file(path)?;
let mut duration = None;
let mut bitrate = None;
if is_ffmpeg_available() {
let output = Command::new("ffprobe")
.args([
"-v",
"error",
"-select_streams",
"a:0", "-show_entries",
"stream=duration,bit_rate",
"-of",
"json",
path.to_str().unwrap(),
])
.output();
if let Ok(output) = output {
if output.status.success() {
let output_str = String::from_utf8_lossy(&output.stdout);
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&output_str) {
duration = json["streams"][0]["duration"]
.as_str()
.and_then(|d| d.parse::<f64>().ok())
.or_else(|| json["streams"][0]["duration"].as_f64());
bitrate = json["streams"][0]["bit_rate"]
.as_str()
.and_then(|b| b.parse::<u32>().ok())
.or_else(|| json["streams"][0]["bit_rate"].as_u64().map(|b| b as u32));
}
}
}
}
Ok(MediaMetadata {
kind: MediaKind::Audio,
width: None, height: None, format,
duration,
bitrate,
perceptual_hash: None,
fingerprint: Some(fingerprint),
})
}
pub fn extract_media_metadata(path: &Path) -> Result<MediaMetadata> {
let media_kind = detect_media_type(path);
match media_kind {
MediaKind::Image => extract_image_metadata(path),
MediaKind::Video => extract_video_metadata(path),
MediaKind::Audio => extract_audio_metadata(path),
MediaKind::Unknown => Err(anyhow::anyhow!("Unknown media type for path: {:?}", path)),
}
}
pub fn calculate_image_similarity(hash1: &str, hash2: &str) -> u32 {
let parse_hash = |hash_str: &str| -> Option<ImageHash> {
let bytes = (0..hash_str.len())
.step_by(2)
.map(|i| u8::from_str_radix(&hash_str[i..i + 2], 16).ok())
.collect::<Option<Vec<u8>>>()?;
ImageHash::from_bytes(&bytes).ok()
};
if let (Some(img_hash1), Some(img_hash2)) = (parse_hash(hash1), parse_hash(hash2)) {
let distance = img_hash1.dist(&img_hash2);
let max_distance = 64; let similarity = ((max_distance - distance) as f64 / max_distance as f64) * 100.0;
return similarity as u32;
}
0 }
pub fn calculate_video_similarity(fp1: &[u8], fp2: &[u8]) -> u32 {
(video_fingerprint::compare_fingerprints(fp1, fp2) * 100.0) as u32
}
pub fn calculate_audio_similarity(fp1: &[u8], fp2: &[u8]) -> u32 {
(audio_fingerprint::compare_fingerprints(fp1, fp2) * 100.0) as u32
}
pub fn compare_media_files(a: &MediaFileInfo, b: &MediaFileInfo) -> u32 {
match (&a.metadata, &b.metadata) {
(Some(meta_a), Some(meta_b)) => {
if meta_a.kind != meta_b.kind {
return 0;
}
match meta_a.kind {
MediaKind::Image => match (&meta_a.perceptual_hash, &meta_b.perceptual_hash) {
(Some(hash_a), Some(hash_b)) => calculate_image_similarity(hash_a, hash_b),
_ => 0,
},
MediaKind::Video => match (&meta_a.fingerprint, &meta_b.fingerprint) {
(Some(fp_a), Some(fp_b)) => calculate_video_similarity(fp_a, fp_b),
_ => 0,
},
MediaKind::Audio => match (&meta_a.fingerprint, &meta_b.fingerprint) {
(Some(fp_a), Some(fp_b)) => calculate_audio_similarity(fp_a, fp_b),
_ => 0,
},
MediaKind::Unknown => 0,
}
}
_ => 0, }
}
pub fn determine_preferred_media_file<'a>(
files: &'a [MediaFileInfo],
options: &'a MediaDedupOptions,
) -> Option<&'a MediaFileInfo> {
if files.is_empty() {
return None;
}
let files_with_metadata: Vec<_> = files.iter().filter(|f| f.metadata.is_some()).collect();
if files_with_metadata.is_empty() {
return files.first(); }
let format_ranks: HashMap<String, usize> = options
.format_preference
.formats
.iter()
.enumerate()
.map(|(i, fmt)| (fmt.clone(), i))
.collect();
let get_format_rank = |file: &&MediaFileInfo| -> usize {
let format = match &file.metadata {
Some(meta) => &meta.format,
None => return usize::MAX, };
*format_ranks.get(format).unwrap_or(&usize::MAX)
};
let mut preferred_format_files = files_with_metadata.clone();
preferred_format_files.sort_by_key(get_format_rank);
let best_format_rank = get_format_rank(&preferred_format_files[0]);
let best_format_files: Vec<_> = preferred_format_files
.into_iter()
.filter(|f| get_format_rank(f) == best_format_rank)
.collect();
if best_format_files.len() > 1 {
match options.resolution_preference {
ResolutionPreference::Highest => {
best_format_files
.into_iter()
.max_by_key(|file| match &file.metadata {
Some(meta) => meta.width.unwrap_or(0) * meta.height.unwrap_or(0),
None => 0,
})
}
ResolutionPreference::Lowest => {
best_format_files
.into_iter()
.min_by_key(|file| match &file.metadata {
Some(meta) => {
meta.width.unwrap_or(u32::MAX) * meta.height.unwrap_or(u32::MAX)
}
None => u32::MAX,
})
}
ResolutionPreference::ClosestTo(target_width, target_height) => {
best_format_files.into_iter().min_by_key(|file| {
match &file.metadata {
Some(meta) => {
let w = meta.width.unwrap_or(0);
let h = meta.height.unwrap_or(0);
let dw = if w > target_width {
w - target_width
} else {
target_width - w
};
let dh = if h > target_height {
h - target_height
} else {
target_height - h
};
dw * dw + dh * dh }
None => u32::MAX,
}
})
}
}
} else {
best_format_files.into_iter().next()
}
}
#[allow(clippy::arc_with_non_send_sync)]
pub fn find_similar_media_files(
file_infos: &[FileInfo],
options: &MediaDedupOptions,
progress_callback: Option<Box<dyn Fn(usize, usize) + Send>>,
) -> Result<Vec<Vec<MediaFileInfo>>> {
if !options.enabled {
return Ok(Vec::new());
}
log::info!(
"Starting media deduplication with threshold: {}%",
options.similarity_threshold
);
let has_video_files = file_infos.iter().any(|f| {
let kind = detect_media_type(&f.path);
kind == MediaKind::Video
});
if has_video_files && !is_ffmpeg_available() {
log::warn!("FFmpeg is not installed. Video deduplication will be limited.");
}
let total_files = file_infos.len();
let mut processed = 0;
let progress_callback = progress_callback.map(|cb| {
let cb = Arc::new(cb);
move |count, total| {
let cb = cb.clone();
cb(count, total);
}
});
let media_files: Vec<MediaFileInfo> = file_infos
.iter()
.map(|file_info| {
let mut media_file = MediaFileInfo::from(file_info.clone());
let media_kind = detect_media_type(&file_info.path);
if media_kind != MediaKind::Unknown {
media_file.metadata = match extract_media_metadata(&file_info.path) {
Ok(metadata) => Some(metadata),
Err(e) => {
log::warn!(
"Failed to extract media metadata for {:?}: {}",
file_info.path,
e
);
None
}
};
}
processed += 1;
if let Some(cb) = &progress_callback {
cb(processed, total_files);
}
media_file
})
.filter(|media_file| media_file.metadata.is_some())
.collect();
log::info!("Extracted metadata for {} media files", media_files.len());
let mut image_files: Vec<_> = Vec::new();
let mut video_files: Vec<_> = Vec::new();
let mut audio_files: Vec<_> = Vec::new();
for file in &media_files {
if let Some(metadata) = &file.metadata {
match metadata.kind {
MediaKind::Image => image_files.push(file),
MediaKind::Video => video_files.push(file),
MediaKind::Audio => audio_files.push(file),
_ => {}
}
}
}
log::info!(
"Media file count: {} images, {} videos, {} audio files",
image_files.len(),
video_files.len(),
audio_files.len()
);
let mut similar_groups: Vec<Vec<MediaFileInfo>> = Vec::new();
process_media_type_similarity(&image_files, options, &mut similar_groups)?;
process_media_type_similarity(&video_files, options, &mut similar_groups)?;
process_media_type_similarity(&audio_files, options, &mut similar_groups)?;
log::info!(
"Found {} groups of similar media files.",
similar_groups.len()
);
Ok(similar_groups)
}
pub fn process_media_type_similarity(
files: &[&MediaFileInfo],
options: &MediaDedupOptions,
similar_groups: &mut Vec<Vec<MediaFileInfo>>,
) -> Result<()> {
if files.len() < 2 {
return Ok(());
}
let mut processed = vec![false; files.len()];
for i in 0..files.len() {
if processed[i] {
continue;
}
let mut current_group = Vec::new();
current_group.push(files[i].clone());
processed[i] = true;
for j in i + 1..files.len() {
if processed[j] {
continue;
}
let similarity = compare_media_files(files[i], files[j]);
if similarity >= options.similarity_threshold {
current_group.push(files[j].clone());
processed[j] = true;
}
}
if current_group.len() > 1 {
similar_groups.push(current_group);
}
}
Ok(())
}
pub fn convert_to_duplicate_sets(
similar_groups: &[Vec<MediaFileInfo>],
options: &MediaDedupOptions,
) -> Vec<DuplicateSet> {
let mut duplicate_sets = Vec::new();
for group in similar_groups {
if group.len() < 2 {
continue;
}
let kept_file = determine_preferred_media_file(group, options);
if let Some(kept) = kept_file {
let mut file_infos = group
.iter()
.map(|mf| mf.file_info.clone())
.collect::<Vec<_>>();
if let Some(kept_idx) = file_infos
.iter()
.position(|f| f.path == kept.file_info.path)
{
let kept_file_info = file_infos.remove(kept_idx);
file_infos.insert(0, kept_file_info);
}
let hash = format!("media_{}", group[0].file_info.path.to_string_lossy());
let size = group[0].file_info.size;
duplicate_sets.push(DuplicateSet {
files: file_infos,
size,
hash,
});
}
}
duplicate_sets
}
pub fn add_media_options_to_cli(
options: &mut MediaDedupOptions,
enable: bool,
resolution: &str,
formats: &[String],
threshold: u32,
) {
options.enabled = enable;
match resolution {
"highest" => options.resolution_preference = ResolutionPreference::Highest,
"lowest" => options.resolution_preference = ResolutionPreference::Lowest,
custom => {
if let Some((width, height)) = custom.split_once('x') {
if let (Ok(w), Ok(h)) = (width.parse::<u32>(), height.parse::<u32>()) {
options.resolution_preference = ResolutionPreference::ClosestTo(w, h);
}
}
}
}
if !formats.is_empty() {
options.format_preference.formats = formats.to_vec();
}
if threshold > 0 && threshold <= 100 {
options.similarity_threshold = threshold;
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use std::time::SystemTime;
fn create_test_file_info(path: &str, size: u64) -> FileInfo {
FileInfo {
path: PathBuf::from(path),
size,
hash: Some("test_hash".to_string()),
modified_at: Some(SystemTime::now()),
created_at: Some(SystemTime::now()),
}
}
#[test]
fn test_ffmpeg_availability() {
let available = is_ffmpeg_available();
println!("FFmpeg available: {}", available);
}
#[test]
fn test_media_kind_from_extension() {
assert_eq!(detect_media_type(Path::new("test.jpg")), MediaKind::Image);
assert_eq!(detect_media_type(Path::new("test.png")), MediaKind::Image);
assert_eq!(detect_media_type(Path::new("test.mp4")), MediaKind::Video);
assert_eq!(detect_media_type(Path::new("test.mov")), MediaKind::Video);
assert_eq!(detect_media_type(Path::new("test.mp3")), MediaKind::Audio);
assert_eq!(detect_media_type(Path::new("test.wav")), MediaKind::Audio);
assert_eq!(detect_media_type(Path::new("test.txt")), MediaKind::Unknown);
}
#[test]
fn test_format_preference() {
let format_pref = FormatPreference::default();
assert!(
format_pref.formats.iter().position(|f| f == "raw").unwrap()
< format_pref.formats.iter().position(|f| f == "jpg").unwrap()
);
assert!(
format_pref.formats.iter().position(|f| f == "png").unwrap()
< format_pref.formats.iter().position(|f| f == "jpg").unwrap()
);
}
#[test]
fn test_resolution_preference_display() {
assert_eq!(ResolutionPreference::Highest.to_string(), "highest");
assert_eq!(ResolutionPreference::Lowest.to_string(), "lowest");
assert_eq!(
ResolutionPreference::ClosestTo(1280, 720).to_string(),
"closest to 1280x720"
);
}
#[test]
fn test_media_dedup_options_default() {
let options = MediaDedupOptions::default();
assert!(!options.enabled);
assert_eq!(options.similarity_threshold, 90);
match options.resolution_preference {
ResolutionPreference::Highest => (),
_ => panic!("Default resolution preference should be Highest"),
}
}
}