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:
121
src/main.rs
121
src/main.rs
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user