use crate::io_timeout::{wait_with_timeout, WaitOutcome};
use crate::semaphore::Semaphore;
use anyhow::Context as _;
use image::DynamicImage;
use std::path::Path;
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 fn decode_via_quicklook(
path: &Path,
tag: &str,
max_size: Option<u32>,
) -> anyhow::Result<DynamicImage> {
if !cfg!(target_os = "macos") {
warn_quicklook_unavailable_once();
return Err(anyhow::anyhow!("needs macOS QuickLook (`qlmanage`)")
.context(crate::error_kind::ErrorKind::QuicklookUnavailable));
}
let probe_ext = path
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_lowercase());
if matches!(probe_ext.as_deref(), Some("mov") | Some("mp4"))
&& !crate::video_probe::has_video_track(path)
{
anyhow::bail!("no video track (audio-only file); skipped without calling QuickLook");
}
let scratch = tempfile::Builder::new()
.prefix(&format!("videre_ql_{tag}_"))
.tempdir()
.context("create qlmanage temp dir")?;
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()
.context("run qlmanage (requires macOS)")?;
let outcome = wait_with_timeout(&mut child, QLMANAGE_TIMEOUT);
if outcome == WaitOutcome::TimedOut {
return Err(anyhow::anyhow!(
"qlmanage timed out after {}s decoding {} (file may be unreachable - is its drive connected?)",
QLMANAGE_TIMEOUT.as_secs(),
path.display()
)
.context(crate::error_kind::ErrorKind::SourceUnavailable));
}
anyhow::ensure!(
outcome == WaitOutcome::Success,
"qlmanage failed for {}",
path.display()
);
let file_name = path.file_name().context("path has no file name")?;
let out_file = out_dir.join(quicklook_output_name(file_name));
image::open(&out_file).with_context(|| format!("decode qlmanage output for {}", path.display()))
}
pub fn publish_cached_original(
cache: &crate::library::CachePaths,
hash: &str,
jpeg: &[u8],
) -> bool {
let destination = crate::thumb_cache::original_path_in(cache, hash);
let written = crate::atomic_file::publish(&destination, |file| {
use std::io::Write as _;
file.write_all(jpeg).map_err(Into::into)
});
if let Err(error) = written {
tracing::warn!("could not cache the full-resolution original for {hash}: {error:#}");
return false;
}
true
}
pub fn decode_fullres_cached(
path: &Path,
tag: &str,
cache: Option<(&crate::library::CachePaths, &str)>,
) -> anyhow::Result<DynamicImage> {
if let Some((cache, hash)) = cache {
let cached = crate::thumb_cache::original_path_in(cache, hash);
let opened =
crate::io_timeout::run_with_timeout(crate::io_timeout::DEFAULT_IO_TIMEOUT, move || {
image::open(&cached)
});
if let Ok(Ok(img)) = opened {
return Ok(img);
}
}
let img = decode_via_quicklook(path, tag, None)?;
if let Some((cache, hash)) = cache {
let mut jpeg = Vec::new();
if img
.write_to(
&mut std::io::Cursor::new(&mut jpeg),
image::ImageFormat::Jpeg,
)
.is_ok()
{
publish_cached_original(cache, hash, &jpeg);
}
}
Ok(img)
}
fn quicklook_output_name(file_name: &std::ffi::OsStr) -> std::ffi::OsString {
let mut name = file_name.to_os_string();
name.push(".png");
name
}
pub fn warn_if_timeout(error: &anyhow::Error) {
if crate::error_kind::ErrorKind::in_chain(error)
== Some(crate::error_kind::ErrorKind::SourceUnavailable)
{
crate::error_log::report(tracing::Level::WARN, error, None);
}
}
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.";
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)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_published_original_opens_as_a_jpeg() {
let temp = tempfile::tempdir().unwrap();
let ctx =
crate::library::LibraryContext::new(temp.path(), &temp.path().join("cache")).unwrap();
let img = image::RgbImage::from_pixel(2, 2, image::Rgb([255, 0, 0]));
let mut jpeg = Vec::new();
image::DynamicImage::ImageRgb8(img)
.write_to(
&mut std::io::Cursor::new(&mut jpeg),
image::ImageFormat::Jpeg,
)
.unwrap();
assert!(publish_cached_original(&ctx.cache, "pub-test", &jpeg));
let opened =
image::open(crate::thumb_cache::original_path_in(&ctx.cache, "pub-test")).unwrap();
assert_eq!((opened.width(), opened.height()), (2, 2));
}
#[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 || {
decode_via_quicklook(Path::new(video), "vposter480", Some(480))
})
})
.collect();
let ok = handles
.into_iter()
.map(|h| h.join().unwrap().is_ok())
.filter(|ok| *ok)
.count();
assert_eq!(ok, 4, "every simultaneous conversion must produce an image");
}
#[test]
#[cfg(target_os = "macos")]
fn an_audio_only_video_is_rejected_before_quicklook_is_called() {
let audio_only = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../videre/tests/fixtures/audio_only.mov"
);
let err = decode_via_quicklook(Path::new(audio_only), "audio-only-test", None).unwrap_err();
assert!(
err.to_string().contains("no video track"),
"unexpected error: {err:#}"
);
}
#[test]
fn without_quicklook_the_decode_fails_with_the_quicklook_unavailable_kind() {
if cfg!(target_os = "macos") {
return;
}
let err =
decode_via_quicklook(Path::new("/nonexistent.mov"), "kind-test", None).unwrap_err();
assert_eq!(
crate::error_kind::ErrorKind::in_chain(&err),
Some(crate::error_kind::ErrorKind::QuicklookUnavailable)
);
}
#[test]
#[cfg(unix)]
fn a_non_utf8_file_name_keeps_its_bytes_in_the_output_name() {
use std::os::unix::ffi::{OsStrExt, OsStringExt};
let name = std::ffi::OsStr::from_bytes(b"caf\xe9.heic");
assert_eq!(
quicklook_output_name(name).into_vec(),
b"caf\xe9.heic.png".to_vec()
);
}
#[test]
fn only_a_timeout_is_warned_for_callers_that_swallow_the_error() {
#[derive(Clone, Default)]
struct Buf(std::sync::Arc<std::sync::Mutex<Vec<u8>>>);
impl std::io::Write for Buf {
fn write(&mut self, b: &[u8]) -> std::io::Result<usize> {
self.0.lock().unwrap().extend_from_slice(b);
Ok(b.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
let buf = Buf::default();
let w = buf.clone();
let sub = tracing_subscriber::fmt()
.with_writer(move || w.clone())
.finish();
tracing::subscriber::with_default(sub, || {
warn_if_timeout(&anyhow::anyhow!("decode failed"));
warn_if_timeout(
&anyhow::anyhow!("qlmanage timed out")
.context(crate::error_kind::ErrorKind::SourceUnavailable),
);
});
let logged = String::from_utf8_lossy(&buf.0.lock().unwrap()).to_string();
assert!(logged.contains("qlmanage timed out"), "{logged}");
assert!(!logged.contains("decode failed"), "{logged}");
}
#[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);
}
}