use std::{ path::{Path, PathBuf}, process::Command, }; use tracing::{info, warn}; use crate::{ config::ThumbnailConfig, error::{PinakesError, Result}, media_type::{BuiltinMediaType, MediaCategory, MediaType}, 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::Builtin(BuiltinMediaType::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::Builtin(BuiltinMediaType::Pdf) => { generate_pdf_thumbnail(source_path, &thumb_path, config) }, MediaType::Builtin(BuiltinMediaType::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(), )) } } /// Cover size variants for book covers #[derive(Debug, Clone, Copy)] pub enum CoverSize { Tiny, // 64x64 - for map markers, timeline Grid, // 320x320 - for library grid view Preview, // 1024x1024 - for quick fullscreen preview Original, // Full size - original cover } impl CoverSize { pub fn dimensions(&self) -> Option<(u32, u32)> { match self { CoverSize::Tiny => Some((64, 64)), CoverSize::Grid => Some((320, 320)), CoverSize::Preview => Some((1024, 1024)), CoverSize::Original => None, // No resizing } } pub fn filename(&self) -> &'static str { match self { CoverSize::Tiny => "tiny.jpg", CoverSize::Grid => "grid.jpg", CoverSize::Preview => "preview.jpg", CoverSize::Original => "original.jpg", } } } /// Generate multi-resolution covers for a book pub fn generate_book_covers( media_id: MediaId, source_image: &[u8], covers_dir: &Path, ) -> Result> { // Create cover directory for this media item let media_cover_dir = covers_dir.join(media_id.to_string()); std::fs::create_dir_all(&media_cover_dir)?; let img = image::load_from_memory(source_image).map_err(|e| { PinakesError::MetadataExtraction(format!("cover image load: {e}")) })?; let mut results = Vec::new(); // Generate each size variant for size in [ CoverSize::Tiny, CoverSize::Grid, CoverSize::Preview, CoverSize::Original, ] { let cover_path = media_cover_dir.join(size.filename()); match size.dimensions() { Some((width, height)) => { // Generate thumbnail let thumb = img.thumbnail(width, height); let mut output = std::fs::File::create(&cover_path)?; let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut output, 90); thumb.write_with_encoder(encoder).map_err(|e| { PinakesError::MetadataExtraction(format!("cover encode: {e}")) })?; }, None => { // Save original let mut output = std::fs::File::create(&cover_path)?; let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut output, 95); img.write_with_encoder(encoder).map_err(|e| { PinakesError::MetadataExtraction(format!("cover encode: {e}")) })?; }, } results.push((size, cover_path)); } Ok(results) } /// Extract full-size cover from an EPUB file pub fn extract_epub_cover(epub_path: &Path) -> Result>> { let mut doc = epub::doc::EpubDoc::new(epub_path) .map_err(|e| PinakesError::MetadataExtraction(format!("EPUB open: {e}")))?; // Try to get the cover image if let Some(cover_id) = doc.get_cover_id() && let Some((cover_data, _mime)) = doc.get_resource(&cover_id) { return Ok(Some(cover_data)); } // Fallback: look for common cover image filenames let cover_names = [ "cover.jpg", "cover.jpeg", "cover.png", "Cover.jpg", "Cover.jpeg", "Cover.png", ]; for name in &cover_names { if let Some(data) = doc.get_resource_by_path(name) { return Ok(Some(data)); } } Ok(None) } /// Extract full-size cover from a PDF file (first page) pub fn extract_pdf_cover(pdf_path: &Path) -> Result>> { // Use pdftoppm to extract the first page at high resolution let pdftoppm = "pdftoppm"; let temp_dir = std::env::temp_dir(); let temp_prefix = temp_dir.join(format!("pdf_cover_{}", uuid::Uuid::new_v4())); let status = Command::new(pdftoppm) .args(["-jpeg", "-f", "1", "-l", "1", "-scale-to", "1200"]) .arg(pdf_path) .arg(&temp_prefix) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .status() .map_err(|e| PinakesError::MetadataExtraction(format!("pdftoppm: {e}")))?; if !status.success() { return Err(PinakesError::MetadataExtraction(format!( "pdftoppm exited with status {}", status ))); } // pdftoppm outputs files like prefix-1.jpg let output_path = format!("{}-1.jpg", temp_prefix.display()); let output_pathbuf = PathBuf::from(&output_path); if output_pathbuf.exists() { let data = std::fs::read(&output_pathbuf)?; let _ = std::fs::remove_file(&output_pathbuf); Ok(Some(data)) } else { Ok(None) } } /// Returns the default covers directory under the data dir pub fn default_covers_dir() -> PathBuf { crate::config::Config::default_data_dir().join("covers") } /// Returns the default thumbnail directory under the data dir. pub fn default_thumbnail_dir() -> PathBuf { crate::config::Config::default_data_dir().join("thumbnails") } /// Thumbnail size variant for multi-resolution support #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ThumbnailSize { /// Tiny thumbnail for map markers and icons (64x64) Tiny, /// Grid thumbnail for library grid view (320x320) Grid, /// Preview thumbnail for quick fullscreen preview (1024x1024) Preview, } impl ThumbnailSize { /// Get the pixel size for this thumbnail variant pub fn pixels(&self) -> u32 { match self { ThumbnailSize::Tiny => 64, ThumbnailSize::Grid => 320, ThumbnailSize::Preview => 1024, } } /// Get the subdirectory name for this size pub fn subdir_name(&self) -> &'static str { match self { ThumbnailSize::Tiny => "tiny", ThumbnailSize::Grid => "grid", ThumbnailSize::Preview => "preview", } } } /// Generate all thumbnail sizes for a media file /// Returns paths to the generated thumbnails (tiny, grid, preview) pub fn generate_all_thumbnail_sizes( media_id: MediaId, source_path: &Path, media_type: MediaType, thumbnail_base_dir: &Path, ) -> Result<(Option, Option, Option)> { let sizes = [ ThumbnailSize::Tiny, ThumbnailSize::Grid, ThumbnailSize::Preview, ]; let mut results = Vec::new(); for size in &sizes { let size_dir = thumbnail_base_dir.join(size.subdir_name()); std::fs::create_dir_all(&size_dir)?; let config = ThumbnailConfig { size: size.pixels(), ..ThumbnailConfig::default() }; let result = generate_thumbnail_with_config( media_id, source_path, media_type.clone(), &size_dir, &config, )?; results.push(result); } Ok((results[0].clone(), results[1].clone(), results[2].clone())) }