Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I4a6b498153eccd5407510dd541b7f4816a6a6964
278 lines
9.9 KiB
Rust
278 lines
9.9 KiB
Rust
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<Option<PathBuf>> {
|
|
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<Option<PathBuf>> {
|
|
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 <prefix>.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")
|
|
}
|