pinakes-core: fix thumbnail generation; use explicit MediaType IDs
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I8d691bd5bffbccb80a7e3c2c387168d56a6a6964
This commit is contained in:
parent
237f7c28d2
commit
b24d4cbcdd
3 changed files with 86 additions and 44 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue