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(|| {
crate::error_log::report(tracing::Level::WARN, &quicklook_unavailable(), None)
});
}
fn quicklook_unavailable() -> anyhow::Error {
anyhow::anyhow!(QUICKLOOK_UNAVAILABLE)
.context(crate::error_kind::ErrorKind::QuicklookUnavailable)
}
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;
}
let scratch = tempfile::Builder::new()
.prefix(&format!("videre_ql_{tag}_"))
.tempdir()
.ok()?;
let out_dir = scratch.path();
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 {
tracing::warn!(
"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"));
if outcome == WaitOutcome::Success {
image::open(&out_file).ok()
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_quicklook_warning_carries_its_kind_and_the_full_explanation() {
let err = quicklook_unavailable();
assert_eq!(
crate::error_kind::ErrorKind::in_chain(&err),
Some(crate::error_kind::ErrorKind::QuicklookUnavailable)
);
assert!(format!("{err:#}").contains("qlmanage"));
}
#[test]
fn simultaneous_conversions_of_one_file_all_succeed() {
if !cfg!(target_os = "macos") {
return;
}
let video = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../videre/tests/fixtures/red_1s.mp4"
);
let handles: Vec<_> = (0..4)
.map(|_| std::thread::spawn(move || heic_via_quicklook(video, "vposter480", Some(480))))
.collect();
let ok = handles
.into_iter()
.map(|h| h.join().unwrap().is_some())
.filter(|ok| *ok)
.count();
assert_eq!(ok, 4, "every simultaneous conversion must produce an image");
}
#[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);
}
}