use std::path::{Path, PathBuf}; use std::process::Command; use tracing::{info, warn}; use crate::config::ThumbnailConfig; use crate::error::{PinakesError, Result}; use crate::media_type::{MediaCategory, MediaType}; use crate::model::MediaId; /// Generate a thumbnail for a media file and return the path to the thumbnail. /// /// Supports images (via `image` crate), videos (via ffmpeg), PDFs (via pdftoppm), /// and EPUBs (via cover image extraction). pub fn generate_thumbnail( media_id: MediaId, source_path: &Path, media_type: MediaType, thumbnail_dir: &Path, ) -> Result> { generate_thumbnail_with_config( media_id, source_path, media_type, thumbnail_dir, &ThumbnailConfig::default(), ) } pub fn generate_thumbnail_with_config( media_id: MediaId, source_path: &Path, media_type: MediaType, thumbnail_dir: &Path, config: &ThumbnailConfig, ) -> Result> { std::fs::create_dir_all(thumbnail_dir)?; let thumb_path = thumbnail_dir.join(format!("{}.jpg", media_id)); let result = match media_type.category() { MediaCategory::Image => { if media_type.is_raw() { generate_raw_thumbnail(source_path, &thumb_path, config) } else if media_type == MediaType::Heic { generate_heic_thumbnail(source_path, &thumb_path, config) } else { generate_image_thumbnail(source_path, &thumb_path, config) } } MediaCategory::Video => generate_video_thumbnail(source_path, &thumb_path, config), MediaCategory::Document => match media_type { MediaType::Pdf => generate_pdf_thumbnail(source_path, &thumb_path, config), MediaType::Epub => generate_epub_thumbnail(source_path, &thumb_path, config), _ => return Ok(None), }, _ => return Ok(None), }; match result { Ok(()) => { info!(media_id = %media_id, category = ?media_type.category(), "generated thumbnail"); Ok(Some(thumb_path)) } Err(e) => { warn!(media_id = %media_id, error = %e, "failed to generate thumbnail"); Ok(None) } } } fn generate_image_thumbnail(source: &Path, dest: &Path, config: &ThumbnailConfig) -> Result<()> { let img = image::open(source) .map_err(|e| PinakesError::MetadataExtraction(format!("image open: {e}")))?; let thumb = img.thumbnail(config.size, config.size); let mut output = std::fs::File::create(dest)?; let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut output, config.quality); thumb .write_with_encoder(encoder) .map_err(|e| PinakesError::MetadataExtraction(format!("thumbnail encode: {e}")))?; Ok(()) } fn generate_video_thumbnail(source: &Path, dest: &Path, config: &ThumbnailConfig) -> Result<()> { let ffmpeg = config.ffmpeg_path.as_deref().unwrap_or("ffmpeg"); let status = Command::new(ffmpeg) .args(["-ss", &config.video_seek_secs.to_string(), "-i"]) .arg(source) .args([ "-vframes", "1", "-vf", &format!("scale={}:{}", config.size, config.size), "-y", ]) .arg(dest) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .status() .map_err(|e| { PinakesError::MetadataExtraction(format!("ffmpeg not found or failed to execute: {e}")) })?; if !status.success() { return Err(PinakesError::MetadataExtraction(format!( "ffmpeg exited with status {}", status ))); } Ok(()) } fn generate_pdf_thumbnail(source: &Path, dest: &Path, config: &ThumbnailConfig) -> Result<()> { // Use pdftoppm to render first page, then resize with image crate let temp_prefix = dest.with_extension("tmp"); let status = Command::new("pdftoppm") .args(["-jpeg", "-f", "1", "-l", "1", "-singlefile"]) .arg(source) .arg(&temp_prefix) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .status() .map_err(|e| { PinakesError::MetadataExtraction(format!( "pdftoppm not found or failed to execute: {e}" )) })?; if !status.success() { return Err(PinakesError::MetadataExtraction(format!( "pdftoppm exited with status {}", status ))); } // pdftoppm outputs .jpg let rendered = temp_prefix.with_extension("jpg"); if rendered.exists() { // Resize to thumbnail size let img = image::open(&rendered) .map_err(|e| PinakesError::MetadataExtraction(format!("pdf thumbnail open: {e}")))?; let thumb = img.thumbnail(config.size, config.size); let mut output = std::fs::File::create(dest)?; let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut output, config.quality); thumb .write_with_encoder(encoder) .map_err(|e| PinakesError::MetadataExtraction(format!("pdf thumbnail encode: {e}")))?; let _ = std::fs::remove_file(&rendered); Ok(()) } else { Err(PinakesError::MetadataExtraction( "pdftoppm did not produce output".to_string(), )) } } fn generate_epub_thumbnail(source: &Path, dest: &Path, config: &ThumbnailConfig) -> Result<()> { // Try to extract cover image from EPUB let mut doc = epub::doc::EpubDoc::new(source) .map_err(|e| PinakesError::MetadataExtraction(format!("epub open: {e}")))?; let cover_data = doc.get_cover().map(|(data, _mime)| data).or_else(|| { // Fallback: try to find a cover image in the resources doc.get_resource("cover-image") .map(|(data, _)| data) .or_else(|| doc.get_resource("cover").map(|(data, _)| data)) }); if let Some(data) = cover_data { let img = image::load_from_memory(&data) .map_err(|e| PinakesError::MetadataExtraction(format!("epub cover decode: {e}")))?; let thumb = img.thumbnail(config.size, config.size); let mut output = std::fs::File::create(dest)?; let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut output, config.quality); thumb .write_with_encoder(encoder) .map_err(|e| PinakesError::MetadataExtraction(format!("epub thumbnail encode: {e}")))?; Ok(()) } else { Err(PinakesError::MetadataExtraction( "no cover image found in epub".to_string(), )) } } fn generate_raw_thumbnail(source: &Path, dest: &Path, config: &ThumbnailConfig) -> Result<()> { // Try dcraw to extract embedded JPEG preview, then resize let temp_ppm = dest.with_extension("ppm"); let status = Command::new("dcraw") .args(["-e", "-c"]) .arg(source) .stdout(std::fs::File::create(&temp_ppm).map_err(|e| { PinakesError::MetadataExtraction(format!("failed to create temp file: {e}")) })?) .stderr(std::process::Stdio::null()) .status() .map_err(|e| PinakesError::MetadataExtraction(format!("dcraw not found or failed: {e}")))?; if !status.success() { let _ = std::fs::remove_file(&temp_ppm); return Err(PinakesError::MetadataExtraction(format!( "dcraw exited with status {}", status ))); } // The extracted preview is typically a JPEG — try loading it if temp_ppm.exists() { let result = image::open(&temp_ppm); let _ = std::fs::remove_file(&temp_ppm); let img = result .map_err(|e| PinakesError::MetadataExtraction(format!("raw preview decode: {e}")))?; let thumb = img.thumbnail(config.size, config.size); let mut output = std::fs::File::create(dest)?; let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut output, config.quality); thumb .write_with_encoder(encoder) .map_err(|e| PinakesError::MetadataExtraction(format!("raw thumbnail encode: {e}")))?; Ok(()) } else { Err(PinakesError::MetadataExtraction( "dcraw did not produce output".to_string(), )) } } fn generate_heic_thumbnail(source: &Path, dest: &Path, config: &ThumbnailConfig) -> Result<()> { // Use heif-convert to convert to JPEG, then resize let temp_jpg = dest.with_extension("tmp.jpg"); let status = Command::new("heif-convert") .arg(source) .arg(&temp_jpg) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .status() .map_err(|e| { PinakesError::MetadataExtraction(format!("heif-convert not found or failed: {e}")) })?; if !status.success() { let _ = std::fs::remove_file(&temp_jpg); return Err(PinakesError::MetadataExtraction(format!( "heif-convert exited with status {}", status ))); } if temp_jpg.exists() { let result = image::open(&temp_jpg); let _ = std::fs::remove_file(&temp_jpg); let img = result.map_err(|e| PinakesError::MetadataExtraction(format!("heic decode: {e}")))?; let thumb = img.thumbnail(config.size, config.size); let mut output = std::fs::File::create(dest)?; let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut output, config.quality); thumb .write_with_encoder(encoder) .map_err(|e| PinakesError::MetadataExtraction(format!("heic thumbnail encode: {e}")))?; Ok(()) } else { Err(PinakesError::MetadataExtraction( "heif-convert did not produce output".to_string(), )) } } /// Returns the default thumbnail directory under the data dir. pub fn default_thumbnail_dir() -> PathBuf { crate::config::Config::default_data_dir().join("thumbnails") }