Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I9e0ff5ea33a5cf697473423e88f167ce6a6a6964
600 lines
16 KiB
Rust
600 lines
16 KiB
Rust
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,
|
|
};
|
|
|
|
/// RAII guard that removes a file on drop.
|
|
struct TempFileGuard(PathBuf);
|
|
|
|
impl TempFileGuard {
|
|
const fn new(path: PathBuf) -> Self {
|
|
Self(path)
|
|
}
|
|
|
|
fn path(&self) -> &Path {
|
|
&self.0
|
|
}
|
|
}
|
|
|
|
impl Drop for TempFileGuard {
|
|
fn drop(&mut self) {
|
|
let _ = std::fs::remove_file(&self.0);
|
|
}
|
|
}
|
|
|
|
/// 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).
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns [`PinakesError`] if thumbnail generation fails.
|
|
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(),
|
|
)
|
|
}
|
|
|
|
/// Generate a thumbnail with custom configuration.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns [`PinakesError`] if thumbnail generation fails.
|
|
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!("{media_id}.jpg"));
|
|
|
|
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<()> {
|
|
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 -- guard ensures cleanup on all paths
|
|
let rendered = TempFileGuard::new(temp_prefix.with_extension("jpg"));
|
|
if rendered.path().exists() {
|
|
let img = image::open(rendered.path()).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}"))
|
|
})?;
|
|
// Guard drops here and cleans up the temp file
|
|
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<()> {
|
|
let temp_ppm = TempFileGuard::new(dest.with_extension("ppm"));
|
|
let status = Command::new("dcraw")
|
|
.args(["-e", "-c"])
|
|
.arg(source)
|
|
.stdout(std::fs::File::create(temp_ppm.path()).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() {
|
|
// Guard drops and cleans up temp_ppm
|
|
return Err(PinakesError::MetadataExtraction(format!(
|
|
"dcraw exited with status {status}"
|
|
)));
|
|
}
|
|
|
|
if temp_ppm.path().exists() {
|
|
let img = image::open(temp_ppm.path()).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}"))
|
|
})?;
|
|
// Guard drops and cleans up temp_ppm
|
|
Ok(())
|
|
} else {
|
|
Err(PinakesError::MetadataExtraction(
|
|
"dcraw did not produce output".to_string(),
|
|
))
|
|
}
|
|
}
|
|
|
|
fn generate_heic_thumbnail(
|
|
source: &Path,
|
|
dest: &Path,
|
|
config: &ThumbnailConfig,
|
|
) -> Result<()> {
|
|
let temp_jpg = TempFileGuard::new(dest.with_extension("tmp.jpg"));
|
|
let status = Command::new("heif-convert")
|
|
.arg(source)
|
|
.arg(temp_jpg.path())
|
|
.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() {
|
|
// Guard drops and cleans up temp_jpg
|
|
return Err(PinakesError::MetadataExtraction(format!(
|
|
"heif-convert exited with status {status}"
|
|
)));
|
|
}
|
|
|
|
if temp_jpg.path().exists() {
|
|
let img = image::open(temp_jpg.path()).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}"))
|
|
})?;
|
|
// Guard drops and cleans up temp_jpg
|
|
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 {
|
|
#[must_use]
|
|
pub const fn dimensions(&self) -> Option<(u32, u32)> {
|
|
match self {
|
|
Self::Tiny => Some((64, 64)),
|
|
Self::Grid => Some((320, 320)),
|
|
Self::Preview => Some((1024, 1024)),
|
|
Self::Original => None, // No resizing
|
|
}
|
|
}
|
|
|
|
#[must_use]
|
|
pub const fn filename(&self) -> &'static str {
|
|
match self {
|
|
Self::Tiny => "tiny.jpg",
|
|
Self::Grid => "grid.jpg",
|
|
Self::Preview => "preview.jpg",
|
|
Self::Original => "original.jpg",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Generate multi-resolution covers for a book.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns [`PinakesError`] if the cover image cannot be decoded or encoded.
|
|
pub fn generate_book_covers(
|
|
media_id: MediaId,
|
|
source_image: &[u8],
|
|
covers_dir: &Path,
|
|
) -> Result<Vec<(CoverSize, PathBuf)>> {
|
|
// 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());
|
|
|
|
if let Some((width, height)) = size.dimensions() {
|
|
// 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}"))
|
|
})?;
|
|
} else {
|
|
// 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.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns [`PinakesError`] if the EPUB cannot be opened or read.
|
|
pub fn extract_epub_cover(epub_path: &Path) -> Result<Option<Vec<u8>>> {
|
|
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).
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns [`PinakesError`] if pdftoppm cannot be executed or fails.
|
|
pub fn extract_pdf_cover(pdf_path: &Path) -> Result<Option<Vec<u8>>> {
|
|
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 -- guard ensures cleanup
|
|
let output = TempFileGuard::new(PathBuf::from(format!(
|
|
"{}-1.jpg",
|
|
temp_prefix.display()
|
|
)));
|
|
|
|
if output.path().exists() {
|
|
let data = std::fs::read(output.path())?;
|
|
// Guard drops and cleans up the temp file
|
|
Ok(Some(data))
|
|
} else {
|
|
Ok(None)
|
|
}
|
|
}
|
|
|
|
/// Returns the default covers directory under the data dir
|
|
#[must_use]
|
|
pub fn default_covers_dir() -> PathBuf {
|
|
crate::config::Config::default_data_dir().join("covers")
|
|
}
|
|
|
|
/// Returns the default thumbnail directory under the data dir.
|
|
#[must_use]
|
|
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
|
|
#[must_use]
|
|
pub const fn pixels(&self) -> u32 {
|
|
match self {
|
|
Self::Tiny => 64,
|
|
Self::Grid => 320,
|
|
Self::Preview => 1024,
|
|
}
|
|
}
|
|
|
|
/// Get the subdirectory name for this size
|
|
#[must_use]
|
|
pub const fn subdir_name(&self) -> &'static str {
|
|
match self {
|
|
Self::Tiny => "tiny",
|
|
Self::Grid => "grid",
|
|
Self::Preview => "preview",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Generate all thumbnail sizes for a media file.
|
|
///
|
|
/// Returns paths to the generated thumbnails (tiny, grid, preview).
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns [`PinakesError`] if thumbnail generation fails.
|
|
pub fn generate_all_thumbnail_sizes(
|
|
media_id: MediaId,
|
|
source_path: &Path,
|
|
media_type: &MediaType,
|
|
thumbnail_base_dir: &Path,
|
|
) -> Result<(Option<PathBuf>, Option<PathBuf>, Option<PathBuf>)> {
|
|
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,
|
|
&size_dir,
|
|
&config,
|
|
)?;
|
|
|
|
results.push(result);
|
|
}
|
|
|
|
Ok((results[0].clone(), results[1].clone(), results[2].clone()))
|
|
}
|