From b77b1bc13960d74180a47b29b827dc81b50351fe Mon Sep 17 00:00:00 2001 From: Sebastian Hugentobler Date: Wed, 26 Jun 2024 21:22:07 +0200 Subject: [PATCH] implement thumbnail caching --- Cargo.lock | 69 ++++++++++++++++++++++ little-hesinde/Cargo.toml | 4 +- little-hesinde/src/cache.rs | 86 ++++++++++++++++++++++++++++ little-hesinde/src/cli.rs | 7 ++- little-hesinde/src/config.rs | 18 ++++++ little-hesinde/src/handlers/cover.rs | 8 ++- little-hesinde/src/lib.rs | 1 + 7 files changed, 187 insertions(+), 6 deletions(-) create mode 100644 little-hesinde/src/cache.rs diff --git a/Cargo.lock b/Cargo.lock index 367dcb8..07db29f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -187,6 +187,18 @@ version = "3.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" +[[package]] +name = "bytemuck" +version = "1.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b236fc92302c97ed75b38da1f4917b5cdda4984745740f153a5d3059e48d725e" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.6.0" @@ -447,6 +459,12 @@ dependencies = [ "syn", ] +[[package]] +name = "either" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" + [[package]] name = "equivalent" version = "1.0.1" @@ -832,6 +850,20 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "image" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd54d660e773627692c524beaad361aca785a4f9f5730ce91f42aabe5bce3d11" +dependencies = [ + "bytemuck", + "byteorder", + "num-traits", + "rayon", + "zune-core", + "zune-jpeg", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -917,6 +949,7 @@ dependencies = [ "calibre-db", "clap", "ignore", + "image", "once_cell", "poem", "quick-xml", @@ -924,6 +957,7 @@ dependencies = [ "serde", "serde_json", "serde_with", + "sha2", "tera", "thiserror", "time", @@ -1356,6 +1390,26 @@ dependencies = [ "getrandom", ] +[[package]] +name = "rayon" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redox_syscall" version = "0.5.1" @@ -2338,3 +2392,18 @@ dependencies = [ "once_cell", "simd-adler32", ] + +[[package]] +name = "zune-core" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" + +[[package]] +name = "zune-jpeg" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec866b44a2a1fd6133d363f073ca1b179f438f99e7e5bfb1e33f7181facfe448" +dependencies = [ + "zune-core", +] diff --git a/little-hesinde/Cargo.toml b/little-hesinde/Cargo.toml index 417ee5a..f1d622c 100644 --- a/little-hesinde/Cargo.toml +++ b/little-hesinde/Cargo.toml @@ -9,10 +9,12 @@ description = "A very simple ebook server for a calibre library, providing a htm [dependencies] calibre-db = { path = "../calibre-db/", version = "0.1.0" } -clap = { version = "4.5.4", features = ["derive"] } +clap = { version = "4.5.4", features = ["derive", "env"] } +image = { version = "0.25.1", default-features = false, features = ["jpeg", "rayon"] } once_cell = "1.19.0" poem = { version = "3.0.0", features = ["embed", "static-files"] } rust-embed = "8.3.0" +sha2 = "0.10.8" serde = { workspace = true } serde_json = "1.0.116" serde_with = "3.8.1" diff --git a/little-hesinde/src/cache.rs b/little-hesinde/src/cache.rs new file mode 100644 index 0000000..d9da937 --- /dev/null +++ b/little-hesinde/src/cache.rs @@ -0,0 +1,86 @@ +//! Handle caching of files, specifically book covers. + +use std::{ + fs, + path::{Path, PathBuf}, +}; + +use sha2::{ + digest::{generic_array::GenericArray, typenum::U32}, + Digest, Sha256, +}; +use std::fmt::Write; +use thiserror::Error; +use tokio::fs::File; +use tracing::debug; + +/// Errors from dealing with file caching. +#[derive(Error, Debug)] +pub enum CacheError { + /// Error converting a hash to its string representation. + #[error("failed to access thumbnail")] + HashError(#[from] std::fmt::Error), + /// Error creating a thumbnail for an image.. + #[error("failed to create thumbnail")] + ImageError(#[from] image::ImageError), + /// Error accessing a thumbnail. + #[error("failed to access thumbnail")] + ThumbnailAccessError(#[from] std::io::Error), + /// Error accessing thumbnail directories. + #[error("failed to access thumbnail directory")] + ThumbnailPathError(PathBuf), +} + +/// Convert a hash into its path representation inside the cache directory. +/// +/// First hash character is the top folder, second character the second level folder and the rest +/// is the filename. +fn hash_to_path(hash: GenericArray, cache_path: &Path) -> Result { + let mut hash_string = String::new(); + for byte in hash { + write!(&mut hash_string, "{:02x}", byte)?; + } + let hash = hash_string; + + let first_segment = &hash[0..1]; + let second_segment = &hash[1..2]; + let remaining_segment = &hash[2..]; + + Ok(PathBuf::from(cache_path) + .join(first_segment) + .join(second_segment) + .join(remaining_segment)) +} + +fn create_thumbnail(cover_path: &Path, thumbnail_path: &Path) -> Result<(), CacheError> { + debug!("creating thumbnail for {}", cover_path.to_string_lossy()); + let folders = thumbnail_path + .parent() + .ok_or_else(|| CacheError::ThumbnailPathError(thumbnail_path.to_path_buf()))?; + fs::create_dir_all(folders)?; + + let img = image::open(cover_path)?; + let thumbnail = img.thumbnail(512, 512); + thumbnail.save_with_format(thumbnail_path, image::ImageFormat::Jpeg)?; + debug!("saved thumbnail to {}", thumbnail_path.to_string_lossy()); + + Ok(()) +} + +/// Get the thumbnail for a book cover. +/// +/// If a thumbnail does not yet exist, create it. +pub async fn get_thumbnail(cover_path: &Path, cache_path: &Path) -> Result { + let path_str = cover_path.to_string_lossy(); + let mut hasher = Sha256::new(); + hasher.update(path_str.as_bytes()); + let hash = hasher.finalize(); + + let thumbnail_path = hash_to_path(hash, cache_path)?; + + if !thumbnail_path.exists() { + create_thumbnail(cover_path, &thumbnail_path)?; + } + + Ok(File::open(thumbnail_path).await?) +} diff --git a/little-hesinde/src/cli.rs b/little-hesinde/src/cli.rs index 8002b29..924f3c1 100644 --- a/little-hesinde/src/cli.rs +++ b/little-hesinde/src/cli.rs @@ -7,9 +7,12 @@ use clap::Parser; #[command(version, about, long_about = None)] pub struct Cli { /// Address to listen on - #[arg(short, long, default_value = "[::1]:3000")] + #[arg(short, long, env, default_value = "[::1]:3000")] pub listen_address: String, + /// Cache path ($TMP cascades through $XDG_CACHE_HOME, $TMPDIR and /tmp) + #[arg(short, long, env, default_value = "$TMP/little-hesinde")] + pub cache_path: String, /// Calibre library path - #[arg(last = true)] + #[arg(env, last = true)] pub library_path: String, } diff --git a/little-hesinde/src/config.rs b/little-hesinde/src/config.rs index 546d833..19f94a3 100644 --- a/little-hesinde/src/config.rs +++ b/little-hesinde/src/config.rs @@ -1,12 +1,14 @@ //! Configuration data. use std::{ + env, fs, io, net::SocketAddr, net::ToSocketAddrs, path::{Path, PathBuf}, }; use thiserror::Error; +use tracing::info; use crate::cli::Cli; @@ -22,6 +24,9 @@ pub enum ConfigError { /// Error converting a string to a listening address. #[error("failed to convert into listening address")] ListeningAddressError(String), + /// Error accessing the configured cache path. + #[error("failed to access cache path")] + CachePathError(#[from] io::Error), } /// Application configuration. @@ -33,6 +38,8 @@ pub struct Config { pub metadata_path: PathBuf, /// Address to listen on. pub listen_address: SocketAddr, + /// Path to data like thumbnails. + pub cache_path: PathBuf, } impl Config { @@ -69,10 +76,21 @@ impl Config { args.listen_address.clone(), ))?; + let cache_path = if args.cache_path.starts_with("$TMP") { + let cache_base = env::var("XDG_CACHE_HOME") + .unwrap_or_else(|_| env::var("TMPDIR").unwrap_or("/tmp/".to_string())); + PathBuf::from(&cache_base).join("little-hesinde") + } else { + PathBuf::from(&args.cache_path) + }; + fs::create_dir_all(&cache_path)?; + info!("Using {} for cache", cache_path.to_string_lossy()); + Ok(Self { library_path, metadata_path, listen_address, + cache_path, }) } } diff --git a/little-hesinde/src/handlers/cover.rs b/little-hesinde/src/handlers/cover.rs index a97514b..fc5440f 100644 --- a/little-hesinde/src/handlers/cover.rs +++ b/little-hesinde/src/handlers/cover.rs @@ -2,14 +2,13 @@ use std::sync::Arc; -use crate::{app_state::AppState, handlers::error::HandlerError}; +use crate::{app_state::AppState, cache, handlers::error::HandlerError}; use poem::{ error::NotFoundError, handler, web::{headers::ContentType, Data, Path}, Response, }; -use tokio::fs::File; /// Handle a request for the cover image of book with id `id`. #[handler] @@ -19,6 +18,9 @@ pub async fn handler(id: Path, state: Data<&Arc>) -> Result