videre-core 0.49.0

Shared SQLite, caching, and search helpers for the videre media library CLI
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
use crate::io_timeout::{wait_with_timeout, WaitOutcome};
use crate::semaphore::Semaphore;
use anyhow::Context as _;
use image::DynamicImage;
use std::fs::File;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use std::time::Duration;

/// Ceiling for a single `qlmanage` conversion. A disconnected/stale external
/// volume can make `qlmanage`'s own file access block indefinitely on macOS
/// rather than fail fast, which otherwise freezes the calling command with
/// no output; this bounds that to a single conversion's worth of waiting.
const QLMANAGE_TIMEOUT: Duration = Duration::from_secs(20);

/// Default max `qlmanage` processes running at once, process-wide, unless
/// overridden via `set_qlmanage_concurrency` (e.g. `videre faces
/// --qlmanage-concurrency <n>`). QuickLook's thumbnail-generation agent is a
/// single shared per-user service, not something that scales with parallel
/// callers, a UI rendering hundreds of HEIC thumbnails at once (or two
/// videre processes both hitting the same library concurrently) can launch
/// enough simultaneous `qlmanage` processes to make
/// the agent and the source drive's I/O queue up, causing conversions that
/// would normally take under a second to occasionally exceed even
/// `QLMANAGE_TIMEOUT`. Capping concurrency here keeps every caller (axum
/// server, faces/embed/watch, and any other embedder) well behaved without
/// needing to coordinate with each other. Raised from 3 to 6 on 2026-07-29 after real
/// A/B measurement of `videre faces`'s parallel pipeline showed HEIC-heavy
/// runs leaving CPU idle (477% of 1000% on a 10-core machine), a real
/// bottleneck candidate given `--workers` now defaults to 2x cores (up to
/// 20 concurrent workers, all previously queuing on a 3-permit cap). With a
/// slot directory set (every videre command sets one) the cap is also
/// machine-wide, shared with other videre processes; see `acquire_slot`.
const QLMANAGE_MAX_CONCURRENT_DEFAULT: usize = 6;

/// Process-wide override for `QLMANAGE_MAX_CONCURRENT_DEFAULT`, set at most
/// once per process (first call wins, matching the underlying semaphore's
/// own `OnceLock` semantics). See `set_qlmanage_concurrency`.
static QLMANAGE_CONCURRENCY_OVERRIDE: OnceLock<usize> = OnceLock::new();

/// Overrides the `qlmanage` concurrency cap for the remainder of this
/// process. Must be called before the first HEIC conversion (i.e. before
/// anything calls `qlmanage_semaphore()`) to take effect, like the
/// semaphore itself, this is a `OnceLock`: the first call sets the value,
/// every later call (including one after the semaphore has already been
/// created with the default) is a no-op. Intended for CLI flags like
/// `videre faces --qlmanage-concurrency <n>` to call once at startup.
pub fn set_qlmanage_concurrency(n: usize) {
    let _ = QLMANAGE_CONCURRENCY_OVERRIDE.set(n);
}

/// Pure resolution of the effective concurrency cap, split out from
/// `qlmanage_semaphore` so the "override if present, else default" logic is
/// unit-testable without touching the process-wide `OnceLock` singletons.
fn resolve_qlmanage_concurrency(override_val: Option<usize>) -> usize {
    override_val.unwrap_or(QLMANAGE_MAX_CONCURRENT_DEFAULT)
}

/// Shared by every `qlmanage` conversion in the codebase, so the
/// concurrency cap is process-wide rather than per-call-site.
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)
    })
}

/// Where this process's machine-wide QuickLook slots live, set once at
/// startup (`set_quicklook_slot_dir`). Unset, as for an embedder or a unit
/// test, the in-process semaphore is the only cap.
static QUICKLOOK_SLOT_DIR: OnceLock<PathBuf> = OnceLock::new();

/// How long a conversion sleeps before retrying when every slot is taken.
const SLOT_POLL: Duration = Duration::from_millis(50);

/// Share the QuickLook cap with every other videre process that uses `dir`.
/// First call wins, like `set_qlmanage_concurrency`.
pub fn set_quicklook_slot_dir(dir: PathBuf) {
    let _ = QUICKLOOK_SLOT_DIR.set(dir);
}

