fix(scan): quiet startup scans & thumbnail RAW/HEIC

Three recurring issues on every full scan:

1. Video playlist scans re-enqueued every file only to reject it as
   AlreadyExists. Pre-filter in ScanDirectoryMessage and QueueVideosMessage
   so we skip videos whose .m3u8 already exists, and demote the leaked
   AlreadyExists log to debug.

2. image crate was built with only jpeg/png features, so webp/tiff/avif
   files logged "format not supported" every scan. Enable those features.

3. RAW (ARW/NEF/CR2/...) and HEIC thumbnails weren't generated, so the
   scan kept retrying them. Try the file's embedded JPEG preview via
   kamadak-exif first (fast, pure-Rust, works on Sony ARW where ffmpeg's
   TIFF decoder fails). Fall back to ffmpeg for HEIC/HEIF and RAWs with
   no preview. Anything still undecodable gets a <thumb>.unsupported
   sentinel so future scans skip it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cameron
2026-04-23 20:47:13 -04:00
parent dc2a96162e
commit 13b9d54861
7 changed files with 300 additions and 48 deletions

View File

@@ -52,7 +52,8 @@ use crate::state::AppState;
use crate::tags::*;
use crate::video::actors::{
GeneratePreviewClipMessage, ProcessMessage, QueueVideosMessage, ScanDirectoryMessage,
VideoPlaylistManager, create_playlist, generate_video_thumbnail,
VideoPlaylistManager, create_playlist, generate_image_thumbnail_ffmpeg,
generate_video_thumbnail,
};
use log::{debug, error, info, trace, warn};
use opentelemetry::trace::{Span, Status, TraceContextExt, Tracer};
@@ -1060,6 +1061,47 @@ async fn delete_favorite(
}
}
/// Sentinel path written next to a would-be thumbnail when a file cannot be
/// decoded by either the `image` crate or ffmpeg. Its presence causes future
/// scans to skip the file instead of re-logging the failure.
pub fn unsupported_thumbnail_sentinel(thumb_path: &Path) -> PathBuf {
let mut s = thumb_path.as_os_str().to_owned();
s.push(".unsupported");
PathBuf::from(s)
}
fn generate_image_thumbnail(src: &Path, thumb_path: &Path) -> std::io::Result<()> {
// RAW formats (ARW/NEF/CR2/etc): try the file's embedded JPEG preview
// first. Avoids ffmpeg choking on proprietary RAW compression (Sony ARW
// in particular), and is faster than decoding RAW pixels anyway.
if let Some(preview) = exif::extract_embedded_jpeg_preview(src) {
let img = image::load_from_memory(&preview).map_err(|e| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("decode embedded preview {:?}: {}", src, e),
)
})?;
let scaled = img.thumbnail(200, u32::MAX);
scaled
.save_with_format(thumb_path, image::ImageFormat::Jpeg)
.map_err(|e| std::io::Error::other(format!("save {:?}: {}", thumb_path, e)))?;
return Ok(());
}
if file_types::needs_ffmpeg_thumbnail(src) {
return generate_image_thumbnail_ffmpeg(src, thumb_path);
}
let img = image::open(src).map_err(|e| {
std::io::Error::new(std::io::ErrorKind::InvalidData, format!("{:?}: {}", src, e))
})?;
let scaled = img.thumbnail(200, u32::MAX);
scaled
.save(thumb_path)
.map_err(|e| std::io::Error::other(format!("save {:?}: {}", thumb_path, e)))?;
Ok(())
}
fn create_thumbnails(libs: &[libraries::Library]) {
let tracer = global_tracer();
let span = tracer.start("creating thumbnails");
@@ -1080,17 +1122,26 @@ fn create_thumbnails(libs: &[libraries::Library]) {
.into_par_iter()
.filter_map(|entry| entry.ok())
.filter(|entry| entry.file_type().is_file())
.filter(|entry| {
if is_video(entry) {
let relative_path = &entry.path().strip_prefix(&images).unwrap();
let thumb_path = Path::new(thumbnail_directory).join(relative_path);
std::fs::create_dir_all(
thumb_path
.parent()
.unwrap_or_else(|| panic!("Thumbnail {:?} has no parent?", thumb_path)),
)
.expect("Error creating directory");
.for_each(|entry| {
let src = entry.path();
let Ok(relative_path) = src.strip_prefix(&images) else {
return;
};
let thumb_path = Path::new(thumbnail_directory).join(relative_path);
if thumb_path.exists() || unsupported_thumbnail_sentinel(&thumb_path).exists() {
return;
}
let Some(parent) = thumb_path.parent() else {
return;
};
if let Err(e) = std::fs::create_dir_all(parent) {
error!("Failed to create thumbnail dir {:?}: {}", parent, e);
return;
}
if is_video(&entry) {
let mut video_span = tracer.start_with_context(
"generate_video_thumbnail",
&opentelemetry::Context::new()
@@ -1103,37 +1154,24 @@ fn create_thumbnails(libs: &[libraries::Library]) {
]);
debug!("Generating video thumbnail: {:?}", thumb_path);
generate_video_thumbnail(entry.path(), &thumb_path);
generate_video_thumbnail(src, &thumb_path);
video_span.end();
false
} else {
is_image(entry)
} else if is_image(&entry) {
match generate_image_thumbnail(src, &thumb_path) {
Ok(_) => info!("Saved thumbnail: {:?}", thumb_path),
Err(e) => {
let sentinel = unsupported_thumbnail_sentinel(&thumb_path);
error!(
"Unable to thumbnail {:?}: {}. Writing sentinel {:?}",
src, e, sentinel
);
if let Err(se) = std::fs::write(&sentinel, b"") {
warn!("Failed to write sentinel {:?}: {}", sentinel, se);
}
}
}
}
})
.filter(|entry| {
let path = entry.path();
let relative_path = &path.strip_prefix(&images).unwrap();
let thumb_path = Path::new(thumbnail_directory).join(relative_path);
!thumb_path.exists()
})
.map(|entry| (image::open(entry.path()), entry.path().to_path_buf()))
.filter(|(img, path)| {
if let Err(e) = img {
error!("Unable to open image: {:?}. {}", path, e);
}
img.is_ok()
})
.map(|(img, path)| (img.unwrap(), path))
.map(|(image, path)| (image.thumbnail(200, u32::MAX), path))
.map(|(image, path)| {
let relative_path = &path.strip_prefix(&images).unwrap();
let thumb_path = Path::new(thumbnail_directory).join(relative_path);
std::fs::create_dir_all(thumb_path.parent().unwrap())
.expect("There was an issue creating directory");
info!("Saving thumbnail: {:?}", thumb_path);
image.save(thumb_path).expect("Failure saving thumbnail");
})
.for_each(drop);
});
}
debug!("Finished making thumbnails");
@@ -1744,7 +1782,8 @@ fn process_new_files(
// not just photos with parseable EXIF.
for (file_path, relative_path) in &files {
let thumb_path = thumbnail_directory.join(relative_path);
let needs_thumbnail = !thumb_path.exists();
let needs_thumbnail =
!thumb_path.exists() && !unsupported_thumbnail_sentinel(&thumb_path).exists();
let needs_row = !existing_exif_paths.contains_key(relative_path);
if needs_thumbnail || needs_row {