diff --git a/Cargo.lock b/Cargo.lock index 367dcb8..4cf885a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -150,12 +150,6 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - [[package]] name = "bitflags" version = "2.5.0" @@ -187,6 +181,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" @@ -239,9 +245,9 @@ dependencies = [ [[package]] name = "chrono-tz" -version = "0.8.6" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d59ae0466b83e838b81a54256c39d5d7c20b9d7daa10510a242d9b75abd5936e" +checksum = "93698b29de5e97ad0ae26447b344c482a7284c737d9ddc5f9e52b74a336671bb" dependencies = [ "chrono", "chrono-tz-build", @@ -250,9 +256,9 @@ dependencies = [ [[package]] name = "chrono-tz-build" -version = "0.2.1" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "433e39f13c9a060046954e0592a8d0a4bcb1040125cbf91cb8ee58964cfb350f" +checksum = "0c088aee841df9c3041febbb73934cfc39708749bf96dc827e3359cd39ef11b1" dependencies = [ "parse-zoneinfo", "phf", @@ -261,9 +267,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.4" +version = "4.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90bc066a67923782aa8515dbaea16946c5bcc5addbd668bb80af688e53e548a0" +checksum = "5db83dced34638ad474f39f250d7fea9598bdd239eaced1bdf45d597da0f433f" dependencies = [ "clap_builder", "clap_derive", @@ -271,9 +277,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.2" +version = "4.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae129e2e766ae0ec03484e609954119f123cc1fe650337e155d03b022f24f7b4" +checksum = "f7e204572485eb3fbf28f871612191521df159bc3e15a9f5064c66dba3a8c05f" dependencies = [ "anstream", "anstyle", @@ -283,9 +289,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.4" +version = "4.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "528131438037fd55894f62d6e9f068b8f45ac57ffa77517819645d10aed04f64" +checksum = "c780290ccf4fb26629baa7a1081e68ced113f1d3ec302fa5948f1c381ebf06c6" dependencies = [ "heck", "proc-macro2", @@ -447,6 +453,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" @@ -601,11 +613,11 @@ dependencies = [ [[package]] name = "globwalk" -version = "0.8.1" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93e3af942408868f6934a7b85134a3230832b9977cf66125df2f9edcfce4ddcc" +checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757" dependencies = [ - "bitflags 1.3.2", + "bitflags", "ignore", "walkdir", ] @@ -832,6 +844,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" @@ -912,11 +938,12 @@ checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" [[package]] name = "little-hesinde" -version = "0.2.3" +version = "0.3.0" dependencies = [ "calibre-db", "clap", "ignore", + "image", "once_cell", "poem", "quick-xml", @@ -924,6 +951,7 @@ dependencies = [ "serde", "serde_json", "serde_with", + "sha2", "tera", "thiserror", "time", @@ -1005,7 +1033,7 @@ version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" dependencies = [ - "bitflags 2.5.0", + "bitflags", "cfg-if", "cfg_aliases", "libc", @@ -1208,9 +1236,9 @@ checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec" [[package]] name = "poem" -version = "3.0.0" +version = "3.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b735eaaaa6bc7ed2dcbcab1d5373afe1f6d03a37d8695ba3c42101f733a8455" +checksum = "e88b6912ed1e8833d7c22c9c986c517f4518d7d37e3c04566d917c789aaea591" dependencies = [ "bytes", "futures-util", @@ -1356,13 +1384,33 @@ 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" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "469052894dcb553421e483e4209ee581a45100d31b4018de03e5a7ad86374a7e" dependencies = [ - "bitflags 2.5.0", + "bitflags", ] [[package]] @@ -1409,7 +1457,7 @@ version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" dependencies = [ - "bitflags 2.5.0", + "bitflags", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -1420,9 +1468,9 @@ dependencies = [ [[package]] name = "rust-embed" -version = "8.3.0" +version = "8.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb78f46d0066053d16d4ca7b898e9343bc3530f71c61d5ad84cd404ada068745" +checksum = "19549741604902eb99a7ed0ee177a0663ee1eda51a29f71401f166e47e77806a" dependencies = [ "rust-embed-impl", "rust-embed-utils", @@ -1431,9 +1479,9 @@ dependencies = [ [[package]] name = "rust-embed-impl" -version = "8.3.0" +version = "8.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b91ac2a3c6c0520a3fb3dd89321177c3c692937c4eb21893378219da10c44fc8" +checksum = "cb9f96e283ec64401f30d3df8ee2aaeb2561f34c824381efa24a35f79bf40ee4" dependencies = [ "proc-macro2", "quote", @@ -1444,9 +1492,9 @@ dependencies = [ [[package]] name = "rust-embed-utils" -version = "8.3.0" +version = "8.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86f69089032567ffff4eada41c573fc43ff466c7db7c5688b2e7969584345581" +checksum = "38c74a686185620830701348de757fd36bef4aa9680fd23c49fc539ddcc1af32" dependencies = [ "sha2", "walkdir", @@ -1464,7 +1512,7 @@ version = "0.38.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f" dependencies = [ - "bitflags 2.5.0", + "bitflags", "errno", "libc", "linux-raw-sys", @@ -1503,18 +1551,18 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "serde" -version = "1.0.200" +version = "1.0.203" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddc6f9cc94d67c0e21aaf7eda3a010fd3af78ebf6e096aa6e2e13c79749cce4f" +checksum = "7253ab4de971e72fb7be983802300c30b5a7f0c2e56fab8abfc6a214307c0094" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.200" +version = "1.0.203" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "856f046b9400cee3c8c94ed572ecdb752444c24528c035cd35882aad6f492bcb" +checksum = "500cbc0ebeb6f46627f50f3f5811ccf6bf00643be300b4c3eabc0ef55dc5b5ba" dependencies = [ "proc-macro2", "quote", @@ -1523,9 +1571,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.116" +version = "1.0.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e17db7126d17feb94eb3fad46bf1a96b034e8aacbc2e775fe81505f8b0b2813" +checksum = "d947f6b3163d8857ea16c4fa0dd4840d52f3041039a85decd46867eb1abef2e4" dependencies = [ "itoa", "ryu", @@ -1707,9 +1755,9 @@ dependencies = [ [[package]] name = "tera" -version = "1.19.1" +version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "970dff17c11e884a4a09bc76e3a17ef71e01bb13447a11e85226e254fe6d10b8" +checksum = "ab9d851b45e865f178319da0abdbfe6acbc4328759ff18dafc3a41c16b4cd2ee" dependencies = [ "chrono", "chrono-tz", @@ -1790,9 +1838,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.37.0" +version = "1.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1adbebffeca75fcfd058afa480fb6c0b81e165a0323f9c9d39c9697e37c46787" +checksum = "ba4f4a02a7a80d6f274636f0aa95c7e383b912d41fe721a31f29e29698585a4a" dependencies = [ "backtrace", "bytes", @@ -1808,9 +1856,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b" +checksum = "5f5ae998a069d4b5aba8ee9dad856af7d520c3699e6159b185c2acd48155d39a" dependencies = [ "proc-macro2", "quote", @@ -1998,9 +2046,9 @@ checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" [[package]] name = "uuid" -version = "1.8.0" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a183cf7feeba97b4dd1c0d46788634f6221d87fa961b305bed08c851829efcc0" +checksum = "5de17fd2f7da591098415cff336e12965a28061ddace43b59cb3c430179c9439" dependencies = [ "getrandom", "rand", @@ -2338,3 +2386,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/Cargo.toml b/Cargo.toml index dcf3448..44a1952 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,8 +5,8 @@ members = [ ] [workspace.dependencies] -serde = "1.0.200" -thiserror = "1.0.59" +serde = "1.0.203" +thiserror = "1.0.61" time = { version = "0.3.36", features = ["macros", "serde", "formatting", "parsing" ] } [workspace.package] diff --git a/Containerfile b/Containerfile index ae22d10..d82e1ec 100644 --- a/Containerfile +++ b/Containerfile @@ -1,5 +1,8 @@ FROM docker.io/rust:1-alpine3.20 AS builder +RUN mkdir /tmp/tmp +RUN echo "hesinde:x:2222:2222:Linux User,,,:/:/app" > /passwd + RUN apk --no-cache add \ musl-dev @@ -13,12 +16,16 @@ COPY . . RUN cargo build --release --target=$(arch)-unknown-linux-musl RUN cp "./target/$(arch)-unknown-linux-musl/release/little-hesinde" /app - FROM scratch +COPY --from=builder /passwd /etc/passwd COPY --from=builder /app /app -CMD ["/app", "--listen-address", "[::]:3000", "--", "/library"] +COPY --from=builder --chown=2222: /tmp/tmp /tmp -ENV TMPDIR=/ -VOLUME ["/library"] +USER hesinde + +CMD ["/app", "--listen-address", "[::]:3000", "--cache-path", "/tmp/cache", "--", "/library"] + +ENV TMPDIR=/tmp +VOLUME ["/library", "/tmp"] EXPOSE 3000 diff --git a/README.md b/README.md index 26ba9f5..92316fc 100644 --- a/README.md +++ b/README.md @@ -37,12 +37,17 @@ From there on `cargo run` and `cargo build` and so on can be used. Usage: little-hesinde [OPTIONS] -- Arguments: - Calibre library path + Calibre library path [env: LIBRARY_PATH=] Options: - -l, --listen-address Address to listen on [default: [::1]:3000] - -h, --help Print help - -V, --version Print version + -l, --listen-address + Address to listen on [env: LISTEN_ADDRESS=] [default: [::1]:3000] + -c, --cache-path + Cache path ($TMP cascades through $XDG_CACHE_HOME, $TMPDIR and /tmp) [env: CACHE_PATH=] [default: $TMP/little-hesinde] + -h, --help + Print help + -V, --version + Print version ``` Example: `little-hesinde -l [::]4000 -- ~/Documents/library/` diff --git a/calibre-db/src/calibre.rs b/calibre-db/src/calibre.rs index 94bfd11..1dd1164 100644 --- a/calibre-db/src/calibre.rs +++ b/calibre-db/src/calibre.rs @@ -1,15 +1,16 @@ //! Bundle all functions together. -use std::path::Path; +use std::path::{Path, PathBuf}; use r2d2::Pool; use r2d2_sqlite::SqliteConnectionManager; +use tempfile::NamedTempFile; use crate::{ data::{ author::Author, book::Book, error::DataStoreError, pagination::SortOrder, series::Series, }, - search::{self, search}, + search::search, }; /// Top level calibre functions, bundling all sub functions in one place and providing secure access to @@ -17,6 +18,7 @@ use crate::{ #[derive(Debug, Clone)] pub struct Calibre { pool: Pool, + search_db_path: PathBuf, } impl Calibre { @@ -27,16 +29,20 @@ impl Calibre { let manager = SqliteConnectionManager::file(path); let pool = r2d2::Pool::new(manager)?; - search::attach(&pool)?; + let tmpfile = NamedTempFile::new()?; + let (_, search_db_path) = tmpfile.keep()?; - Ok(Self { pool }) + Ok(Self { + pool, + search_db_path, + }) } /// Full text search with a query. /// /// See https://www.sqlite.org/fts5.html#full_text_query_syntax for syntax. pub fn search(&self, query: &str) -> Result, DataStoreError> { - search(query, &self.pool) + search(query, &self.pool, &self.search_db_path) } /// Fetch book data from calibre, starting at `cursor`, fetching up to an amount of `limit` and diff --git a/calibre-db/src/search.rs b/calibre-db/src/search.rs index 0d61d51..8c6ae9a 100644 --- a/calibre-db/src/search.rs +++ b/calibre-db/src/search.rs @@ -5,10 +5,11 @@ //! virtual table leveraging fts5 (https://www.sqlite.org/fts5.html). Full-text search is run on //! that virtual table. +use std::path::Path; + use r2d2::{Pool, PooledConnection}; use r2d2_sqlite::SqliteConnectionManager; use rusqlite::named_params; -use tempfile::NamedTempFile; use crate::data::{book::Book, error::DataStoreError}; @@ -32,28 +33,53 @@ const SEARCH_INIT_QUERY: &str = "INSERT INTO search.fts(book_id, data) LEFT JOIN main.series AS s ON b2s.series = s.id GROUP BY b.id"; -/// Attach the fts temporary database to the read-only calibre database. -pub(crate) fn attach(pool: &Pool) -> Result<(), DataStoreError> { - let conn = pool.get()?; - let tmpfile = NamedTempFile::new()?; - let (_, path) = tmpfile.keep()?; +/// Ensure the search database is attached to the connection and +/// initializes the data if needed. +fn ensure_search_db( + conn: &PooledConnection, + db_path: &Path, +) -> Result<(), DataStoreError> { + let mut stmt = + conn.prepare("SELECT COUNT() FROM pragma_database_list WHERE name = 'search'")?; + let count: u64 = stmt.query_row([], |x| x.get(0))?; + let need_attachment = count == 0; + if need_attachment { + attach(conn, db_path)?; + init(conn)?; + } + + Ok(()) +} + +/// Attach the fts temporary database to the read-only calibre database. +fn attach( + conn: &PooledConnection, + db_path: &Path, +) -> Result<(), DataStoreError> { conn.execute( - &format!("ATTACH DATABASE '{}' AS search", path.to_string_lossy()), + &format!("ATTACH DATABASE '{}' AS search", db_path.to_string_lossy()), [], )?; - init(&conn)?; + init(conn)?; Ok(()) } /// Initialise the fts virtual table. fn init(conn: &PooledConnection) -> Result<(), DataStoreError> { - conn.execute( - "CREATE VIRTUAL TABLE search.fts USING fts5(book_id, data)", - [], - )?; - conn.execute(SEARCH_INIT_QUERY, [])?; + let mut stmt = conn + .prepare("SELECT COUNT() FROM search.sqlite_master WHERE type='table' AND name = 'fts'")?; + let count: u64 = stmt.query_row([], |x| x.get(0))?; + let need_init = count == 0; + + if need_init { + conn.execute( + "CREATE VIRTUAL TABLE search.fts USING fts5(book_id, data)", + [], + )?; + conn.execute(SEARCH_INIT_QUERY, [])?; + } Ok(()) } @@ -62,8 +88,10 @@ fn init(conn: &PooledConnection) -> Result<(), DataStor pub(crate) fn search( query: &str, pool: &Pool, + search_db_path: &Path, ) -> Result, DataStoreError> { let conn = pool.get()?; + ensure_search_db(&conn, search_db_path)?; let mut stmt = conn.prepare("SELECT book_id FROM search.fts WHERE data MATCH (:query) ORDER BY rank")?; diff --git a/little-hesinde/Cargo.toml b/little-hesinde/Cargo.toml index 417ee5a..06a5985 100644 --- a/little-hesinde/Cargo.toml +++ b/little-hesinde/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "little-hesinde" -version = "0.2.3" +version = "0.3.0" edition = "2021" license = { workspace = true } authors = { workspace = true } @@ -9,21 +9,23 @@ 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.7", 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" +poem = { version = "3.0.1", features = ["embed", "static-files"] } +rust-embed = "8.4.0" +sha2 = "0.10.8" serde = { workspace = true } -serde_json = "1.0.116" +serde_json = "1.0.118" serde_with = "3.8.1" -tera = "1.19.1" +tera = "1.20.0" thiserror = { workspace = true } time = { workspace = true } -tokio = { version = "1.37.0", features = ["signal", "rt-multi-thread", "macros"] } +tokio = { version = "1.38.0", features = ["signal", "rt-multi-thread", "macros"] } tokio-util = "0.7.11" tracing = "0.1.40" tracing-subscriber = "0.3.18" -uuid = { version = "1.8.0", features = ["v4", "fast-rng"] } +uuid = { version = "1.9.1", features = ["v4", "fast-rng"] } quick-xml = { version = "0.34.0", features = ["serialize"] } [build-dependencies] diff --git a/little-hesinde/src/cache.rs b/little-hesinde/src/cache.rs new file mode 100644 index 0000000..ddf01e2 --- /dev/null +++ b/little-hesinde/src/cache.rs @@ -0,0 +1,86 @@ +//! Handle caching of files, specifically book covers. + +use std::{ + fs::{self, File}, + path::{Path, PathBuf}, +}; + +use sha2::{ + digest::{generic_array::GenericArray, typenum::U32}, + Digest, Sha256, +}; +use std::fmt::Write; +use thiserror::Error; +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)?; + + const THUMBNAIL_SIZE: u32 = 512; + let img = image::open(cover_path)?; + let thumbnail = img.thumbnail(THUMBNAIL_SIZE, THUMBNAIL_SIZE); + 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 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)?) +} 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..53c7c0c 100644 --- a/little-hesinde/src/handlers/cover.rs +++ b/little-hesinde/src/handlers/cover.rs @@ -1,24 +1,74 @@ //! Handle requests for cover images. -use std::sync::Arc; +use std::{fs::File, path::Path as FilePath, sync::Arc}; -use crate::{app_state::AppState, handlers::error::HandlerError}; +use crate::{ + app_state::AppState, + cache::{self, CacheError}, + config::Config, + handlers::error::HandlerError, +}; +use calibre_db::calibre::Calibre; use poem::{ error::NotFoundError, handler, web::{headers::ContentType, Data, Path}, Response, }; -use tokio::fs::File; +use thiserror::Error; +use tokio::fs::File as AsyncFile; + +/// Errors from fetching cover images. +#[derive(Error, Debug)] +pub enum CoverError { + /// Error fetching a cover thumbnail. + #[error("failed to access thumbnail")] + ThumbnailError(#[from] CacheError), + /// Error fetching a full cover. + #[error("failed access cover")] + FullCoverError(#[from] std::io::Error), +} + +/// Handle a request for the cover thumbnail of book with id `id`. +#[handler] +pub async fn handler_thumbnail( + id: Path, + state: Data<&Arc>, +) -> Result { + cover( + &state.calibre, + &state.config, + *id, + |cover_path, cache_path| Ok(cache::get_thumbnail(cover_path, cache_path)?), + ) + .await +} /// Handle a request for the cover image of book with id `id`. #[handler] -pub async fn handler(id: Path, state: Data<&Arc>) -> Result { - let book = state - .calibre - .scalar_book(*id) - .map_err(HandlerError::DataError)?; - let cover_path = state.config.library_path.join(book.path).join("cover.jpg"); - let mut cover = File::open(cover_path).await.map_err(|_| NotFoundError)?; +pub async fn handler_full( + id: Path, + state: Data<&Arc>, +) -> Result { + cover(&state.calibre, &state.config, *id, |cover_path, _| { + Ok(File::open(cover_path)?) + }) + .await +} + +async fn cover( + calibre: &Calibre, + config: &Config, + id: u64, + f: F, +) -> Result +where + F: Fn(&FilePath, &FilePath) -> Result, +{ + let book = calibre.scalar_book(id).map_err(HandlerError::DataError)?; + let cover_path = config.library_path.join(book.path).join("cover.jpg"); + + let cover = f(&cover_path, &config.cache_path).map_err(|_| NotFoundError)?; + let cover = AsyncFile::from_std(cover); crate::handlers::download::handler("cover.jpg", cover, &ContentType::jpeg().to_string()).await } diff --git a/little-hesinde/src/lib.rs b/little-hesinde/src/lib.rs index 95b1ebc..3bdc615 100644 --- a/little-hesinde/src/lib.rs +++ b/little-hesinde/src/lib.rs @@ -16,6 +16,7 @@ use tokio::signal; use tracing::info; pub mod app_state; +pub mod cache; pub mod cli; pub mod config; /// Data structs and their functions. @@ -76,7 +77,7 @@ pub mod opds { pub mod templates; pub const APP_NAME: &str = "little-hesinde"; -pub const VERSION: &str = "0.2.3"; +pub const VERSION: &str = "0.3.0"; /// Internal marker data in lieu of a proper `Accept` header. #[derive(Debug, Clone, Copy)] @@ -116,7 +117,11 @@ pub async fn run(config: Config) -> Result<(), std::io::Error> { "/authors/:cursor/:sort_order", get(handlers::authors::handler), ) - .at("/cover/:id", get(handlers::cover::handler)) + .at("/cover/:id", get(handlers::cover::handler_full)) + .at( + "/cover/:id/thumbnail", + get(handlers::cover::handler_thumbnail), + ) .at("/book/:id/:format", get(handlers::books::handler_download)) .at("/archive", get(handlers::source_archive::handler)) .at("/search", get(handlers::search::handler)) @@ -142,7 +147,7 @@ pub async fn run(config: Config) -> Result<(), std::io::Error> { .with(Tracing); let server = Server::new(TcpListener::bind(config.listen_address)) - .name("cops-web") + .name("little-hesinde") .run(app); tokio::select! { diff --git a/little-hesinde/templates/book_card.html b/little-hesinde/templates/book_card.html index db491eb..1549cb4 100644 --- a/little-hesinde/templates/book_card.html +++ b/little-hesinde/templates/book_card.html @@ -14,7 +14,7 @@ {% endif %} - book cover + book cover