/// One held machine-wide QuickLook slot. Dropping it closes the file, which
/// releases the lock.
struct QuicklookSlot(#[allow(dead_code)] File);

/// Take one of the `n` slots `slot-0.lock .. slot-{n-1}.lock` in `dir`,
/// waiting until one is free.
///
/// The in-process semaphore alone let two videre processes run twice the
/// intended conversions against the one per-user QuickLook agent; measured,
/// a HEIC that converts in 0.39 s alone then exceeded `QLMANAGE_TIMEOUT`.
/// Each conversion therefore also holds an exclusive `flock` on a slot file
/// shared by every videre process on the machine.
///
/// - `n` is this process's own cap, so processes with the same cap share the
///   same slots: the machine total is the largest cap in use, not the sum.
/// - The wait happens before `qlmanage` is spawned, so it never counts toward
///   `QLMANAGE_TIMEOUT`: contention slows a run instead of skipping files.
/// - The kernel releases a flock when its process dies, so a crash leaves no
///   stale token to clean up.
/// - Accepted gap: a holder that is stopped (Ctrl-Z) keeps its slot until it
///   resumes or dies, and other processes wait for it.
///
/// The files are created once and never unlinked: the lock lives on the
/// inode, so removing a held file would let a second, independent lock form.
fn acquire_slot(dir: &Path, n: usize, poll: Duration) -> std::io::Result<QuicklookSlot> {
    acquire_slot_with(dir, n, poll, fs2::FileExt::try_lock_exclusive)
}

/// `acquire_slot` with the lock attempt passed in, so a test can stand in
/// for a filesystem that refuses locking.
fn acquire_slot_with(
    dir: &Path,
    n: usize,
    poll: Duration,
    try_lock: impl Fn(&File) -> std::io::Result<()>,
) -> std::io::Result<QuicklookSlot> {
    std::fs::create_dir_all(dir)?;
    let slots = (0..n.max(1))
        .map(|i| {
            std::fs::OpenOptions::new()
                .create(true)
                .write(true)
                .truncate(false)
                .open(dir.join(format!("slot-{i}.lock")))
        })
        .collect::<std::io::Result<Vec<File>>>()?;
    loop {
        for slot in &slots {
            // Contention is the only reason to wait. Any other error (a
            // filesystem without flock support) goes back to the caller,
            // which falls back to the in-process cap instead of spinning.
            match try_lock(slot) {
                Ok(()) => return Ok(QuicklookSlot(slot.try_clone()?)),
                Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {}
                Err(error) => return Err(error),
            }
        }
        std::thread::sleep(poll);
    }
}

/// This process's machine-wide slot, or `None` when no slot directory is set
/// or it cannot be used; the latter warns once and falls back to the
/// in-process cap, which is how videre behaved before slots existed.
fn machine_slot() -> Option<QuicklookSlot> {
    let dir = QUICKLOOK_SLOT_DIR.get()?;
    match acquire_slot(dir, qlmanage_semaphore().max(), SLOT_POLL) {
        Ok(slot) => Some(slot),
        Err(error) => {
            static WARNED: std::sync::Once = std::sync::Once::new();
            WARNED.call_once(|| {
                tracing::warn!(
                    "QuickLook slots in {} are unusable, limiting conversions in this process only: {error}",
                    dir.display()
                )
            });
            None
        }
    }
}

/// Convert an image or video file to a `DynamicImage` via QuickLook
/// (`qlmanage -t`). The one QuickLook decode path in the workspace: HEIC
/// photos (the `image` crate cannot decode HEIC) and video poster frames
/// (QuickLook generates one the same way it does for HEIC) decode here,
/// from every crate.
///
/// `sips -s format jpeg` copies the raw sensor-buffer pixels unrotated for
/// HEIC files where the camera encoded rotation via the HEIF `irot`
/// transform box rather than a classic EXIF Orientation tag, the same
/// rotation Finder/Preview/Photos apply via QuickLook. Using `sips` would
/// produce sideways images (or, for dupe-faces, detect faces and compute
/// bounding boxes against the wrongly oriented image).
///
/// `tag` disambiguates concurrent/repeated conversions of the same path for
/// different purposes (e.g. a 240px thumbnail vs a 1200px lightbox version,
/// or a HEIC conversion next to a video conversion of a same-named file) so
/// their temp-directory names don't collide.
///
/// `max_size` caps `qlmanage -s`'s longest-side render size. `qlmanage -s`
/// only ever caps, never upscales, so `None` (rendered as `10000`,
/// comfortably above any real photo's native resolution) means "give me the
/// native/full resolution", the only safe choice for callers whose output
/// pixels must stay in the same coordinate space as something already
/// stored (face detection bboxes, face-thumbnail crops) or that need true
/// full quality (serving the original image). `Some(n)` is only safe for
/// callers that immediately downscale the result themselves anyway (report
/// thumbnails, gallery posters, the embed path's double-the-tensor-size
/// renders); for them, requesting a smaller render up front avoids qlmanage
/// decoding/resizing/PNG-encoding pixels that get thrown away moments
/// later. Do NOT pass `Some` for the face-detection or face-thumbnail-crop
/// call sites: detection's bbox coordinates are stored in terms of whatever
/// image size detection ran on (see `face_detect.rs`), so shrinking that
/// decode would silently corrupt every later full-res thumbnail crop and
/// the `--min-face-size` quality gate, which measures bbox size in that
/// same (assumed-full-res) space.
///
/// Video wall time does not scale with file size (one seek, one frame):
/// timed against the 5 largest real videos of a real library (2.3-5.7GB)
/// on 2026-08-01, all poster extractions finished in 0.22-0.36s, so the 20s
/// ceiling has ~50-90x headroom even for the largest real files measured.
pub fn decode_via_quicklook(
    path: &Path,
    tag: &str,
    max_size: Option<u32>,
) -> anyhow::Result<DynamicImage> {
    if !cfg!(target_os = "macos") {
        // Long explanation once per run; short typed reason per file. The
        // warning has already fired by the time the caller sees this error.
        warn_quicklook_unavailable_once();
        return Err(anyhow::anyhow!("needs macOS QuickLook (`qlmanage`)")
            .context(crate::error_kind::ErrorKind::QuicklookUnavailable));
    }
    // `qlmanage -t` does not fail on a container with no video track, it
    // hangs. Extension-gated so HEIC, which shares this function, is
    // untouched, and placed before the semaphore so a skipped file never
    // holds a permit. See `video_probe` for the measurement behind this.
    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");
    }
    // A scratch directory of this call's own, removed when `scratch` drops,
    // so simultaneous decodes of one file cannot delete each other's output
    // (the gallery requesting an uncached video poster twice at once once
    // got a 404 when a shared, path-named directory made that possible).
    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 _slot = machine_slot();
    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);
    // Returned, never logged here: a caller that reports its errors would log
    // it a second time. Callers that swallow the error pass it through
    // `warn_if_timeout` so a disconnected drive still reaches the log.
    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()))
}

