use crate::io_timeout::{wait_with_timeout, WaitOutcome};
use crate::semaphore::Semaphore;
use image::DynamicImage;
use std::sync::OnceLock;
use std::time::Duration;
const QLMANAGE_TIMEOUT: Duration = Duration::from_secs(20);
const QLMANAGE_MAX_CONCURRENT_DEFAULT: usize = 6;
static QLMANAGE_CONCURRENCY_OVERRIDE: OnceLock<usize> = OnceLock::new();
pub fn set_qlmanage_concurrency(n: usize) {
let _ = QLMANAGE_CONCURRENCY_OVERRIDE.set(n);
}
fn resolve_qlmanage_concurrency(override_val: Option<usize>) -> usize {
override_val.unwrap_or(QLMANAGE_MAX_CONCURRENT_DEFAULT)
}
pub fn qlmanage_semaphore() -> &'static Semaphore {
static SEM: OnceLock<Semaphore> = OnceLock::new();
SEM.get_or_init(|| {
let max = resolve_qlmanage_concurrency(QLMANAGE_CONCURRENCY_OVERRIDE.get().copied());
Semaphore::new(max)
})
}
pub const QUICKLOOK_UNAVAILABLE: &str =
"HEIC images and video frames are decoded via macOS QuickLook (`qlmanage`), \
which has no equivalent on this platform - those files are skipped. \
Scanning, dedupe, and search still work for jpg/jpeg/png/gif/webp/bmp/tiff.";
pub fn warn_quicklook_unavailable_once() {
static WARNED: std::sync::Once = std::sync::Once::new();
WARNED.call_once(|| eprintln!("warning: {QUICKLOOK_UNAVAILABLE}"));
}
pub fn heic_via_quicklook(path: &str, tag: &str, max_size: Option<u32>) -> Option<DynamicImage> {
if !cfg!(target_os = "macos") {
warn_quicklook_unavailable_once();
return None;
}
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
path.hash(&mut hasher);
tag.hash(&mut hasher);
let out_dir = std::env::temp_dir().join(format!("dupe_ql_{:016x}", hasher.finish()));
let _ = std::fs::remove_dir_all(&out_dir);
std::fs::create_dir_all(&out_dir).ok()?;
let _permit = qlmanage_semaphore().acquire();
let size_arg = max_size.unwrap_or(10000).to_string();
let mut child = std::process::Command::new("qlmanage")
.args(["-t", "-s", &size_arg, "-o"])
.arg(&out_dir)
.arg(path)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.ok()?;
let outcome = wait_with_timeout(&mut child, QLMANAGE_TIMEOUT);
if outcome == WaitOutcome::TimedOut {
eprintln!(
"warning: qlmanage timed out after {}s converting {path} (file may be unreachable - is its drive disconnected?); skipping",
QLMANAGE_TIMEOUT.as_secs()
);
}
let file_name = std::path::Path::new(path).file_name()?.to_str()?;
let out_file = out_dir.join(format!("{file_name}.png"));
let result = if outcome == WaitOutcome::Success { image::open(&out_file).ok() } else { None };
let _ = std::fs::remove_dir_all(&out_dir);
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolve_qlmanage_concurrency_uses_override_when_present() {
assert_eq!(resolve_qlmanage_concurrency(Some(10)), 10);
}
#[test]
fn resolve_qlmanage_concurrency_falls_back_to_default_when_absent() {
assert_eq!(resolve_qlmanage_concurrency(None), QLMANAGE_MAX_CONCURRENT_DEFAULT);
}
#[test]
fn resolve_qlmanage_concurrency_override_of_zero_is_honored_literally() {
assert_eq!(resolve_qlmanage_concurrency(Some(0)), 0);
}
}