pinakes-core: fix thumbnail generation; use explicit MediaType IDs

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I8d691bd5bffbccb80a7e3c2c387168d56a6a6964
This commit is contained in:
raf 2026-03-07 16:55:43 +03:00
commit b24d4cbcdd
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
3 changed files with 86 additions and 44 deletions

View file

@ -61,9 +61,40 @@ pub enum MediaCategory {
} }
impl BuiltinMediaType { impl BuiltinMediaType {
/// Get the unique ID for this media type /// Get the unique, stable ID for this media type.
pub fn id(&self) -> String { pub fn id(&self) -> &'static str {
format!("{:?}", self).to_lowercase() match self {
Self::Mp3 => "mp3",
Self::Flac => "flac",
Self::Ogg => "ogg",
Self::Wav => "wav",
Self::Aac => "aac",
Self::Opus => "opus",
Self::Mp4 => "mp4",
Self::Mkv => "mkv",
Self::Avi => "avi",
Self::Webm => "webm",
Self::Pdf => "pdf",
Self::Epub => "epub",
Self::Djvu => "djvu",
Self::Markdown => "markdown",
Self::PlainText => "plaintext",
Self::Jpeg => "jpeg",
Self::Png => "png",
Self::Gif => "gif",
Self::Webp => "webp",
Self::Svg => "svg",
Self::Avif => "avif",
Self::Tiff => "tiff",
Self::Bmp => "bmp",
Self::Cr2 => "cr2",
Self::Nef => "nef",
Self::Arw => "arw",
Self::Dng => "dng",
Self::Orf => "orf",
Self::Rw2 => "rw2",
Self::Heic => "heic",
}
} }
/// Get the display name for this media type /// Get the display name for this media type

View file

@ -1,6 +1,6 @@
//! Extensible media type system //! Media types
//! //!
//! This module provides an extensible media type system that supports both //! Supports both
//! built-in media types and plugin-registered custom types. //! built-in media types and plugin-registered custom types.
use std::path::Path; use std::path::Path;
@ -13,7 +13,7 @@ pub mod registry;
pub use builtin::{BuiltinMediaType, MediaCategory}; pub use builtin::{BuiltinMediaType, MediaCategory};
pub use registry::{MediaTypeDescriptor, MediaTypeRegistry}; pub use registry::{MediaTypeDescriptor, MediaTypeRegistry};
/// Media type identifier - can be either built-in or custom /// Media type identifier, can be either built-in or custom
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(untagged)] #[serde(untagged)]
pub enum MediaType { pub enum MediaType {
@ -33,7 +33,7 @@ impl MediaType {
/// Get the type ID as a string /// Get the type ID as a string
pub fn id(&self) -> String { pub fn id(&self) -> String {
match self { match self {
Self::Builtin(b) => b.id(), Self::Builtin(b) => b.id().to_string(),
Self::Custom(id) => id.clone(), Self::Custom(id) => id.clone(),
} }
} }
@ -61,8 +61,8 @@ impl MediaType {
} }
/// Get the category for this media type /// Get the category for this media type
/// For custom types without a registry, returns MediaCategory::Document as /// For custom types without a registry, returns [`MediaCategory::Document`]
/// default /// as default
pub fn category(&self) -> MediaCategory { pub fn category(&self) -> MediaCategory {
match self { match self {
Self::Builtin(b) => b.category(), Self::Builtin(b) => b.category(),
@ -192,7 +192,7 @@ impl MediaType {
} }
} }
// Implement From<BuiltinMediaType> for easier conversion // Implement `From<BuiltinMediaType>` for easier conversion
impl From<BuiltinMediaType> for MediaType { impl From<BuiltinMediaType> for MediaType {
fn from(builtin: BuiltinMediaType) -> Self { fn from(builtin: BuiltinMediaType) -> Self {
Self::Builtin(builtin) Self::Builtin(builtin)

View file

@ -12,6 +12,25 @@ use crate::{
model::MediaId, model::MediaId,
}; };
/// RAII guard that removes a file on drop.
struct TempFileGuard(PathBuf);
impl TempFileGuard {
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. /// Generate a thumbnail for a media file and return the path to the thumbnail.
/// ///
/// Supports images (via `image` crate), videos (via ffmpeg), PDFs (via /// Supports images (via `image` crate), videos (via ffmpeg), PDFs (via
@ -145,7 +164,6 @@ fn generate_pdf_thumbnail(
dest: &Path, dest: &Path,
config: &ThumbnailConfig, config: &ThumbnailConfig,
) -> Result<()> { ) -> Result<()> {
// Use pdftoppm to render first page, then resize with image crate
let temp_prefix = dest.with_extension("tmp"); let temp_prefix = dest.with_extension("tmp");
let status = Command::new("pdftoppm") let status = Command::new("pdftoppm")
.args(["-jpeg", "-f", "1", "-l", "1", "-singlefile"]) .args(["-jpeg", "-f", "1", "-l", "1", "-singlefile"])
@ -167,11 +185,10 @@ fn generate_pdf_thumbnail(
))); )));
} }
// pdftoppm outputs <prefix>.jpg // pdftoppm outputs <prefix>.jpg -- guard ensures cleanup on all paths
let rendered = temp_prefix.with_extension("jpg"); let rendered = TempFileGuard::new(temp_prefix.with_extension("jpg"));
if rendered.exists() { if rendered.path().exists() {
// Resize to thumbnail size let img = image::open(rendered.path()).map_err(|e| {
let img = image::open(&rendered).map_err(|e| {
PinakesError::MetadataExtraction(format!("pdf thumbnail open: {e}")) PinakesError::MetadataExtraction(format!("pdf thumbnail open: {e}"))
})?; })?;
let thumb = img.thumbnail(config.size, config.size); let thumb = img.thumbnail(config.size, config.size);
@ -183,7 +200,7 @@ fn generate_pdf_thumbnail(
thumb.write_with_encoder(encoder).map_err(|e| { thumb.write_with_encoder(encoder).map_err(|e| {
PinakesError::MetadataExtraction(format!("pdf thumbnail encode: {e}")) PinakesError::MetadataExtraction(format!("pdf thumbnail encode: {e}"))
})?; })?;
let _ = std::fs::remove_file(&rendered); // Guard drops here and cleans up the temp file
Ok(()) Ok(())
} else { } else {
Err(PinakesError::MetadataExtraction( Err(PinakesError::MetadataExtraction(
@ -235,12 +252,11 @@ fn generate_raw_thumbnail(
dest: &Path, dest: &Path,
config: &ThumbnailConfig, config: &ThumbnailConfig,
) -> Result<()> { ) -> Result<()> {
// Try dcraw to extract embedded JPEG preview, then resize let temp_ppm = TempFileGuard::new(dest.with_extension("ppm"));
let temp_ppm = dest.with_extension("ppm");
let status = Command::new("dcraw") let status = Command::new("dcraw")
.args(["-e", "-c"]) .args(["-e", "-c"])
.arg(source) .arg(source)
.stdout(std::fs::File::create(&temp_ppm).map_err(|e| { .stdout(std::fs::File::create(temp_ppm.path()).map_err(|e| {
PinakesError::MetadataExtraction(format!( PinakesError::MetadataExtraction(format!(
"failed to create temp file: {e}" "failed to create temp file: {e}"
)) ))
@ -254,18 +270,15 @@ fn generate_raw_thumbnail(
})?; })?;
if !status.success() { if !status.success() {
let _ = std::fs::remove_file(&temp_ppm); // Guard drops and cleans up temp_ppm
return Err(PinakesError::MetadataExtraction(format!( return Err(PinakesError::MetadataExtraction(format!(
"dcraw exited with status {}", "dcraw exited with status {}",
status status
))); )));
} }
// The extracted preview is typically a JPEG; try loading it if temp_ppm.path().exists() {
if temp_ppm.exists() { let img = image::open(temp_ppm.path()).map_err(|e| {
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}")) PinakesError::MetadataExtraction(format!("raw preview decode: {e}"))
})?; })?;
let thumb = img.thumbnail(config.size, config.size); let thumb = img.thumbnail(config.size, config.size);
@ -277,6 +290,7 @@ fn generate_raw_thumbnail(
thumb.write_with_encoder(encoder).map_err(|e| { thumb.write_with_encoder(encoder).map_err(|e| {
PinakesError::MetadataExtraction(format!("raw thumbnail encode: {e}")) PinakesError::MetadataExtraction(format!("raw thumbnail encode: {e}"))
})?; })?;
// Guard drops and cleans up temp_ppm
Ok(()) Ok(())
} else { } else {
Err(PinakesError::MetadataExtraction( Err(PinakesError::MetadataExtraction(
@ -290,11 +304,10 @@ fn generate_heic_thumbnail(
dest: &Path, dest: &Path,
config: &ThumbnailConfig, config: &ThumbnailConfig,
) -> Result<()> { ) -> Result<()> {
// Use heif-convert to convert to JPEG, then resize let temp_jpg = TempFileGuard::new(dest.with_extension("tmp.jpg"));
let temp_jpg = dest.with_extension("tmp.jpg");
let status = Command::new("heif-convert") let status = Command::new("heif-convert")
.arg(source) .arg(source)
.arg(&temp_jpg) .arg(temp_jpg.path())
.stdout(std::process::Stdio::null()) .stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null()) .stderr(std::process::Stdio::null())
.status() .status()
@ -305,17 +318,15 @@ fn generate_heic_thumbnail(
})?; })?;
if !status.success() { if !status.success() {
let _ = std::fs::remove_file(&temp_jpg); // Guard drops and cleans up temp_jpg
return Err(PinakesError::MetadataExtraction(format!( return Err(PinakesError::MetadataExtraction(format!(
"heif-convert exited with status {}", "heif-convert exited with status {}",
status status
))); )));
} }
if temp_jpg.exists() { if temp_jpg.path().exists() {
let result = image::open(&temp_jpg); let img = image::open(temp_jpg.path()).map_err(|e| {
let _ = std::fs::remove_file(&temp_jpg);
let img = result.map_err(|e| {
PinakesError::MetadataExtraction(format!("heic decode: {e}")) PinakesError::MetadataExtraction(format!("heic decode: {e}"))
})?; })?;
let thumb = img.thumbnail(config.size, config.size); let thumb = img.thumbnail(config.size, config.size);
@ -327,6 +338,7 @@ fn generate_heic_thumbnail(
thumb.write_with_encoder(encoder).map_err(|e| { thumb.write_with_encoder(encoder).map_err(|e| {
PinakesError::MetadataExtraction(format!("heic thumbnail encode: {e}")) PinakesError::MetadataExtraction(format!("heic thumbnail encode: {e}"))
})?; })?;
// Guard drops and cleans up temp_jpg
Ok(()) Ok(())
} else { } else {
Err(PinakesError::MetadataExtraction( Err(PinakesError::MetadataExtraction(
@ -449,14 +461,11 @@ pub fn extract_epub_cover(epub_path: &Path) -> Result<Option<Vec<u8>>> {
/// Extract full-size cover from a PDF file (first page) /// Extract full-size cover from a PDF file (first page)
pub fn extract_pdf_cover(pdf_path: &Path) -> Result<Option<Vec<u8>>> { pub fn extract_pdf_cover(pdf_path: &Path) -> Result<Option<Vec<u8>>> {
// Use pdftoppm to extract the first page at high resolution
let pdftoppm = "pdftoppm";
let temp_dir = std::env::temp_dir(); let temp_dir = std::env::temp_dir();
let temp_prefix = let temp_prefix =
temp_dir.join(format!("pdf_cover_{}", uuid::Uuid::new_v4())); temp_dir.join(format!("pdf_cover_{}", uuid::Uuid::new_v4()));
let status = Command::new(pdftoppm) let status = Command::new("pdftoppm")
.args(["-jpeg", "-f", "1", "-l", "1", "-scale-to", "1200"]) .args(["-jpeg", "-f", "1", "-l", "1", "-scale-to", "1200"])
.arg(pdf_path) .arg(pdf_path)
.arg(&temp_prefix) .arg(&temp_prefix)
@ -472,13 +481,15 @@ pub fn extract_pdf_cover(pdf_path: &Path) -> Result<Option<Vec<u8>>> {
))); )));
} }
// pdftoppm outputs files like prefix-1.jpg // pdftoppm outputs files like prefix-1.jpg -- guard ensures cleanup
let output_path = format!("{}-1.jpg", temp_prefix.display()); let output = TempFileGuard::new(PathBuf::from(format!(
let output_pathbuf = PathBuf::from(&output_path); "{}-1.jpg",
temp_prefix.display()
)));
if output_pathbuf.exists() { if output.path().exists() {
let data = std::fs::read(&output_pathbuf)?; let data = std::fs::read(output.path())?;
let _ = std::fs::remove_file(&output_pathbuf); // Guard drops and cleans up the temp file
Ok(Some(data)) Ok(Some(data))
} else { } else {
Ok(None) Ok(None)