/// Encode `jpeg` into the library's cached original for `hash`, atomically:
/// every writer gets its own temporary file (`atomic_file::publish`), so two
/// concurrent savers in one gallery process can never rename a half-written
/// entry into place. Best-effort: a failure is logged and reported, never
/// fatal to the decode the bytes came from.
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
}

/// Full-resolution HEIC decode for a library context: the cached original
/// (`thumb_cache::original_path_in`) when it is there, opened under the I/O
/// timeout; a fresh `decode_via_quicklook` render otherwise. A render is
/// published back to the cache, so the first request for a hash pays for it
/// and the rest read it: five faces on one photo render once, not five
/// times. `cache` `None`: render only. A missing, corrupt, or stale entry
/// is replaced by the render that follows it. The published entry is the
/// same upright, full-resolution QuickLook output the bbox geometry
/// expects; open it with plain `image::open`, never
/// `image_decode::decode_oriented_file` (double rotation).
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);
        }
    }
    // A QuickLook timeout is the caller's to log: the two callers either
    // swallow the error (`make_face_thumb`, via `warn_if_timeout`) or
    // propagate it to a worker that logs it (`load_image`), and calling it
    // here as well would log a detection timeout twice.
    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)
}

/// `qlmanage -t` writes `<file name>.png`, byte for byte. Built as an OS
/// string so a file name that is not valid UTF-8 still finds its output.
fn quicklook_output_name(file_name: &std::ffi::OsStr) -> std::ffi::OsString {
    let mut name = file_name.to_os_string();
    name.push(".png");
    name
}

/// For callers that swallow a QuickLook decode failure (a thumbnail, a
/// poster, a cache fill): a timeout still reaches the log, since it usually
/// means a disconnected drive. Other failures stay quiet there, as before.
/// Callers that return the error must not call this, or it is logged twice.
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);
    }
}

/// Message shared by every QuickLook entry point, so a non-macOS user gets one
/// clear explanation instead of a stream of opaque per-file decode failures.
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.";

/// Prints `QUICKLOOK_UNAVAILABLE` at most once per process. Called on the
/// non-macOS path of the QuickLook decoder: without it a Linux user just sees
/// each HEIC/video silently fail to decode, with nothing saying why.
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)
    });
}

/// The QuickLook-unavailable warning, carrying its kind so the log records
/// why HEIC and video files were skipped on this platform.
fn quicklook_unavailable() -> anyhow::Error {
    anyhow::anyhow!(QUICKLOOK_UNAVAILABLE)
        .context(crate::error_kind::ErrorKind::QuicklookUnavailable)
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Hold `slot-{i}` the way another process would: through its own open
    /// file, so the flock is a separate lock from any the code takes.
    fn hold(dir: &Path, i: usize) -> std::fs::File {
        use fs2::FileExt;
        std::fs::create_dir_all(dir).unwrap();
        let file = std::fs::OpenOptions::new()
            .create(true)
            .write(true)
            .truncate(false)
            .open(dir.join(format!("slot-{i}.lock")))
            .unwrap();
        file.try_lock_exclusive().unwrap();
        file
    }

    fn is_held(dir: &Path, i: usize) -> bool {
        use fs2::FileExt;
        let file = std::fs::File::open(dir.join(format!("slot-{i}.lock"))).unwrap();
        file.try_lock_exclusive().is_err()
    }

    #[test]
    fn a_free_slot_is_taken() {
        let temp = tempfile::tempdir().unwrap();
        let dir = temp.path().join("quicklook");
        let _other = hold(&dir, 0);
        let _slot = acquire_slot(&dir, 2, Duration::from_millis(10)).unwrap();
        assert!(is_held(&dir, 1), "the free slot is the one taken");
    }

    #[test]
    fn a_full_pool_waits_for_a_release() {
        let temp = tempfile::tempdir().unwrap();
        let dir = temp.path().join("quicklook");
        let first = hold(&dir, 0);
        let _second = hold(&dir, 1);
        let release = std::thread::spawn(move || {
            std::thread::sleep(Duration::from_millis(300));
            drop(first);
        });
        let started = std::time::Instant::now();
        let _slot = acquire_slot(&dir, 2, Duration::from_millis(10)).unwrap();
        assert!(started.elapsed() >= Duration::from_millis(300));
        release.join().unwrap();
    }

    #[test]
    fn a_released_slot_is_free_again() {
        let temp = tempfile::tempdir().unwrap();
        let dir = temp.path().join("quicklook");
        drop(acquire_slot(&dir, 1, Duration::from_millis(10)).unwrap());
        assert!(!is_held(&dir, 0));
    }

    #[test]
    fn a_filesystem_that_rejects_locking_is_an_error_not_a_wait() {
        // Only contention means "wait": any other lock error (a filesystem
        // without flock support) must reach the caller's fallback, or every
        // conversion would spin here forever before qlmanage starts.
        let temp = tempfile::tempdir().unwrap();
        let dir = temp.path().join("quicklook");
        let (tx, rx) = std::sync::mpsc::channel();
        std::thread::spawn(move || {
            let rejected = |_: &File| Err(std::io::Error::from(std::io::ErrorKind::Unsupported));
            let _ = tx.send(acquire_slot_with(&dir, 2, Duration::from_millis(10), rejected).err());
        });
        let error = rx
            .recv_timeout(Duration::from_secs(5))
            .expect("acquire returned instead of waiting")
            .expect("a lock that is refused outright is an error");
        assert_eq!(error.kind(), std::io::ErrorKind::Unsupported);
    }

    #[test]
    fn an_unusable_directory_is_an_error() {
        let temp = tempfile::tempdir().unwrap();
        let dir = temp.path().join("quicklook");
        std::fs::write(&dir, b"not a directory").unwrap();
        assert!(acquire_slot(&dir, 2, Duration::from_millis(10)).is_err());
    }

    #[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"));
    }

    /// Simultaneous conversions of one file must not share a scratch
    /// directory. They did: it was named from the path and tag, and each call
    /// cleared it before and after running, so one call deleted another's
    /// output and failed. The gallery's tile view hit this whenever the page
    /// and a second request asked for the same uncached video poster.
    #[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");
    }

    /// An audio-only mov/mp4 must be rejected before `qlmanage` is spawned:
    /// qlmanage does not fail on such containers, it hangs until
    /// QLMANAGE_TIMEOUT (20s). Reached across crates the same way the
    /// simultaneous-conversions test above reaches red_1s.mp4.
    #[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:#}"
        );
    }

    /// Off macOS the shared decoder fails with the typed
    /// QuicklookUnavailable kind, so pipeline runs record the real reason
    /// and callers that swallow the error have already seen the long
    /// once-per-process warning.
    #[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() {
        // Not clamped here, resolve_qlmanage_concurrency is pure plumbing;
        // Semaphore::new(0) blocking forever is a caller-input-validation
        // concern (see the --qlmanage-concurrency CLI flag), not this
        // function's job.
        assert_eq!(resolve_qlmanage_concurrency(Some(0)), 0);
    }

    // A real conversion through decode_via_quicklook is not unit tested:
    // qlmanage does not fail fast on a nonexistent path, it hangs until
    // QLMANAGE_TIMEOUT (20s), exactly the slow-path this module's own
    // timeout mechanism exists to bound. The audio-only test above exercises
    // the pre-spawn guard without spawning qlmanage; real conversions are
    // exercised in practice by videre faces/report/embed/watch against real
    // HEIC files.
}