diff --git a/.containerignore b/.containerignore deleted file mode 100644 index 5fda93c..0000000 --- a/.containerignore +++ /dev/null @@ -1,2 +0,0 @@ -/target -Containerfile diff --git a/.gitea/workflows/container.yaml b/.gitea/workflows/container.yaml deleted file mode 100644 index e48b3fd..0000000 --- a/.gitea/workflows/container.yaml +++ /dev/null @@ -1,12 +0,0 @@ -name: Build Multiarch Container Image -on: [push] -jobs: - call-reusable-workflow: - uses: container/multiarch-build-workflow/.gitea/workflows/build.yaml@main - with: - repository: ${{ gitea.repository }} - ref_name: ${{ gitea.ref_name }} - sha: ${{ gitea.sha }} - registry_url: ${{ secrets.REGISTRY_URL }} - registry_user: ${{ secrets.REGISTRY_USER }} - registry_pw: ${{ secrets.REGISTRY_PW }} diff --git a/Cargo.lock b/Cargo.lock index 970005b..3f9b5bd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -837,29 +837,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "little-hesinde" -version = "0.1.0" -dependencies = [ - "calibre-db", - "clap", - "once_cell", - "poem", - "quick-xml", - "rust-embed", - "serde", - "serde_json", - "serde_with", - "tera", - "thiserror", - "time", - "tokio", - "tokio-util", - "tracing", - "tracing-subscriber", - "uuid", -] - [[package]] name = "lock_api" version = "0.4.12" @@ -1377,6 +1354,28 @@ version = "0.1.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" +[[package]] +name = "rusty-library" +version = "0.1.0" +dependencies = [ + "calibre-db", + "clap", + "once_cell", + "poem", + "quick-xml", + "rust-embed", + "serde", + "serde_json", + "serde_with", + "tera", + "thiserror", + "time", + "tokio", + "tracing", + "tracing-subscriber", + "uuid", +] + [[package]] name = "ryu" version = "1.0.17" @@ -1511,15 +1510,6 @@ dependencies = [ "lazy_static", ] -[[package]] -name = "signal-hook-registry" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" -dependencies = [ - "libc", -] - [[package]] name = "siphasher" version = "0.3.11" @@ -1688,7 +1678,6 @@ dependencies = [ "mio", "num_cpus", "pin-project-lite", - "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.48.0", diff --git a/Cargo.toml b/Cargo.toml index 96444e1..ac2af26 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [workspace] resolver = "2" members = [ - "calibre-db", "little-hesinde", + "calibre-db", "rusty-library", ] [workspace.dependencies] diff --git a/Containerfile b/Containerfile deleted file mode 100644 index 97352b4..0000000 --- a/Containerfile +++ /dev/null @@ -1,22 +0,0 @@ -FROM docker.io/rust:1-alpine3.19 AS builder - -RUN apk --no-cache add musl-dev - -ENV CARGO_CARGO_NEW_VCS="none" -ENV CARGO_BUILD_RUSTFLAGS="-C target-feature=+crt-static" - -WORKDIR /work - -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 /app /app -CMD ["/app", "--", "/library"] - -VOLUME ["/library"] -EXPOSE 3000 diff --git a/COPYING b/LICENSE similarity index 100% rename from COPYING rename to LICENSE diff --git a/README.md b/README.md deleted file mode 100644 index 1540da2..0000000 --- a/README.md +++ /dev/null @@ -1,78 +0,0 @@ -# Little Hesinde - -I have been a long time user of [cops](https://github.com/seblucas/cops) and its -[fork](https://github.com/mikespub-org/seblucas-cops) in order to access my -ebook collection in a nice way from a browser. My biggest use always has been -the [ODPS](https://en.wikipedia.org/wiki/Open_Publication_Distribution_System) -feed for getting all of that from within [KOReader](https://koreader.rocks/). - -It so happened that exactly when I was looking for a small side project I also -had trouble with a new cops version (not through their fault, only because I -insist on writing my own containers). _How hard can it be_ I thought and went on -hacking something together. The result does at most one tenth of what cops can -do but luckily enough it is the part I need for myself. - -# Building - -## Nix - -A [nix](https://nixos.org/download/) environment with enabled -[nix-commands](https://nixos.wiki/wiki/Flakes) in order to use `nix develop` and -`nix build`. - -A statically linked binary for linux systems (using -[musl](https://musl.libc.org/)) can be compiled by running `nix build` (run -`nix flake show` to get a list of available targets). - -Otherwise run `nix develop` to be dropped into a shell with everything installed -and configured. From there all the usual `cargo` commands are accessible. - -## Classic - -A recent [rust](https://www.rust-lang.org/learn/get-started) installation is all -that is needed. - -From there on `cargo run` and `cargo build` and so on can be used. - -# Configuration - -The binary takes exactly one argument, the path to the calibre library folder. - -The listening port is hardcoded to `3000` for now, as is the listening on all -interfaces. - -# Usage - -Run the binary with the calibre library as an argument and open -http://localhost:3000 (or wherever it should be accessible). -http://localhost:3000/opds is the entry point for the OPDS feed. - -# FAQ - -## No authentication? - -Not planned, put a reverse proxy in front of it that handles access. - -## No search? - -On my todo list once I feel like I need it. - -## Why are the OPDS entries not paginated? - -My hardware (a Kobo Aura One from ~2016) with KOReader works perfectly fine with -parsing the 1MB book feed from own library. Once that changes I might get over -my laziness and implement it. - -## Aren't these database access patterns inefficient? - -They most probably are but my elaborate testing setup (my own calibre library) -works fine with it. - -## Why rust? - -I like the language and wanted to try the -[poem](https://github.com/poem-web/poem) framework. - -## Is it webscale? - -Go away. diff --git a/calibre-db/src/calibre.rs b/calibre-db/src/calibre.rs index fc6bc53..221a4cb 100644 --- a/calibre-db/src/calibre.rs +++ b/calibre-db/src/calibre.rs @@ -1,5 +1,3 @@ -//! Bundle all functions together. - use std::path::Path; use r2d2::Pool; @@ -9,17 +7,12 @@ use crate::data::{ author::Author, book::Book, error::DataStoreError, pagination::SortOrder, series::Series, }; -/// Top level calibre functions, bundling all sub functions in one place and providing secure access to -/// the database. #[derive(Debug, Clone)] pub struct Calibre { pool: Pool, } impl Calibre { - /// Open a connection to the calibre database. - /// - /// Fail if the database file can not be opened or not be found. pub fn load(path: &Path) -> Result { let manager = SqliteConnectionManager::file(path); let pool = r2d2::Pool::new(manager)?; @@ -27,8 +20,6 @@ impl Calibre { Ok(Self { pool }) } - /// Fetch book data from calibre, starting at `cursor`, fetching up to an amount of `limit` and - /// ordering by `sort_order`. pub fn books( &self, limit: u64, @@ -39,8 +30,6 @@ impl Calibre { Book::multiple(&conn, limit, cursor, sort_order) } - /// Fetch author data from calibre, starting at `cursor`, fetching up to an amount of `limit` and - /// ordering by `sort_order`. pub fn authors( &self, limit: u64, @@ -51,8 +40,6 @@ impl Calibre { Author::multiple(&conn, limit, cursor, sort_order) } - /// Fetch books for an author specified by `author_id`, paginate the books by starting at `cursor`, - /// fetching up to an amount of `limit` and ordering by `sort_order`. pub fn author_books( &self, author_id: u64, @@ -64,26 +51,21 @@ impl Calibre { Book::author_books(&conn, author_id, limit, cursor, sort_order) } - /// Get recent books up to a limit of `limit`. pub fn recent_books(&self, limit: u64) -> Result, DataStoreError> { let conn = self.pool.get()?; Book::recents(&conn, limit) } - /// Get a single book, specified `id`. pub fn scalar_book(&self, id: u64) -> Result { let conn = self.pool.get()?; Book::scalar_book(&conn, id) } - /// Get the author to a book with id `id`. pub fn book_author(&self, id: u64) -> Result { let conn = self.pool.get()?; Author::book_author(&conn, id) } - /// Fetch series data from calibre, starting at `cursor`, fetching up to an amount of `limit` and - /// ordering by `sort_order`. pub fn series( &self, limit: u64, @@ -94,61 +76,51 @@ impl Calibre { Series::multiple(&conn, limit, cursor, sort_order) } - /// Get the series a book with id `id` is in, as well as the book's position within the series. pub fn book_series(&self, id: u64) -> Result, DataStoreError> { let conn = self.pool.get()?; Series::book_series(&conn, id) } - /// Get all books belonging to the series with id `id`. pub fn series_books(&self, id: u64) -> Result, DataStoreError> { let conn = self.pool.get()?; Book::series_books(&conn, id) } - /// Check if there are more authors before the specified cursor. pub fn has_previous_authors(&self, author_sort: &str) -> Result { let conn = self.pool.get()?; Author::has_previous_authors(&conn, author_sort) } - /// Check if there are more authors after the specified cursor. pub fn has_more_authors(&self, author_sort: &str) -> Result { let conn = self.pool.get()?; Author::has_more_authors(&conn, author_sort) } - /// Check if there are more books before the specified cursor. pub fn has_previous_books(&self, book_sort: &str) -> Result { let conn = self.pool.get()?; Book::has_previous_books(&conn, book_sort) } - /// Check if there are more books after the specified cursor. pub fn has_more_books(&self, book_sort: &str) -> Result { let conn = self.pool.get()?; Book::has_more_books(&conn, book_sort) } - /// Check if there are more series before the specified cursor. pub fn has_previous_series(&self, series_sort: &str) -> Result { let conn = self.pool.get()?; Series::has_previous_series(&conn, series_sort) } - /// Check if there are more series after the specified cursor. pub fn has_more_series(&self, series_sort: &str) -> Result { let conn = self.pool.get()?; Series::has_more_series(&conn, series_sort) } - /// Fetch a single author with id `id`. pub fn scalar_author(&self, id: u64) -> Result { let conn = self.pool.get()?; Author::scalar_author(&conn, id) } - /// Fetch a single series with id `id`. pub fn scalar_series(&self, id: u64) -> Result { let conn = self.pool.get()?; Series::scalar_series(&conn, id) diff --git a/calibre-db/src/data/author.rs b/calibre-db/src/data/author.rs index e79da5a..2ecf9e5 100644 --- a/calibre-db/src/data/author.rs +++ b/calibre-db/src/data/author.rs @@ -1,5 +1,3 @@ -//! Author data. - use rusqlite::{named_params, Connection, Row}; use serde::Serialize; @@ -8,14 +6,10 @@ use super::{ pagination::{Pagination, SortOrder}, }; -/// Author in calibre. #[derive(Debug, Clone, Serialize)] pub struct Author { - /// Id in database. pub id: u64, - /// Full name. pub name: String, - /// Full name for sorting. pub sort: String, } @@ -28,8 +22,6 @@ impl Author { }) } - /// Fetch author data from calibre, starting at `cursor`, fetching up to an amount of `limit` and - /// ordering by `sort_order`. pub fn multiple( conn: &Connection, limit: u64, @@ -45,7 +37,6 @@ impl Author { ) } - /// Get the author to a book with id `id`. pub fn book_author(conn: &Connection, id: u64) -> Result { let mut stmt = conn.prepare( "SELECT authors.id, authors.name, authors.sort FROM authors \ @@ -56,14 +47,12 @@ impl Author { Ok(stmt.query_row(params, Self::from_row)?) } - /// Fetch a single author with id `id`. pub fn scalar_author(conn: &Connection, id: u64) -> Result { let mut stmt = conn.prepare("SELECT id, name, sort FROM authors WHERE id = (:id)")?; let params = named_params! { ":id": id }; Ok(stmt.query_row(params, Self::from_row)?) } - /// Check if there are more authors before the specified cursor. pub fn has_previous_authors( conn: &Connection, sort_name: &str, @@ -71,7 +60,6 @@ impl Author { Pagination::has_prev_or_more(conn, "authors", sort_name, &SortOrder::DESC) } - /// Check if there are more authors after the specified cursor. pub fn has_more_authors(conn: &Connection, sort_name: &str) -> Result { Pagination::has_prev_or_more(conn, "authors", sort_name, &SortOrder::ASC) } diff --git a/calibre-db/src/data/book.rs b/calibre-db/src/data/book.rs index 4547008..a893b2e 100644 --- a/calibre-db/src/data/book.rs +++ b/calibre-db/src/data/book.rs @@ -1,5 +1,3 @@ -//! Book data. - use rusqlite::{named_params, Connection, Row}; use serde::Serialize; use time::OffsetDateTime; @@ -9,22 +7,14 @@ use super::{ pagination::{Pagination, SortOrder}, }; -/// Book in calibre. -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Serialize)] pub struct Book { - /// Id in database. pub id: u64, - /// Book title. pub title: String, - /// Book title for sorting. pub sort: String, - /// Folder of the book within the calibre library. pub path: String, - /// Uuid of the book. pub uuid: String, - /// When was the book last modified. pub last_modified: OffsetDateTime, - /// Optional description. pub description: Option, } @@ -41,8 +31,6 @@ impl Book { }) } - /// Fetch book data from calibre, starting at `cursor`, fetching up to an amount of `limit` and - /// ordering by `sort_order`. pub fn multiple( conn: &Connection, limit: u64, @@ -59,8 +47,6 @@ impl Book { ) } - /// Fetch books for an author specified by `author_id`, paginate the books by starting at `cursor`, - /// fetching up to an amount of `limit` and ordering by `sort_order`. pub fn author_books( conn: &Connection, author_id: u64, @@ -80,7 +66,6 @@ impl Book { ) } - /// Get all books belonging to the series with id `id`. pub fn series_books(conn: &Connection, id: u64) -> Result, DataStoreError> { let mut stmt = conn.prepare( "SELECT books.id, books.title, books.sort, books.path, books.uuid, books.last_modified, comments.text FROM series \ @@ -95,7 +80,6 @@ impl Book { Ok(iter.filter_map(Result::ok).collect()) } - /// Get recent books up to a limit of `limit`. pub fn recents(conn: &Connection, limit: u64) -> Result, DataStoreError> { let mut stmt = conn.prepare( "SELECT books.id, books.title, books.sort, books.path, books.uuid, books.last_modified, comments.text \ @@ -106,7 +90,6 @@ impl Book { Ok(iter.filter_map(Result::ok).collect()) } - /// Get a single book, specified `id`. pub fn scalar_book(conn: &Connection, id: u64) -> Result { let mut stmt = conn.prepare( "SELECT books.id, books.title, books.sort, books.path, books.uuid, books.last_modified, comments.text \ @@ -116,12 +99,10 @@ impl Book { Ok(stmt.query_row(params, Self::from_row)?) } - /// Check if there are more books before the specified cursor. pub fn has_previous_books(conn: &Connection, sort_title: &str) -> Result { Pagination::has_prev_or_more(conn, "books", sort_title, &SortOrder::DESC) } - /// Check if there are more books after the specified cursor. pub fn has_more_books(conn: &Connection, sort_title: &str) -> Result { Pagination::has_prev_or_more(conn, "books", sort_title, &SortOrder::ASC) } diff --git a/calibre-db/src/data/error.rs b/calibre-db/src/data/error.rs index ea78a79..0680abc 100644 --- a/calibre-db/src/data/error.rs +++ b/calibre-db/src/data/error.rs @@ -1,28 +1,19 @@ -//! Error handling for calibre database access. - use thiserror::Error; use time::error::Parse; -/// Errors from accessing the calibre database. #[derive(Error, Debug)] #[error("data store error")] pub enum DataStoreError { - /// Found no entries for the query. #[error("no results")] NoResults(rusqlite::Error), - /// Error with SQLite. #[error("sqlite error")] SqliteError(rusqlite::Error), - /// Error connecting to the database. #[error("connection error")] ConnectionError(#[from] r2d2::Error), - /// Error wparsing a datetime from the database. #[error("failed to parse datetime")] DateTimeError(#[from] Parse), } -/// Convert an SQLite error into a proper NoResults one if the query -/// returned no rows, return others as is. impl From for DataStoreError { fn from(error: rusqlite::Error) -> Self { match error { diff --git a/calibre-db/src/data/pagination.rs b/calibre-db/src/data/pagination.rs index c2f6064..d08449b 100644 --- a/calibre-db/src/data/pagination.rs +++ b/calibre-db/src/data/pagination.rs @@ -1,33 +1,22 @@ -//! Cursor pagination handling. - use rusqlite::{named_params, Connection, Row, ToSql}; use serde::{Deserialize, Serialize}; use super::error::DataStoreError; -/// How to sort query results. Signifying whether we are paginating forwards or backwards. #[derive(Debug, Copy, Clone, PartialEq, Deserialize, Serialize)] pub enum SortOrder { - /// Forwards ASC, - /// Backwards DESC, } -/// Pagination data. pub struct Pagination<'a> { - /// Sort by this column. pub sort_col: &'a str, - /// Limit returned results. pub limit: u64, - /// Where to start paginating. pub cursor: Option<&'a str>, - /// Paginating forwards or backwards. pub sort_order: SortOrder, } impl<'a> Pagination<'a> { - /// Create a new pagination. pub fn new( sort_col: &'a str, cursor: Option<&'a str>, @@ -51,7 +40,6 @@ impl<'a> Pagination<'a> { .to_string() } - /// Check if there are more items forwards or backwards from `cursor` (direction specified by `sort_order`). pub fn has_prev_or_more( conn: &Connection, table: &str, @@ -69,7 +57,6 @@ impl<'a> Pagination<'a> { Ok(count > 0) } - /// Paginate a statement. pub fn paginate( &self, conn: &Connection, @@ -90,8 +77,7 @@ impl<'a> Pagination<'a> { }; let sort_col = self.sort_col; - // otherwise paginated statements with join will fails, not happy with this but fine for - // now + // otherwise paginated statements with join will fail let sort_col_wrapped = if let Some(index) = sort_col.find('.') { let right_part = &sort_col[index..]; "t".to_owned() + right_part diff --git a/calibre-db/src/data/series.rs b/calibre-db/src/data/series.rs index b467fde..ffe43bf 100644 --- a/calibre-db/src/data/series.rs +++ b/calibre-db/src/data/series.rs @@ -1,5 +1,3 @@ -//! Series data. - use rusqlite::{named_params, Connection, Row}; use serde::Serialize; @@ -8,14 +6,10 @@ use super::{ pagination::{Pagination, SortOrder}, }; -/// Series in calibre. #[derive(Debug, Clone, Serialize)] pub struct Series { - /// Id in database. pub id: u64, - /// Series name. pub name: String, - /// Series name for sorting. pub sort: String, } @@ -28,8 +22,6 @@ impl Series { }) } - /// Fetch series data from calibre, starting at `cursor`, fetching up to an amount of `limit` and - /// ordering by `sort_order`. pub fn multiple( conn: &Connection, limit: u64, @@ -45,14 +37,12 @@ impl Series { ) } - /// Fetch a single series with id `id`. pub fn scalar_series(conn: &Connection, id: u64) -> Result { let mut stmt = conn.prepare("SELECT id, name, sort FROM series WHERE id = (:id)")?; let params = named_params! { ":id": id }; Ok(stmt.query_row(params, Self::from_row)?) } - /// Get the series a book with id `id` is in, as well as the book's position within the series. pub fn book_series( conn: &Connection, book_id: u64, @@ -78,12 +68,10 @@ impl Series { } } - /// Check if there are more series before the specified cursor. pub fn has_previous_series(conn: &Connection, sort_name: &str) -> Result { Pagination::has_prev_or_more(conn, "series", sort_name, &SortOrder::DESC) } - /// Check if there are more series after the specified cursor. pub fn has_more_series(conn: &Connection, sort_name: &str) -> Result { Pagination::has_prev_or_more(conn, "series", sort_name, &SortOrder::ASC) } diff --git a/calibre-db/src/lib.rs b/calibre-db/src/lib.rs index bae8fbc..c7a0452 100644 --- a/calibre-db/src/lib.rs +++ b/calibre-db/src/lib.rs @@ -1,7 +1,4 @@ -//! Read data from a calibre library, leveraging its SQLite metadata database. - pub mod calibre; -/// Data structs for the calibre database. pub mod data { pub mod author; pub mod book; diff --git a/flake.nix b/flake.nix index da1dc6f..68b8983 100644 --- a/flake.nix +++ b/flake.nix @@ -1,5 +1,5 @@ { - description = "little-hesinde project"; + description = "rusty-library project"; inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; flake-utils.url = "github:numtide/flake-utils"; diff --git a/little-hesinde/src/app_state.rs b/little-hesinde/src/app_state.rs deleted file mode 100644 index 7136258..0000000 --- a/little-hesinde/src/app_state.rs +++ /dev/null @@ -1,13 +0,0 @@ -//! Data for global app state. - -use calibre_db::calibre::Calibre; - -use crate::config::Config; - -/// Global application state, meant to be used in request handlers. -pub struct AppState { - /// Access calibre database. - pub calibre: Calibre, - /// Access application configuration. - pub config: Config, -} diff --git a/little-hesinde/src/handlers/authors.rs b/little-hesinde/src/handlers/authors.rs deleted file mode 100644 index f6d4ba1..0000000 --- a/little-hesinde/src/handlers/authors.rs +++ /dev/null @@ -1,44 +0,0 @@ -//! Handle requests for multiple authors. - -use std::sync::Arc; - -use calibre_db::{calibre::Calibre, data::pagination::SortOrder}; -use poem::{ - handler, - web::{Data, Path}, - Response, -}; - -use crate::{app_state::AppState, Accept}; - -/// Handle a request for multiple authors, starting at the first. -#[handler] -pub async fn handler_init( - accept: Data<&Accept>, - state: Data<&Arc>, -) -> Result { - authors(&accept, &state.calibre, None, &SortOrder::ASC).await -} - -/// Handle a request for multiple authors, starting at the `cursor` and going in the direction of -/// `sort_order`. -#[handler] -pub async fn handler( - Path((cursor, sort_order)): Path<(String, SortOrder)>, - accept: Data<&Accept>, - state: Data<&Arc>, -) -> Result { - authors(&accept, &state.calibre, Some(&cursor), &sort_order).await -} - -async fn authors( - acccept: &Accept, - calibre: &Calibre, - cursor: Option<&str>, - sort_order: &SortOrder, -) -> Result { - match acccept { - Accept::Html => crate::handlers::html::authors::handler(calibre, cursor, sort_order).await, - Accept::Opds => crate::handlers::opds::authors::handler(calibre, cursor, sort_order).await, - } -} diff --git a/little-hesinde/src/handlers/books.rs b/little-hesinde/src/handlers/books.rs deleted file mode 100644 index 9944821..0000000 --- a/little-hesinde/src/handlers/books.rs +++ /dev/null @@ -1,44 +0,0 @@ -//! Handle requests for multiple books. - -use std::sync::Arc; - -use calibre_db::data::pagination::SortOrder; -use poem::{ - handler, - web::{Data, Path}, - Response, -}; - -use crate::{app_state::AppState, Accept}; - -/// Handle a request for multiple books, starting at the first. -#[handler] -pub async fn handler_init( - accept: Data<&Accept>, - state: Data<&Arc>, -) -> Result { - books(&accept, &state, None, &SortOrder::ASC).await -} - -/// Handle a request for multiple books, starting at the `cursor` and going in the direction of -/// `sort_order`. -#[handler] -pub async fn handler( - Path((cursor, sort_order)): Path<(String, SortOrder)>, - accept: Data<&Accept>, - state: Data<&Arc>, -) -> Result { - books(&accept, &state, Some(&cursor), &sort_order).await -} - -async fn books( - accept: &Accept, - state: &Arc, - cursor: Option<&str>, - sort_order: &SortOrder, -) -> Result { - match accept { - Accept::Html => crate::handlers::html::books::handler(state, cursor, sort_order).await, - Accept::Opds => crate::handlers::opds::books::handler(state, cursor, sort_order).await, - } -} diff --git a/little-hesinde/src/handlers/download.rs b/little-hesinde/src/handlers/download.rs deleted file mode 100644 index 1f25c56..0000000 --- a/little-hesinde/src/handlers/download.rs +++ /dev/null @@ -1,50 +0,0 @@ -//! Handle requests for specific formats of a book. - -use std::sync::Arc; - -use tokio::fs::File; - -use poem::{ - error::NotFoundError, - handler, - web::{Data, Path}, - Body, IntoResponse, Response, -}; -use tokio_util::io::ReaderStream; - -use crate::{ - app_state::AppState, - data::book::{Book, Format}, - handlers::error::HandlerError, - opds::media_type::MediaType, -}; - -/// Handle a request for a book with id `id` in format `format`. -#[handler] -pub async fn handler( - Path((id, format)): Path<(u64, String)>, - state: Data<&Arc>, -) -> Result { - let book = state - .calibre - .scalar_book(id) - .map_err(HandlerError::DataError)?; - let book = Book::full_book(&book, &state).ok_or(NotFoundError)?; - let format = Format(format); - let file_name = book.formats.get(&format).ok_or(NotFoundError)?; - let file_path = state - .config - .library_path - .join(book.data.path) - .join(file_name); - - let mut file = File::open(file_path).await.map_err(|_| NotFoundError)?; - let stream = ReaderStream::new(file); - let body = Body::from_bytes_stream(stream); - - let content_type: MediaType = format.into(); - Ok(body - .with_content_type(format!("{content_type}")) - .with_header("Content-Disposition", format!("filename=\"{file_name}\"")) - .into_response()) -} diff --git a/little-hesinde/src/handlers/html/author.rs b/little-hesinde/src/handlers/html/author.rs deleted file mode 100644 index d27c6d3..0000000 --- a/little-hesinde/src/handlers/html/author.rs +++ /dev/null @@ -1,21 +0,0 @@ -//! Handle a single author for html. - -use calibre_db::data::author::Author; -use poem::{error::InternalServerError, web::Html, IntoResponse, Response}; -use tera::Context; - -use crate::{data::book::Book, templates::TEMPLATES}; - -/// Render a single author in html. -pub async fn handler(author: Author, books: Vec) -> Result { - let mut context = Context::new(); - context.insert("title", &author.name); - context.insert("nav", "authors"); - context.insert("books", &books); - - Ok(TEMPLATES - .render("book_list", &context) - .map_err(InternalServerError) - .map(Html)? - .into_response()) -} diff --git a/little-hesinde/src/handlers/html/authors.rs b/little-hesinde/src/handlers/html/authors.rs deleted file mode 100644 index 6db29d4..0000000 --- a/little-hesinde/src/handlers/html/authors.rs +++ /dev/null @@ -1,21 +0,0 @@ -//! Handle multiple authors in html. - -use calibre_db::{calibre::Calibre, data::pagination::SortOrder}; -use poem::Response; - -use crate::handlers::paginated; - -/// Render all authors paginated by cursor in html. -pub async fn handler( - calibre: &Calibre, - cursor: Option<&str>, - sort_order: &SortOrder, -) -> Result { - paginated::render( - "authors", - || calibre.authors(25, cursor, sort_order), - |author| author.sort.clone(), - |cursor| calibre.has_previous_authors(cursor), - |cursor| calibre.has_more_authors(cursor), - ) -} diff --git a/little-hesinde/src/handlers/html/books.rs b/little-hesinde/src/handlers/html/books.rs deleted file mode 100644 index 14700ae..0000000 --- a/little-hesinde/src/handlers/html/books.rs +++ /dev/null @@ -1,26 +0,0 @@ -//! Handle multiple books in html. - -use calibre_db::data::pagination::SortOrder; -use poem::Response; - -use crate::{app_state::AppState, data::book::Book, handlers::paginated}; - -/// Render all books paginated by cursor in html. -pub async fn handler( - state: &AppState, - cursor: Option<&str>, - sort_order: &SortOrder, -) -> Result { - paginated::render( - "books", - || { - state - .calibre - .books(25, cursor, sort_order) - .map(|x| x.iter().filter_map(|y| Book::full_book(y, state)).collect()) - }, - |book| book.data.sort.clone(), - |cursor| state.calibre.has_previous_books(cursor), - |cursor| state.calibre.has_more_books(cursor), - ) -} diff --git a/little-hesinde/src/handlers/html/recent.rs b/little-hesinde/src/handlers/html/recent.rs deleted file mode 100644 index 05c142f..0000000 --- a/little-hesinde/src/handlers/html/recent.rs +++ /dev/null @@ -1,20 +0,0 @@ -//! Handle recent books in html. - -use poem::{error::InternalServerError, web::Html, IntoResponse, Response}; -use tera::Context; - -use crate::{data::book::Book, templates::TEMPLATES}; - -/// Render recent books as html. -pub async fn handler(recent_books: Vec) -> Result { - let mut context = Context::new(); - context.insert("title", "Recent Books"); - context.insert("nav", "recent"); - context.insert("books", &recent_books); - - Ok(TEMPLATES - .render("book_list", &context) - .map_err(InternalServerError) - .map(Html)? - .into_response()) -} diff --git a/little-hesinde/src/handlers/html/series.rs b/little-hesinde/src/handlers/html/series.rs deleted file mode 100644 index 856939d..0000000 --- a/little-hesinde/src/handlers/html/series.rs +++ /dev/null @@ -1,21 +0,0 @@ -//! Handle multiple series in html. - -use calibre_db::{calibre::Calibre, data::pagination::SortOrder}; -use poem::Response; - -use crate::handlers::paginated; - -/// Render all series paginated by cursor as html. -pub async fn handler( - calibre: &Calibre, - cursor: Option<&str>, - sort_order: &SortOrder, -) -> Result { - paginated::render( - "series", - || calibre.series(25, cursor, sort_order), - |series| series.sort.clone(), - |cursor| calibre.has_previous_series(cursor), - |cursor| calibre.has_more_series(cursor), - ) -} diff --git a/little-hesinde/src/handlers/html/series_single.rs b/little-hesinde/src/handlers/html/series_single.rs deleted file mode 100644 index 148aad4..0000000 --- a/little-hesinde/src/handlers/html/series_single.rs +++ /dev/null @@ -1,21 +0,0 @@ -//! Handle a single series in html. - -use calibre_db::data::series::Series; -use poem::{error::InternalServerError, web::Html, IntoResponse, Response}; -use tera::Context; - -use crate::{data::book::Book, templates::TEMPLATES}; - -/// Render a single series as html. -pub async fn handler(series: Series, books: Vec) -> Result { - let mut context = Context::new(); - context.insert("title", &series.name); - context.insert("nav", "series"); - context.insert("books", &books); - - Ok(TEMPLATES - .render("book_list", &context) - .map_err(InternalServerError) - .map(Html)? - .into_response()) -} diff --git a/little-hesinde/src/handlers/opds/author.rs b/little-hesinde/src/handlers/opds/author.rs deleted file mode 100644 index 69fbb2a..0000000 --- a/little-hesinde/src/handlers/opds/author.rs +++ /dev/null @@ -1,38 +0,0 @@ -//! Handle a single author for opds. - -use calibre_db::data::author::Author; -use poem::{IntoResponse, Response}; -use time::OffsetDateTime; - -use crate::{ - data::book::Book, - handlers::error::HandlerError, - opds::{entry::Entry, feed::Feed, link::Link, media_type::MediaType, relation::Relation}, -}; - -/// Render a single author as an OPDS entry embedded in a feed. -pub async fn handler(author: Author, books: Vec) -> Result { - let entries: Vec = books.into_iter().map(Entry::from).collect(); - let now = OffsetDateTime::now_utc(); - - let self_link = Link { - href: format!("/opds/authors/{}", author.id), - media_type: MediaType::Navigation, - rel: Relation::Myself, - title: None, - count: None, - }; - let feed = Feed::create( - now, - &format!("little-hesinde:author:{}", author.id), - &author.name, - self_link, - vec![], - entries, - ); - let xml = feed.as_xml().map_err(HandlerError::OpdsError)?; - - Ok(xml - .with_content_type("application/atom+xml") - .into_response()) -} diff --git a/little-hesinde/src/handlers/opds/authors.rs b/little-hesinde/src/handlers/opds/authors.rs deleted file mode 100644 index ce8a744..0000000 --- a/little-hesinde/src/handlers/opds/authors.rs +++ /dev/null @@ -1,48 +0,0 @@ -//! Handle multiple authors for opds. - -use calibre_db::{ - calibre::Calibre, - data::{author::Author as DbAuthor, pagination::SortOrder}, -}; -use poem::{IntoResponse, Response}; -use time::OffsetDateTime; - -use crate::{ - handlers::error::HandlerError, - opds::{entry::Entry, feed::Feed, link::Link, media_type::MediaType, relation::Relation}, -}; - -/// Render all authors as OPDS entries embedded in a feed. -pub async fn handler( - calibre: &Calibre, - _cursor: Option<&str>, - _sort_order: &SortOrder, -) -> Result { - let authors: Vec = calibre - .authors(u32::MAX.into(), None, &SortOrder::ASC) - .map_err(HandlerError::DataError)?; - - let entries: Vec = authors.into_iter().map(Entry::from).collect(); - let now = OffsetDateTime::now_utc(); - - let self_link = Link { - href: "/opds/authors".to_string(), - media_type: MediaType::Navigation, - rel: Relation::Myself, - title: None, - count: None, - }; - let feed = Feed::create( - now, - "little-hesinde:authors", - "All Authors", - self_link, - vec![], - entries, - ); - let xml = feed.as_xml().map_err(HandlerError::OpdsError)?; - - Ok(xml - .with_content_type("application/atom+xml") - .into_response()) -} diff --git a/little-hesinde/src/handlers/opds/books.rs b/little-hesinde/src/handlers/opds/books.rs deleted file mode 100644 index c1c8a74..0000000 --- a/little-hesinde/src/handlers/opds/books.rs +++ /dev/null @@ -1,49 +0,0 @@ -//! Handle multiple books for opds. - -use calibre_db::data::pagination::SortOrder; -use poem::{IntoResponse, Response}; -use time::OffsetDateTime; - -use crate::{ - app_state::AppState, - data::book::Book, - handlers::error::HandlerError, - opds::{entry::Entry, feed::Feed, link::Link, media_type::MediaType, relation::Relation}, -}; - -/// Render all books as OPDS entries embedded in a feed. -pub async fn handler( - state: &AppState, - _cursor: Option<&str>, - _sort_order: &SortOrder, -) -> Result { - let books: Vec = state - .calibre - .books(u32::MAX.into(), None, &SortOrder::ASC) - .map(|x| x.iter().filter_map(|y| Book::full_book(y, state)).collect()) - .map_err(HandlerError::DataError)?; - - let entries: Vec = books.into_iter().map(Entry::from).collect(); - let now = OffsetDateTime::now_utc(); - - let self_link = Link { - href: "/opds/books".to_string(), - media_type: MediaType::Navigation, - rel: Relation::Myself, - title: None, - count: None, - }; - let feed = Feed::create( - now, - "little-hesinde:books", - "All Books", - self_link, - vec![], - entries, - ); - let xml = feed.as_xml().map_err(HandlerError::OpdsError)?; - - Ok(xml - .with_content_type("application/atom+xml") - .into_response()) -} diff --git a/little-hesinde/src/handlers/opds/feed.rs b/little-hesinde/src/handlers/opds/feed.rs deleted file mode 100644 index f402aff..0000000 --- a/little-hesinde/src/handlers/opds/feed.rs +++ /dev/null @@ -1,109 +0,0 @@ -//! Handle the OPDS root feed. - -use poem::{handler, web::WithContentType, IntoResponse}; -use time::OffsetDateTime; - -use crate::{ - handlers::error::HandlerError, - opds::{ - content::Content, entry::Entry, feed::Feed, link::Link, media_type::MediaType, - relation::Relation, - }, -}; - -/// Render a root OPDS feed with links to the subsections (authors, books, series and recent). -#[handler] -pub async fn handler() -> Result, poem::Error> { - let now = OffsetDateTime::now_utc(); - - let self_link = Link { - href: "/opds".to_string(), - media_type: MediaType::Navigation, - rel: Relation::Myself, - title: None, - count: None, - }; - let books_entry = Entry { - title: "Books".to_string(), - id: "little-hesinde:books".to_string(), - updated: now, - content: Some(Content { - media_type: MediaType::Text, - content: "Index of all books".to_string(), - }), - author: None, - links: vec![Link { - href: "/opds/books".to_string(), - media_type: MediaType::Navigation, - rel: Relation::Subsection, - title: None, - count: None, - }], - }; - - let authors_entry = Entry { - title: "Authors".to_string(), - id: "little-hesinde:authors".to_string(), - updated: now, - content: Some(Content { - media_type: MediaType::Text, - content: "Index of all authors".to_string(), - }), - author: None, - links: vec![Link { - href: "/opds/authors".to_string(), - media_type: MediaType::Navigation, - rel: Relation::Subsection, - title: None, - count: None, - }], - }; - - let series_entry = Entry { - title: "Series".to_string(), - id: "little-hesinde:series".to_string(), - updated: now, - content: Some(Content { - media_type: MediaType::Text, - content: "Index of all series".to_string(), - }), - author: None, - links: vec![Link { - href: "/opds/series".to_string(), - media_type: MediaType::Navigation, - rel: Relation::Subsection, - title: None, - count: None, - }], - }; - - let recents_entry = Entry { - title: "Recent Additions".to_string(), - id: "little-hesinde:recentbooks".to_string(), - updated: now, - content: Some(Content { - media_type: MediaType::Text, - content: "Recently added books".to_string(), - }), - author: None, - links: vec![Link { - href: "/opds/recent".to_string(), - media_type: MediaType::Navigation, - rel: Relation::Subsection, - title: None, - count: None, - }], - }; - - let feed = Feed::create( - now, - "little-hesinde:catalog", - "Little Hesinde", - self_link, - vec![], - vec![authors_entry, series_entry, books_entry, recents_entry], - ); - let xml = feed.as_xml().map_err(HandlerError::OpdsError)?; - - Ok(xml.with_content_type("application/atom+xml")) -} diff --git a/little-hesinde/src/handlers/opds/recent.rs b/little-hesinde/src/handlers/opds/recent.rs deleted file mode 100644 index 8e36691..0000000 --- a/little-hesinde/src/handlers/opds/recent.rs +++ /dev/null @@ -1,37 +0,0 @@ -//! Handle recent books for OPDS. - -use poem::{IntoResponse, Response}; -use time::OffsetDateTime; - -use crate::{ - data::book::Book, - handlers::error::HandlerError, - opds::{entry::Entry, feed::Feed, link::Link, media_type::MediaType, relation::Relation}, -}; - -/// Render recent books as OPDS entries embedded in a feed. -pub async fn handler(recent_books: Vec) -> Result { - let entries: Vec = recent_books.into_iter().map(Entry::from).collect(); - let now = OffsetDateTime::now_utc(); - - let self_link = Link { - href: "/opds/recent".to_string(), - media_type: MediaType::Navigation, - rel: Relation::Myself, - title: None, - count: None, - }; - let feed = Feed::create( - now, - "little-hesinde:recentbooks", - "Recent Books", - self_link, - vec![], - entries, - ); - let xml = feed.as_xml().map_err(HandlerError::OpdsError)?; - - Ok(xml - .with_content_type("application/atom+xml") - .into_response()) -} diff --git a/little-hesinde/src/handlers/opds/series.rs b/little-hesinde/src/handlers/opds/series.rs deleted file mode 100644 index 9aeedf2..0000000 --- a/little-hesinde/src/handlers/opds/series.rs +++ /dev/null @@ -1,45 +0,0 @@ -//! Handle multiple series for OPDS. - -use calibre_db::{calibre::Calibre, data::pagination::SortOrder}; -use poem::{IntoResponse, Response}; -use time::OffsetDateTime; - -use crate::{ - handlers::error::HandlerError, - opds::{entry::Entry, feed::Feed, link::Link, media_type::MediaType, relation::Relation}, -}; - -/// Render all series as OPDS entries embedded in a feed. -pub async fn handler( - calibre: &Calibre, - _cursor: Option<&str>, - _sort_order: &SortOrder, -) -> Result { - let series = calibre - .series(u32::MAX.into(), None, &SortOrder::ASC) - .map_err(HandlerError::DataError)?; - - let entries: Vec = series.into_iter().map(Entry::from).collect(); - let now = OffsetDateTime::now_utc(); - - let self_link = Link { - href: "/opds/series".to_string(), - media_type: MediaType::Navigation, - rel: Relation::Myself, - title: None, - count: None, - }; - let feed = Feed::create( - now, - "little-hesinde:series", - "All Series", - self_link, - vec![], - entries, - ); - let xml = feed.as_xml().map_err(HandlerError::OpdsError)?; - - Ok(xml - .with_content_type("application/atom+xml") - .into_response()) -} diff --git a/little-hesinde/src/handlers/opds/series_single.rs b/little-hesinde/src/handlers/opds/series_single.rs deleted file mode 100644 index 20d68ba..0000000 --- a/little-hesinde/src/handlers/opds/series_single.rs +++ /dev/null @@ -1,38 +0,0 @@ -//! Handle a single series for opds. - -use calibre_db::data::series::Series; -use poem::{IntoResponse, Response}; -use time::OffsetDateTime; - -use crate::{ - data::book::Book, - handlers::error::HandlerError, - opds::{entry::Entry, feed::Feed, link::Link, media_type::MediaType, relation::Relation}, -}; - -/// Render a single series as an OPDS entry embedded in a feed. -pub async fn handler(series: Series, books: Vec) -> Result { - let entries: Vec = books.into_iter().map(Entry::from).collect(); - let now = OffsetDateTime::now_utc(); - - let self_link = Link { - href: format!("/opds/series/{}", series.id), - media_type: MediaType::Navigation, - rel: Relation::Myself, - title: None, - count: None, - }; - let feed = Feed::create( - now, - &format!("little-hesinde:series:{}", series.id), - &series.name, - self_link, - vec![], - entries, - ); - let xml = feed.as_xml().map_err(HandlerError::OpdsError)?; - - Ok(xml - .with_content_type("application/atom+xml") - .into_response()) -} diff --git a/little-hesinde/src/handlers/recent.rs b/little-hesinde/src/handlers/recent.rs deleted file mode 100644 index 44f268c..0000000 --- a/little-hesinde/src/handlers/recent.rs +++ /dev/null @@ -1,28 +0,0 @@ -//! Handle requests for recent books. - -use std::sync::Arc; - -use poem::{handler, web::Data, Response}; - -use crate::{app_state::AppState, data::book::Book, handlers::error::HandlerError, Accept}; - -/// Handle a request recent books and decide whether to render to html or OPDS. -#[handler] -pub async fn handler( - accept: Data<&Accept>, - state: Data<&Arc>, -) -> Result { - let recent_books = state - .calibre - .recent_books(25) - .map_err(HandlerError::DataError)?; - let recent_books = recent_books - .iter() - .filter_map(|x| Book::full_book(x, &state)) - .collect::>(); - - match accept.0 { - Accept::Html => crate::handlers::html::recent::handler(recent_books).await, - Accept::Opds => crate::handlers::opds::recent::handler(recent_books).await, - } -} diff --git a/little-hesinde/src/handlers/series.rs b/little-hesinde/src/handlers/series.rs deleted file mode 100644 index 9c701e9..0000000 --- a/little-hesinde/src/handlers/series.rs +++ /dev/null @@ -1,48 +0,0 @@ -//! Handle requests for multiple series. - -use std::sync::Arc; - -use calibre_db::data::pagination::SortOrder; -use poem::{ - handler, - web::{Data, Path}, - Response, -}; - -use crate::{app_state::AppState, Accept}; - -/// Handle a request for multiple series, starting at the first. -#[handler] -pub async fn handler_init( - accept: Data<&Accept>, - state: Data<&Arc>, -) -> Result { - series(&accept, &state, None, &SortOrder::ASC).await -} - -/// Handle a request for multiple series, starting at the `cursor` and going in the direction of -/// `sort_order`. -#[handler] -pub async fn handler( - Path((cursor, sort_order)): Path<(String, SortOrder)>, - accept: Data<&Accept>, - state: Data<&Arc>, -) -> Result { - series(&accept, &state, Some(&cursor), &sort_order).await -} - -async fn series( - accept: &Accept, - state: &Arc, - cursor: Option<&str>, - sort_order: &SortOrder, -) -> Result { - match accept { - Accept::Html => { - crate::handlers::html::series::handler(&state.calibre, cursor, sort_order).await - } - Accept::Opds => { - crate::handlers::opds::series::handler(&state.calibre, cursor, sort_order).await - } - } -} diff --git a/little-hesinde/src/handlers/series_single.rs b/little-hesinde/src/handlers/series_single.rs deleted file mode 100644 index 7b41b7a..0000000 --- a/little-hesinde/src/handlers/series_single.rs +++ /dev/null @@ -1,37 +0,0 @@ -//! Handle requests for a single series. - -use std::sync::Arc; - -use poem::{ - handler, - web::{Data, Path}, - Response, -}; - -use crate::{app_state::AppState, data::book::Book, handlers::error::HandlerError, Accept}; - -/// Handle a request for a series with `id` and decide whether to render to html or OPDS. -#[handler] -pub async fn handler( - id: Path, - accept: Data<&Accept>, - state: Data<&Arc>, -) -> Result { - let series = state - .calibre - .scalar_series(*id) - .map_err(HandlerError::DataError)?; - let books = state - .calibre - .series_books(*id) - .map_err(HandlerError::DataError)?; - let books = books - .iter() - .filter_map(|x| Book::full_book(x, &state)) - .collect::>(); - - match accept.0 { - Accept::Html => crate::handlers::html::series_single::handler(series, books).await, - Accept::Opds => crate::handlers::opds::series_single::handler(series, books).await, - } -} diff --git a/little-hesinde/src/lib.rs b/little-hesinde/src/lib.rs deleted file mode 100644 index 951b117..0000000 --- a/little-hesinde/src/lib.rs +++ /dev/null @@ -1,139 +0,0 @@ -//! A very simple ebook server for a calibre library, providing a html interface as well as an OPDS feed. -//! -//! Shamelessly written to scratch my own itches. - -use std::sync::Arc; - -use app_state::AppState; -use calibre_db::calibre::Calibre; -use config::Config; -use poem::{ - endpoint::EmbeddedFilesEndpoint, get, listener::TcpListener, middleware::Tracing, EndpointExt, - Route, Server, -}; -use rust_embed::RustEmbed; -use tokio::signal; -use tracing::info; - -pub mod app_state; -pub mod cli; -pub mod config; -/// Data structs and their functions. -pub mod data { - pub mod book; -} -/// Request handlers. Because it can not be guaranteed that a proper accept header is sent, the -/// routes are doubled and the decision on whether to render html or OPDS is made with internal -/// data on the respective routes. -pub mod handlers { - /// Handle requests for html. - pub mod html { - pub mod author; - pub mod authors; - pub mod books; - pub mod recent; - pub mod series; - pub mod series_single; - } - /// Handle requests for OPDS. - pub mod opds { - pub mod author; - pub mod authors; - pub mod books; - pub mod feed; - pub mod recent; - pub mod series; - pub mod series_single; - } - pub mod author; - pub mod authors; - pub mod books; - pub mod cover; - pub mod download; - pub mod error; - pub mod paginated; - pub mod recent; - pub mod series; - pub mod series_single; -} -/// OPDS data structs. -pub mod opds { - pub mod author; - pub mod content; - pub mod entry; - pub mod error; - pub mod feed; - pub mod link; - pub mod media_type; - pub mod relation; -} -pub mod templates; - -/// Internal marker data in lieu of a proper `Accept` header. -#[derive(Debug, Clone, Copy)] -pub enum Accept { - /// Render as html. - Html, - /// Render as OPDS. - Opds, -} - -/// Embedd static files. -#[derive(RustEmbed)] -#[folder = "static"] -pub struct Files; - -/// Main entry point to run the ebook server with a calibre library specified in `config`. -pub async fn run(config: Config) -> Result<(), std::io::Error> { - let calibre = Calibre::load(&config.metadata_path).expect("failed to load calibre database"); - let app_state = Arc::new(AppState { calibre, config }); - - let html_routes = Route::new() - .at("/", get(handlers::recent::handler)) - .at("/books", get(handlers::books::handler_init)) - .at("/books/:cursor/:sort_order", get(handlers::books::handler)) - .at("/series", get(handlers::series::handler_init)) - .at( - "/series/:cursor/:sort_order", - get(handlers::series::handler), - ) - .at("/series/:id", get(handlers::series_single::handler)) - .at("/authors", get(handlers::authors::handler_init)) - .at("/authors/:id", get(handlers::author::handler)) - .at( - "/authors/:cursor/:sort_order", - get(handlers::authors::handler), - ) - .at("/cover/:id", get(handlers::cover::handler)) - .at("/book/:id/:format", get(handlers::download::handler)) - .nest("/static", EmbeddedFilesEndpoint::::new()) - .data(Accept::Html); - - let opds_routes = Route::new() - .at("/", get(handlers::opds::feed::handler)) - .at("/recent", get(handlers::recent::handler)) - .at("/books", get(handlers::books::handler_init)) - .at("/authors", get(handlers::authors::handler_init)) - .at("/authors/:id", get(handlers::author::handler)) - .at("/series", get(handlers::series::handler_init)) - .at("/series/:id", get(handlers::series_single::handler)) - .data(Accept::Opds); - - let app = Route::new() - .nest("/", html_routes) - .nest("/opds", opds_routes) - .data(app_state) - .with(Tracing); - - let server = Server::new(TcpListener::bind("[::]:3000")) - .name("cops-web") - .run(app); - - tokio::select! { - _ = server => {}, - _ = signal::ctrl_c() => { - info!("Received Ctrl+C, shutting down..."); - }, - } - Ok(()) -} diff --git a/little-hesinde/Cargo.toml b/rusty-library/Cargo.toml similarity index 82% rename from little-hesinde/Cargo.toml rename to rusty-library/Cargo.toml index 4a10942..dfa2399 100644 --- a/little-hesinde/Cargo.toml +++ b/rusty-library/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "little-hesinde" +name = "rusty-library" version = "0.1.0" edition = "2021" license = { workspace = true } @@ -16,8 +16,7 @@ serde_with = "3.8.1" tera = "1.19.1" thiserror = { workspace = true } time = { workspace = true } -tokio = { version = "1.37.0", features = ["signal", "rt-multi-thread", "macros"] } -tokio-util = "0.7.11" +tokio = { version = "1.37.0", features = ["rt-multi-thread", "macros"] } tracing = "0.1.40" tracing-subscriber = "0.3.18" uuid = { version = "1.8.0", features = ["v4", "fast-rng"] } diff --git a/rusty-library/src/app_state.rs b/rusty-library/src/app_state.rs new file mode 100644 index 0000000..9b886cc --- /dev/null +++ b/rusty-library/src/app_state.rs @@ -0,0 +1,8 @@ +use calibre_db::calibre::Calibre; + +use crate::config::Config; + +pub struct AppState { + pub calibre: Calibre, + pub config: Config, +} diff --git a/rusty-library/src/basic_auth.rs b/rusty-library/src/basic_auth.rs new file mode 100644 index 0000000..092bde4 --- /dev/null +++ b/rusty-library/src/basic_auth.rs @@ -0,0 +1,44 @@ +use poem::{ + http::StatusCode, + web::{ + headers, + headers::{authorization::Basic, HeaderMapExt}, + }, + Endpoint, Error, Middleware, Request, Result, +}; + +pub struct BasicAuth { + pub username: String, + pub password: String, +} + +impl Middleware for BasicAuth { + type Output = BasicAuthEndpoint; + + fn transform(&self, ep: E) -> Self::Output { + BasicAuthEndpoint { + ep, + username: self.username.clone(), + password: self.password.clone(), + } + } +} + +pub struct BasicAuthEndpoint { + ep: E, + username: String, + password: String, +} + +impl Endpoint for BasicAuthEndpoint { + type Output = E::Output; + + async fn call(&self, req: Request) -> Result { + if let Some(auth) = req.headers().typed_get::>() { + if auth.0.username() == self.username && auth.0.password() == self.password { + return self.ep.call(req).await; + } + } + Err(Error::from_status(StatusCode::UNAUTHORIZED)) + } +} diff --git a/little-hesinde/src/cli.rs b/rusty-library/src/cli.rs similarity index 91% rename from little-hesinde/src/cli.rs rename to rusty-library/src/cli.rs index 6f1c404..5a7f28c 100644 --- a/little-hesinde/src/cli.rs +++ b/rusty-library/src/cli.rs @@ -1,5 +1,3 @@ -//! Cli interface. - use clap::Parser; /// Simple opds server for calibre diff --git a/little-hesinde/src/config.rs b/rusty-library/src/config.rs similarity index 77% rename from little-hesinde/src/config.rs rename to rusty-library/src/config.rs index 9d0b97e..0747cf3 100644 --- a/little-hesinde/src/config.rs +++ b/rusty-library/src/config.rs @@ -1,32 +1,23 @@ -//! Configuration data. - use std::path::{Path, PathBuf}; use thiserror::Error; use crate::cli::Cli; -/// Errors when dealing with application configuration. #[derive(Error, Debug)] pub enum ConfigError { - /// Calibre library path does not exist. #[error("no folder at {0}")] LibraryPathNotFound(String), - /// Calibre database does not exist. #[error("no metadata.db in {0}")] MetadataNotFound(String), } -/// Application configuration. pub struct Config { - /// Calibre library folder. pub library_path: PathBuf, - /// Calibre metadata file path. pub metadata_path: PathBuf, } impl Config { - /// Check if the calibre library from `args` exists and if the calibre database can be found. pub fn load(args: &Cli) -> Result { let library_path = Path::new(&args.library_path).to_path_buf(); diff --git a/little-hesinde/src/data/book.rs b/rusty-library/src/data/book.rs similarity index 59% rename from little-hesinde/src/data/book.rs rename to rusty-library/src/data/book.rs index 849144e..ac5fc12 100644 --- a/little-hesinde/src/data/book.rs +++ b/rusty-library/src/data/book.rs @@ -1,22 +1,17 @@ -//! Enrich the [`Book`](struct@calibre_db::data::book::Book) type with additional information. - use std::{collections::HashMap, fmt::Display, path::Path}; use calibre_db::data::{ author::Author as DbAuthor, book::Book as DbBook, series::Series as DbSeries, }; use serde::Serialize; +use time::OffsetDateTime; use crate::app_state::AppState; -/// Wrapper type for a file format string (must be a struct in order to implement traits). #[derive(Debug, Clone, Serialize, Eq, PartialEq, Hash)] pub struct Format(pub String); -/// Wrapper type for a collection of formats, [`Format`](struct@Format) on the left and a String -/// signifying its file path on the right. pub type Formats = HashMap; -/// Recognize `pdf` and `epub` and return their value, everything else transforms to `unknown`. impl Display for Format { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self.0.as_ref() { @@ -27,30 +22,21 @@ impl Display for Format { } } -/// Wrapper around [`Book`](struct@calibre_db::data::book::Book) from the -/// [`calibre-db`](mod@calibre_db) crate. Adding -/// [`Author`](struct@calibre_db::data::author::Author), -/// [`Series`](struct@calibre_db::data::series::Series) as well as -/// [`Format`](struct@Format) information. #[derive(Debug, Clone, Serialize)] pub struct Book { - /// Book data from the database. - pub data: DbBook, - /// Author information. + pub id: u64, + pub title: String, + pub sort: String, + pub path: String, + pub uuid: String, + pub last_modified: OffsetDateTime, + pub description: Option, pub author: DbAuthor, - /// Series information. Series on the left and the index of the book within - /// that series on the right. pub series: Option<(DbSeries, f64)>, - /// Format information. pub formats: Formats, } impl Book { - /// Wrap a [`DbBook`](struct@calibre_db::data::book::Book) in a [`Book`](struct@Book) and - /// enrich it with additional information. - /// - /// `db_series` is an Option tuple with the series on the left and the index of the book within - /// that series on the right. pub fn from_db_book( db_book: &DbBook, db_series: Option<(DbSeries, f64)>, @@ -58,15 +44,19 @@ impl Book { formats: Formats, ) -> Self { Self { - data: db_book.clone(), + id: db_book.id, + title: db_book.title.clone(), + sort: db_book.sort.clone(), + path: db_book.path.clone(), + uuid: db_book.uuid.clone(), + description: db_book.description.clone(), + last_modified: db_book.last_modified, author: author.clone(), series: db_series.map(|x| (x.0, x.1)), formats, } } - /// Find all pdf and epub formats of a book on disk. Search their directory in the library for - /// it. fn formats(book: &DbBook, library_path: &Path) -> Formats { let book_path = library_path.join(&book.path); let mut formats = HashMap::new(); @@ -90,8 +80,6 @@ impl Book { formats } - /// Wrap a [`DbBook`](struct@calibre_db::data::book::Book) in a [`Book`](struct@Book) by - /// fetching additional information about author, formats and series. pub fn full_book(book: &DbBook, state: &AppState) -> Option { let formats = Book::formats(book, &state.config.library_path); let author = state.calibre.book_author(book.id).ok()?; diff --git a/little-hesinde/src/handlers/author.rs b/rusty-library/src/handlers/author.rs similarity index 52% rename from little-hesinde/src/handlers/author.rs rename to rusty-library/src/handlers/author.rs index 60f7e20..aee053b 100644 --- a/little-hesinde/src/handlers/author.rs +++ b/rusty-library/src/handlers/author.rs @@ -1,23 +1,22 @@ -//! Handle requests for a single author. - use std::sync::Arc; use calibre_db::data::pagination::SortOrder; use poem::{ + error::InternalServerError, handler, - web::{Data, Path}, - Response, + web::{Data, Html, Path}, +}; +use tera::Context; + +use crate::{ + app_state::AppState, data::book::Book, handlers::error::HandlerError, templates::TEMPLATES, }; -use crate::{app_state::AppState, data::book::Book, handlers::error::HandlerError, Accept}; - -/// Handle a request for an author with `id` and decide whether to render to html or OPDS. #[handler] pub async fn handler( id: Path, - accept: Data<&Accept>, state: Data<&Arc>, -) -> Result { +) -> Result, poem::Error> { let author = state .calibre .scalar_author(*id) @@ -31,8 +30,13 @@ pub async fn handler( .filter_map(|x| Book::full_book(x, &state)) .collect::>(); - match accept.0 { - Accept::Html => crate::handlers::html::author::handler(author, books).await, - Accept::Opds => crate::handlers::opds::author::handler(author, books).await, - } + let mut context = Context::new(); + context.insert("title", &author.name); + context.insert("nav", "authors"); + context.insert("books", &books); + + TEMPLATES + .render("book_list", &context) + .map_err(InternalServerError) + .map(Html) } diff --git a/rusty-library/src/handlers/authors.rs b/rusty-library/src/handlers/authors.rs new file mode 100644 index 0000000..f85ff2b --- /dev/null +++ b/rusty-library/src/handlers/authors.rs @@ -0,0 +1,36 @@ +use std::sync::Arc; + +use calibre_db::{calibre::Calibre, data::pagination::SortOrder}; +use poem::{ + handler, + web::{Data, Html, Path}, +}; + +use crate::{app_state::AppState, handlers::paginated}; + +#[handler] +pub async fn handler_init(state: Data<&Arc>) -> Result, poem::Error> { + authors(&state.calibre, None, &SortOrder::ASC) +} + +#[handler] +pub async fn handler( + Path((cursor, sort_order)): Path<(String, SortOrder)>, + state: Data<&Arc>, +) -> Result, poem::Error> { + authors(&state.calibre, Some(&cursor), &sort_order) +} + +fn authors( + calibre: &Calibre, + cursor: Option<&str>, + sort_order: &SortOrder, +) -> Result, poem::Error> { + paginated::render( + "authors", + || calibre.authors(25, cursor, sort_order), + |author| author.sort.clone(), + |cursor| calibre.has_previous_authors(cursor), + |cursor| calibre.has_more_authors(cursor), + ) +} diff --git a/rusty-library/src/handlers/books.rs b/rusty-library/src/handlers/books.rs new file mode 100644 index 0000000..e181f91 --- /dev/null +++ b/rusty-library/src/handlers/books.rs @@ -0,0 +1,43 @@ +use std::sync::Arc; + +use calibre_db::data::pagination::SortOrder; +use poem::{ + handler, + web::{Data, Html, Path}, +}; + +use crate::{app_state::AppState, data::book::Book}; + +use super::paginated; + +#[handler] +pub async fn handler_init(state: Data<&Arc>) -> Result, poem::Error> { + books(&state, None, &SortOrder::ASC) +} + +#[handler] +pub async fn handler( + Path((cursor, sort_order)): Path<(String, SortOrder)>, + state: Data<&Arc>, +) -> Result, poem::Error> { + books(&state, Some(&cursor), &sort_order) +} + +fn books( + state: &Arc, + cursor: Option<&str>, + sort_order: &SortOrder, +) -> Result, poem::Error> { + paginated::render( + "books", + || { + state + .calibre + .books(25, cursor, sort_order) + .map(|x| x.iter().filter_map(|y| Book::full_book(y, state)).collect()) + }, + |book| book.sort.clone(), + |cursor| state.calibre.has_previous_books(cursor), + |cursor| state.calibre.has_more_books(cursor), + ) +} diff --git a/little-hesinde/src/handlers/cover.rs b/rusty-library/src/handlers/cover.rs similarity index 88% rename from little-hesinde/src/handlers/cover.rs rename to rusty-library/src/handlers/cover.rs index 4a0696d..022cb3d 100644 --- a/little-hesinde/src/handlers/cover.rs +++ b/rusty-library/src/handlers/cover.rs @@ -1,5 +1,3 @@ -//! Handle requests for cover images. - use std::{fs::File, io::Read, sync::Arc}; use poem::{ @@ -11,7 +9,6 @@ use poem::{ use crate::{app_state::AppState, handlers::error::HandlerError}; -/// Handle a request for the cover image of book with id `id`. #[handler] pub async fn handler( id: Path, diff --git a/rusty-library/src/handlers/download.rs b/rusty-library/src/handlers/download.rs new file mode 100644 index 0000000..4c26d9a --- /dev/null +++ b/rusty-library/src/handlers/download.rs @@ -0,0 +1,38 @@ +use std::{fs::File, io::Read, sync::Arc}; + +use poem::{ + error::NotFoundError, + handler, + web::{Data, Path, WithContentType, WithHeader}, + IntoResponse, +}; + +use crate::{ + app_state::AppState, + data::book::{Book, Format}, + handlers::error::HandlerError, +}; + +#[handler] +pub async fn handler( + Path((id, format)): Path<(u64, String)>, + state: Data<&Arc>, +) -> Result>>, poem::Error> { + let book = state + .calibre + .scalar_book(id) + .map_err(HandlerError::DataError)?; + let book = Book::full_book(&book, &state).ok_or(NotFoundError)?; + let format = Format(format); + let file_name = book.formats.get(&format).ok_or(NotFoundError)?; + let file_path = state.config.library_path.join(book.path).join(file_name); + let mut file = File::open(file_path).map_err(|_| NotFoundError)?; + + let mut data = Vec::new(); + file.read_to_end(&mut data).map_err(|_| NotFoundError)?; + let content_type = format.0; + + Ok(data + .with_content_type(content_type) + .with_header("Content-Disposition", format!("filename={file_name};"))) +} diff --git a/little-hesinde/src/handlers/error.rs b/rusty-library/src/handlers/error.rs similarity index 78% rename from little-hesinde/src/handlers/error.rs rename to rusty-library/src/handlers/error.rs index 176b431..d3f89f1 100644 --- a/little-hesinde/src/handlers/error.rs +++ b/rusty-library/src/handlers/error.rs @@ -1,5 +1,3 @@ -//! Error handling for requests handlers. - use calibre_db::data::error::DataStoreError; use poem::{error::ResponseError, http::StatusCode, Body, Response}; use thiserror::Error; @@ -8,22 +6,15 @@ use uuid::Uuid; use crate::opds::error::OpdsError; -/// Errors happening during handling of requests. #[derive(Error, Debug)] #[error("opds error")] pub enum HandlerError { - /// Error rendering OPDS. #[error("opds error")] OpdsError(#[from] OpdsError), - /// Error fetching data from calibre. #[error("data error")] DataError(#[from] DataStoreError), } -/// Convert a [`HandlerError`](enum@HandlerError) into a suitable response error. -/// -/// Log the real error (internal) with an uuid and send a suitable error message to the user with -/// the same uuid (for correlation purposes). impl ResponseError for HandlerError { fn status(&self) -> StatusCode { match &self { diff --git a/rusty-library/src/handlers/opds.rs b/rusty-library/src/handlers/opds.rs new file mode 100644 index 0000000..cfbc880 --- /dev/null +++ b/rusty-library/src/handlers/opds.rs @@ -0,0 +1,363 @@ +use std::sync::Arc; + +use calibre_db::data::{author::Author as DbAuthor, pagination::SortOrder}; +use poem::{ + handler, + web::{Data, Path, WithContentType}, + IntoResponse, +}; +use time::OffsetDateTime; + +use crate::{ + app_state::AppState, + data::book::Book, + handlers::error::HandlerError, + opds::{ + author::Author, content::Content, entry::Entry, feed::Feed, link::Link, + media_type::MediaType, relation::Relation, + }, +}; + +fn create_feed( + now: OffsetDateTime, + id: &str, + title: &str, + self_link: Link, + mut additional_links: Vec, + entries: Vec, +) -> Feed { + let author = Author { + name: "Thallian".to_string(), + uri: "https://code.vanwa.ch/shu/rusty-library".to_string(), + email: None, + }; + let mut links = vec![ + Link { + href: "/opds".to_string(), + media_type: MediaType::Navigation, + rel: Relation::Start, + title: Some("Home".to_string()), + count: None, + }, + self_link, + ]; + links.append(&mut additional_links); + + Feed { + title: title.to_string(), + id: id.to_string(), + updated: now, + icon: "favicon.ico".to_string(), + author, + links, + entries, + } +} + +#[handler] +pub async fn recents_handler( + state: Data<&Arc>, +) -> Result, poem::Error> { + let books = state + .calibre + .recent_books(25) + .map_err(HandlerError::DataError)?; + let books = books + .iter() + .filter_map(|x| Book::full_book(x, &state)) + .collect::>(); + + let entries: Vec = books.into_iter().map(Entry::from).collect(); + let now = OffsetDateTime::now_utc(); + + let self_link = Link { + href: "/opds/recent".to_string(), + media_type: MediaType::Navigation, + rel: Relation::Myself, + title: None, + count: None, + }; + let feed = create_feed( + now, + "rusty:recentbooks", + "Recent Books", + self_link, + vec![], + entries, + ); + let xml = feed.as_xml().map_err(HandlerError::OpdsError)?; + + Ok(xml.with_content_type("application/atom+xml")) +} + +#[handler] +pub async fn series_single_handler( + id: Path, + state: Data<&Arc>, +) -> Result, poem::Error> { + let series = state + .calibre + .scalar_series(*id) + .map_err(HandlerError::DataError)?; + let books = state + .calibre + .series_books(*id) + .map_err(HandlerError::DataError)?; + let books = books + .iter() + .filter_map(|x| Book::full_book(x, &state)) + .collect::>(); + + let entries: Vec = books.into_iter().map(Entry::from).collect(); + let now = OffsetDateTime::now_utc(); + + let self_link = Link { + href: format!("/opds/series/{}", *id), + media_type: MediaType::Navigation, + rel: Relation::Myself, + title: None, + count: None, + }; + let feed = create_feed( + now, + &format!("rusty:series:{}", *id), + &series.name, + self_link, + vec![], + entries, + ); + let xml = feed.as_xml().map_err(HandlerError::OpdsError)?; + + Ok(xml.with_content_type("application/atom+xml")) +} + +#[handler] +pub async fn series_handler( + state: Data<&Arc>, +) -> Result, poem::Error> { + let series = state + .calibre + .series(u32::MAX.into(), None, &SortOrder::ASC) + .map_err(HandlerError::DataError)?; + + let entries: Vec = series.into_iter().map(Entry::from).collect(); + let now = OffsetDateTime::now_utc(); + + let self_link = Link { + href: "/opds/series".to_string(), + media_type: MediaType::Navigation, + rel: Relation::Myself, + title: None, + count: None, + }; + let feed = create_feed( + now, + "rusty:series", + "All Series", + self_link, + vec![], + entries, + ); + let xml = feed.as_xml().map_err(HandlerError::OpdsError)?; + + Ok(xml.with_content_type("application/atom+xml")) +} + +#[handler] +pub async fn author_handler( + id: Path, + state: Data<&Arc>, +) -> Result, poem::Error> { + let author = state + .calibre + .scalar_author(*id) + .map_err(HandlerError::DataError)?; + let books = state + .calibre + .author_books(*id, u32::MAX.into(), None, SortOrder::ASC) + .map_err(HandlerError::DataError)?; + let books = books + .iter() + .filter_map(|x| Book::full_book(x, &state)) + .collect::>(); + + let entries: Vec = books.into_iter().map(Entry::from).collect(); + let now = OffsetDateTime::now_utc(); + + let self_link = Link { + href: format!("/opds/authors/{}", author.id), + media_type: MediaType::Navigation, + rel: Relation::Myself, + title: None, + count: None, + }; + let feed = create_feed( + now, + &format!("rusty:author:{}", author.id), + &author.name, + self_link, + vec![], + entries, + ); + let xml = feed.as_xml().map_err(HandlerError::OpdsError)?; + + Ok(xml.with_content_type("application/atom+xml")) +} + +#[handler] +pub async fn authors_handler( + state: Data<&Arc>, +) -> Result, poem::Error> { + let authors: Vec = state + .calibre + .authors(u32::MAX.into(), None, &SortOrder::ASC) + .map_err(HandlerError::DataError)?; + + let entries: Vec = authors.into_iter().map(Entry::from).collect(); + let now = OffsetDateTime::now_utc(); + + let self_link = Link { + href: "/opds/authors".to_string(), + media_type: MediaType::Navigation, + rel: Relation::Myself, + title: None, + count: None, + }; + let feed = create_feed( + now, + "rusty:authors", + "All Authors", + self_link, + vec![], + entries, + ); + let xml = feed.as_xml().map_err(HandlerError::OpdsError)?; + + Ok(xml.with_content_type("application/atom+xml")) +} + +#[handler] +pub async fn books_handler( + state: Data<&Arc>, +) -> Result, poem::Error> { + let books: Vec = state + .calibre + .books(u32::MAX.into(), None, &SortOrder::ASC) + .map(|x| { + x.iter() + .filter_map(|y| Book::full_book(y, &state)) + .collect() + }) + .map_err(HandlerError::DataError)?; + + let entries: Vec = books.into_iter().map(Entry::from).collect(); + let now = OffsetDateTime::now_utc(); + + let self_link = Link { + href: "/opds/books".to_string(), + media_type: MediaType::Navigation, + rel: Relation::Myself, + title: None, + count: None, + }; + let feed = create_feed(now, "rusty:books", "All Books", self_link, vec![], entries); + let xml = feed.as_xml().map_err(HandlerError::OpdsError)?; + + Ok(xml.with_content_type("application/atom+xml")) +} + +#[handler] +pub async fn handler() -> Result, poem::Error> { + let now = OffsetDateTime::now_utc(); + + let self_link = Link { + href: "/opds".to_string(), + media_type: MediaType::Navigation, + rel: Relation::Myself, + title: None, + count: None, + }; + let books_entry = Entry { + title: "Books".to_string(), + id: "rusty:books".to_string(), + updated: now, + content: Some(Content { + media_type: MediaType::Text, + content: "Index of all books".to_string(), + }), + author: None, + links: vec![Link { + href: "/opds/books".to_string(), + media_type: MediaType::Navigation, + rel: Relation::Subsection, + title: None, + count: None, + }], + }; + + let authors_entry = Entry { + title: "Authors".to_string(), + id: "rusty:authors".to_string(), + updated: now, + content: Some(Content { + media_type: MediaType::Text, + content: "Index of all authors".to_string(), + }), + author: None, + links: vec![Link { + href: "/opds/authors".to_string(), + media_type: MediaType::Navigation, + rel: Relation::Subsection, + title: None, + count: None, + }], + }; + + let series_entry = Entry { + title: "Series".to_string(), + id: "rusty:series".to_string(), + updated: now, + content: Some(Content { + media_type: MediaType::Text, + content: "Index of all series".to_string(), + }), + author: None, + links: vec![Link { + href: "/opds/series".to_string(), + media_type: MediaType::Navigation, + rel: Relation::Subsection, + title: None, + count: None, + }], + }; + + let recents_entry = Entry { + title: "Recent Additions".to_string(), + id: "rusty:recentbooks".to_string(), + updated: now, + content: Some(Content { + media_type: MediaType::Text, + content: "Recently added books".to_string(), + }), + author: None, + links: vec![Link { + href: "/opds/recent".to_string(), + media_type: MediaType::Navigation, + rel: Relation::Subsection, + title: None, + count: None, + }], + }; + + let feed = create_feed( + now, + "rusty:catalog", + "Rusty-Library", + self_link, + vec![], + vec![authors_entry, series_entry, books_entry, recents_entry], + ); + let xml = feed.as_xml().map_err(HandlerError::OpdsError)?; + + Ok(xml.with_content_type("application/atom+xml")) +} diff --git a/little-hesinde/src/handlers/paginated.rs b/rusty-library/src/handlers/paginated.rs similarity index 82% rename from little-hesinde/src/handlers/paginated.rs rename to rusty-library/src/handlers/paginated.rs index 76a92af..2b83b43 100644 --- a/little-hesinde/src/handlers/paginated.rs +++ b/rusty-library/src/handlers/paginated.rs @@ -1,9 +1,7 @@ -//! Deal with cursor pagination. - use std::fmt::Debug; use calibre_db::data::error::DataStoreError; -use poem::{error::InternalServerError, web::Html, IntoResponse, Response}; +use poem::{error::InternalServerError, web::Html}; use serde::Serialize; use tera::Context; @@ -11,14 +9,13 @@ use crate::templates::TEMPLATES; use super::error::HandlerError; -/// Render a tera template with paginated items and generate back and forth links. pub fn render( template: &str, fetcher: F, sort_field: S, has_previous: P, has_more: M, -) -> Result +) -> Result, poem::Error> where F: Fn() -> Result, DataStoreError>, S: Fn(&T) -> String, @@ -45,9 +42,8 @@ where context.insert("nav", template); context.insert(template, &items); - Ok(TEMPLATES + TEMPLATES .render(template, &context) .map_err(InternalServerError) - .map(Html)? - .into_response()) + .map(Html) } diff --git a/rusty-library/src/handlers/recents.rs b/rusty-library/src/handlers/recents.rs new file mode 100644 index 0000000..1261542 --- /dev/null +++ b/rusty-library/src/handlers/recents.rs @@ -0,0 +1,33 @@ +use std::sync::Arc; + +use poem::{ + error::InternalServerError, + handler, + web::{Data, Html}, +}; +use tera::Context; + +use crate::{ + app_state::AppState, data::book::Book, handlers::error::HandlerError, templates::TEMPLATES, +}; + +#[handler] +pub async fn handler(state: Data<&Arc>) -> Result, poem::Error> { + let recent_books = state + .calibre + .recent_books(25) + .map_err(HandlerError::DataError)?; + let recent_books = recent_books + .iter() + .filter_map(|x| Book::full_book(x, &state)) + .collect::>(); + + let mut context = Context::new(); + context.insert("title", "Recent Books"); + context.insert("nav", "recent"); + context.insert("books", &recent_books); + TEMPLATES + .render("book_list", &context) + .map_err(InternalServerError) + .map(Html) +} diff --git a/rusty-library/src/handlers/series.rs b/rusty-library/src/handlers/series.rs new file mode 100644 index 0000000..50cb9e3 --- /dev/null +++ b/rusty-library/src/handlers/series.rs @@ -0,0 +1,38 @@ +use std::sync::Arc; + +use calibre_db::data::pagination::SortOrder; +use poem::{ + handler, + web::{Data, Html, Path}, +}; + +use crate::app_state::AppState; + +use super::paginated; + +#[handler] +pub async fn handler_init(state: Data<&Arc>) -> Result, poem::Error> { + series(&state, None, &SortOrder::ASC) +} + +#[handler] +pub async fn handler( + Path((cursor, sort_order)): Path<(String, SortOrder)>, + state: Data<&Arc>, +) -> Result, poem::Error> { + series(&state, Some(&cursor), &sort_order) +} + +fn series( + state: &Arc, + cursor: Option<&str>, + sort_order: &SortOrder, +) -> Result, poem::Error> { + paginated::render( + "series", + || state.calibre.series(25, cursor, sort_order), + |series| series.sort.clone(), + |cursor| state.calibre.has_previous_series(cursor), + |cursor| state.calibre.has_more_series(cursor), + ) +} diff --git a/rusty-library/src/handlers/series_single.rs b/rusty-library/src/handlers/series_single.rs new file mode 100644 index 0000000..600fd99 --- /dev/null +++ b/rusty-library/src/handlers/series_single.rs @@ -0,0 +1,41 @@ +use std::sync::Arc; + +use poem::{ + error::InternalServerError, + handler, + web::{Data, Html, Path}, +}; +use tera::Context; + +use crate::{ + app_state::AppState, data::book::Book, handlers::error::HandlerError, templates::TEMPLATES, +}; + +#[handler] +pub async fn handler( + id: Path, + state: Data<&Arc>, +) -> Result, poem::Error> { + let series = state + .calibre + .scalar_series(*id) + .map_err(HandlerError::DataError)?; + let books = state + .calibre + .series_books(*id) + .map_err(HandlerError::DataError)?; + let books = books + .iter() + .filter_map(|x| Book::full_book(x, &state)) + .collect::>(); + + let mut context = Context::new(); + context.insert("title", &series.name); + context.insert("nav", "series"); + context.insert("books", &books); + + TEMPLATES + .render("book_list", &context) + .map_err(InternalServerError) + .map(Html) +} diff --git a/rusty-library/src/lib.rs b/rusty-library/src/lib.rs new file mode 100644 index 0000000..a8bb2d1 --- /dev/null +++ b/rusty-library/src/lib.rs @@ -0,0 +1,79 @@ +use std::sync::Arc; + +use app_state::AppState; +use calibre_db::calibre::Calibre; +use config::Config; +use poem::{ + endpoint::EmbeddedFilesEndpoint, get, listener::TcpListener, middleware::Tracing, EndpointExt, + Route, Server, +}; +use rust_embed::RustEmbed; + +pub mod app_state; +pub mod basic_auth; +pub mod cli; +pub mod config; +pub mod data { + pub mod book; +} +pub mod handlers { + pub mod author; + pub mod authors; + pub mod books; + pub mod cover; + pub mod download; + pub mod error; + pub mod opds; + pub mod paginated; + pub mod recents; + pub mod series; + pub mod series_single; +} +pub mod opds; +pub mod templates; + +#[derive(RustEmbed)] +#[folder = "static"] +pub struct Files; + +pub async fn run(config: Config) -> Result<(), std::io::Error> { + let calibre = Calibre::load(&config.metadata_path).expect("failed to load calibre database"); + let app_state = Arc::new(AppState { calibre, config }); + + let app = Route::new() + .at("/", get(handlers::recents::handler)) + .at("/opds", get(handlers::opds::handler)) + .at("/opds/recent", get(handlers::opds::recents_handler)) + .at("/opds/books", get(handlers::opds::books_handler)) + .at("/opds/authors", get(handlers::opds::authors_handler)) + .at("/opds/authors/:id", get(handlers::opds::author_handler)) + .at("/opds/series", get(handlers::opds::series_handler)) + .at( + "/opds/series/:id", + get(handlers::opds::series_single_handler), + ) + .at("/books", get(handlers::books::handler_init)) + .at("/books/:cursor/:sort_order", get(handlers::books::handler)) + .at("/series", get(handlers::series::handler_init)) + .at( + "/series/:cursor/:sort_order", + get(handlers::series::handler), + ) + .at("/series/:id", get(handlers::series_single::handler)) + .at("/authors", get(handlers::authors::handler_init)) + .at("/authors/:id", get(handlers::author::handler)) + .at( + "/authors/:cursor/:sort_order", + get(handlers::authors::handler), + ) + .at("/cover/:id", get(handlers::cover::handler)) + .at("/book/:id/:format", get(handlers::download::handler)) + .nest("/static", EmbeddedFilesEndpoint::::new()) + .data(app_state) + .with(Tracing); + + Server::new(TcpListener::bind("[::]:3000")) + .name("cops-web") + .run(app) + .await +} diff --git a/little-hesinde/src/main.rs b/rusty-library/src/main.rs similarity index 79% rename from little-hesinde/src/main.rs rename to rusty-library/src/main.rs index 1d01085..0001fb5 100644 --- a/little-hesinde/src/main.rs +++ b/rusty-library/src/main.rs @@ -1,5 +1,5 @@ use clap::Parser; -use little_hesinde::{cli::Cli, config::Config}; +use rusty_library::{cli::Cli, config::Config}; #[tokio::main] async fn main() -> Result<(), std::io::Error> { @@ -11,5 +11,5 @@ async fn main() -> Result<(), std::io::Error> { let args = Cli::parse(); let config = Config::load(&args).expect("failed to load configuration"); - little_hesinde::run(config).await + rusty_library::run(config).await } diff --git a/little-hesinde/src/opds/author.rs b/rusty-library/src/opds/author.rs similarity index 86% rename from little-hesinde/src/opds/author.rs rename to rusty-library/src/opds/author.rs index 72ba315..6350245 100644 --- a/little-hesinde/src/opds/author.rs +++ b/rusty-library/src/opds/author.rs @@ -1,16 +1,10 @@ -//! Author data. - use serde::Serialize; -/// Author information. #[derive(Debug, Serialize)] #[serde(rename = "author")] pub struct Author { - /// Full name. pub name: String, - /// Where to find the author. pub uri: String, - /// Optional email address. #[serde(skip_serializing_if = "Option::is_none")] pub email: Option, } diff --git a/little-hesinde/src/opds/content.rs b/rusty-library/src/opds/content.rs similarity index 62% rename from little-hesinde/src/opds/content.rs rename to rusty-library/src/opds/content.rs index d9de97c..accc7ee 100644 --- a/little-hesinde/src/opds/content.rs +++ b/rusty-library/src/opds/content.rs @@ -1,17 +1,12 @@ -//! Content data. - use serde::Serialize; use super::media_type::MediaType; -/// Content of different types, used for example for description of an entry. #[derive(Debug, Serialize)] #[serde(rename = "content")] pub struct Content { - /// Media type of this content. #[serde(rename = "@type")] pub media_type: MediaType, - /// Actual content. #[serde(rename = "$value")] pub content: String, } diff --git a/little-hesinde/src/opds/entry.rs b/rusty-library/src/opds/entry.rs similarity index 78% rename from little-hesinde/src/opds/entry.rs rename to rusty-library/src/opds/entry.rs index 6686990..ef518ed 100644 --- a/little-hesinde/src/opds/entry.rs +++ b/rusty-library/src/opds/entry.rs @@ -1,5 +1,3 @@ -//! Entry data. - use calibre_db::data::{author::Author as DbAuthor, series::Series}; use serde::Serialize; use time::OffsetDateTime; @@ -10,32 +8,20 @@ use super::{ author::Author, content::Content, link::Link, media_type::MediaType, relation::Relation, }; -/// Fundamental piece of OPDS, holding information about entries (for example a book). #[derive(Debug, Serialize)] #[serde(rename = "entry")] pub struct Entry { - /// Title of the entry. pub title: String, - /// Id, for example a uuid. pub id: String, - /// When was this entry updated last. #[serde(with = "time::serde::rfc3339")] pub updated: OffsetDateTime, - /// Optional content. #[serde(skip_serializing_if = "Option::is_none")] - /// Optional author information. pub content: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub author: Option, - /// List of links, for example to download an entry. #[serde(rename = "link")] pub links: Vec, } -/// Convert a book into an OPDS entry. -/// -/// Add the cover and formats as link with a proper media type. -/// Add author and content information. impl From for Entry { fn from(value: Book) -> Self { let author = Author { @@ -44,7 +30,7 @@ impl From for Entry { email: None, }; let mut links = vec![Link { - href: format!("/cover/{}", value.data.id), + href: format!("/cover/{}", value.id), media_type: MediaType::Jpeg, rel: Relation::Image, title: None, @@ -57,15 +43,15 @@ impl From for Entry { .collect(); links.append(&mut format_links); - let content = value.data.description.map(|desc| Content { + let content = value.description.map(|desc| Content { media_type: MediaType::Html, content: desc, }); Self { - title: value.data.title.clone(), - id: format!("urn:uuid:{}", value.data.uuid), - updated: value.data.last_modified, + title: value.title.clone(), + id: format!("urn:uuid:{}", value.uuid), + updated: value.last_modified, content, author: Some(author), links, @@ -73,9 +59,6 @@ impl From for Entry { } } -/// Convert author information into an OPDS entry. -/// -/// Add the author link. impl From for Entry { fn from(value: DbAuthor) -> Self { let links = vec![Link { @@ -88,7 +71,7 @@ impl From for Entry { Self { title: value.name.clone(), - id: format!("little-hesinde:authors:{}", value.id), + id: format!("rusty:authors:{}", value.id), updated: OffsetDateTime::now_utc(), content: None, author: None, @@ -97,9 +80,6 @@ impl From for Entry { } } -/// Convert series information into an OPDS entry. -/// -/// Add the series link. impl From for Entry { fn from(value: Series) -> Self { let links = vec![Link { @@ -112,7 +92,7 @@ impl From for Entry { Self { title: value.name.clone(), - id: format!("little-hesinde:series:{}", value.id), + id: format!("rusty:series:{}", value.id), updated: OffsetDateTime::now_utc(), content: None, author: None, diff --git a/little-hesinde/src/opds/error.rs b/rusty-library/src/opds/error.rs similarity index 65% rename from little-hesinde/src/opds/error.rs rename to rusty-library/src/opds/error.rs index 2bc0806..c644cab 100644 --- a/little-hesinde/src/opds/error.rs +++ b/rusty-library/src/opds/error.rs @@ -1,21 +1,15 @@ -//! Error handling for OPDS data. - use std::string::FromUtf8Error; use quick_xml::DeError; use thiserror::Error; -/// Errors happening during handling OPDS data. #[derive(Error, Debug)] #[error("opds error")] pub enum OpdsError { - /// Error serializing OPDS data. #[error("failed to serialize struct")] SerializingError(#[from] DeError), - /// Error parsing OPDS xml structure. #[error("xml failure")] XmlError(#[from] quick_xml::Error), - /// Error decoding xml as UTF-8. #[error("failed to decode as utf-8")] Utf8Error(#[from] FromUtf8Error), } diff --git a/little-hesinde/src/opds/feed.rs b/rusty-library/src/opds/feed.rs similarity index 56% rename from little-hesinde/src/opds/feed.rs rename to rusty-library/src/opds/feed.rs index bd8d28b..518d09b 100644 --- a/little-hesinde/src/opds/feed.rs +++ b/rusty-library/src/opds/feed.rs @@ -1,5 +1,3 @@ -//! Root feed data. - use std::io::Cursor; use quick_xml::{ @@ -10,73 +8,24 @@ use quick_xml::{ use serde::Serialize; use time::OffsetDateTime; -use super::{ - author::Author, entry::Entry, error::OpdsError, link::Link, media_type::MediaType, - relation::Relation, -}; +use super::{author::Author, entry::Entry, error::OpdsError, link::Link}; -/// Root feed element of OPDS. #[derive(Debug, Serialize)] #[serde(rename = "feed")] pub struct Feed { - /// Title, often shown in OPDS clients. pub title: String, - /// Feed id. pub id: String, - /// When was the feed updated last. #[serde(with = "time::serde::rfc3339")] pub updated: OffsetDateTime, - /// Icon for the feed. pub icon: String, - /// Feed author. pub author: Author, - /// Links, for example home or self. #[serde(rename = "link")] pub links: Vec, - /// Entries inside the feed (books, series, subsections, ...) #[serde(rename = "entry")] pub entries: Vec, } impl Feed { - /// Create a feed with the specified data. - pub fn create( - now: OffsetDateTime, - id: &str, - title: &str, - self_link: Link, - mut additional_links: Vec, - entries: Vec, - ) -> Self { - let author = Author { - name: "Thallian".to_string(), - uri: "https://code.vanwa.ch/shu/little-hesinde".to_string(), - email: None, - }; - let mut links = vec![ - Link { - href: "/opds".to_string(), - media_type: MediaType::Navigation, - rel: Relation::Start, - title: Some("Home".to_string()), - count: None, - }, - self_link, - ]; - links.append(&mut additional_links); - - Self { - title: title.to_string(), - id: id.to_string(), - updated: now, - icon: "favicon.ico".to_string(), - author, - links, - entries, - } - } - - /// Serialize a feed to OPDS xml. pub fn as_xml(&self) -> Result { let xml = to_string(&self)?; let mut reader = Reader::from_str(&xml); diff --git a/little-hesinde/src/opds/link.rs b/rusty-library/src/opds/link.rs similarity index 83% rename from little-hesinde/src/opds/link.rs rename to rusty-library/src/opds/link.rs index f19e28f..1cfe1ba 100644 --- a/little-hesinde/src/opds/link.rs +++ b/rusty-library/src/opds/link.rs @@ -1,41 +1,32 @@ -//! Link data. - use serde::Serialize; use crate::data::book::{Book, Format}; use super::{media_type::MediaType, relation::Relation}; -/// Link element in OPDS. #[derive(Debug, Serialize)] #[serde(rename = "link")] pub struct Link { - /// Actual hyperlink. #[serde(rename = "@href")] pub href: String, - /// Type of the target. #[serde(rename = "@type")] pub media_type: MediaType, - /// Relation of the target. #[serde(rename = "@rel")] pub rel: Relation, - /// Optional link title. #[serde(rename = "@title")] #[serde(skip_serializing_if = "Option::is_none")] pub title: Option, - /// Optional count (how many entries at the target). #[serde(rename = "@thr:count")] #[serde(skip_serializing_if = "Option::is_none")] pub count: Option, } -/// Convert a format from a book into a link where it is downloadable. impl From<(&Book, (&Format, &str))> for Link { fn from(value: (&Book, (&Format, &str))) -> Self { let format = value.1 .0.clone(); let media_type: MediaType = format.into(); Self { - href: format!("/book/{}/{}", value.0.data.id, value.1 .0), + href: format!("/book/{}/{}", value.0.id, value.1 .0), media_type, rel: media_type.into(), title: Some(value.1 .0 .0.clone()), diff --git a/little-hesinde/src/opds/media_type.rs b/rusty-library/src/opds/media_type.rs similarity index 75% rename from little-hesinde/src/opds/media_type.rs rename to rusty-library/src/opds/media_type.rs index 7a98869..a9547ce 100644 --- a/little-hesinde/src/opds/media_type.rs +++ b/rusty-library/src/opds/media_type.rs @@ -1,24 +1,18 @@ -//! Media types for OPDS elements. - use serde_with::SerializeDisplay; use crate::data::book::Format; -/// Valid media types. #[derive(Debug, Copy, Clone, SerializeDisplay)] pub enum MediaType { - /// A link with this type is meant to acquire a certain thing, for example an entry. Acquisition, Epub, Html, Jpeg, - /// A link with this type is meant for navigation around a feed. Navigation, Pdf, Text, } -/// Convert `epub` and `pdf` formats to their respective media type. Everything else is `Text`. impl From for MediaType { fn from(value: Format) -> Self { match value.0.as_ref() { @@ -29,7 +23,6 @@ impl From for MediaType { } } -/// Display the respective mime types of the respective media types. impl std::fmt::Display for MediaType { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { diff --git a/rusty-library/src/opds/mod.rs b/rusty-library/src/opds/mod.rs new file mode 100644 index 0000000..dcd52e0 --- /dev/null +++ b/rusty-library/src/opds/mod.rs @@ -0,0 +1,8 @@ +pub mod author; +pub mod content; +pub mod entry; +pub mod error; +pub mod feed; +pub mod link; +pub mod media_type; +pub mod relation; diff --git a/little-hesinde/src/opds/relation.rs b/rusty-library/src/opds/relation.rs similarity index 82% rename from little-hesinde/src/opds/relation.rs rename to rusty-library/src/opds/relation.rs index 8a9a9d3..52ac158 100644 --- a/little-hesinde/src/opds/relation.rs +++ b/rusty-library/src/opds/relation.rs @@ -1,14 +1,10 @@ -//! Relation data. - use serde_with::SerializeDisplay; use super::media_type::MediaType; -/// Types of relations for links. #[derive(Debug, SerializeDisplay)] pub enum Relation { Image, - /// Refer to the self feed. Myself, Start, Subsection, @@ -16,9 +12,6 @@ pub enum Relation { Acquisition, } -/// Convert a media type int a relation. -/// -/// This is not always deterministic but for the ones I actually use so far it is correct. impl From for Relation { fn from(value: MediaType) -> Self { match value { @@ -33,7 +26,6 @@ impl From for Relation { } } -/// Specify how to represent all relations in OPDS. impl std::fmt::Display for Relation { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { diff --git a/little-hesinde/src/templates.rs b/rusty-library/src/templates.rs similarity index 90% rename from little-hesinde/src/templates.rs rename to rusty-library/src/templates.rs index c0c7cd8..532f84c 100644 --- a/little-hesinde/src/templates.rs +++ b/rusty-library/src/templates.rs @@ -1,9 +1,6 @@ -//! Tera templates. - use once_cell::sync::Lazy; use tera::Tera; -/// All tera templates, globally accessible. pub static TEMPLATES: Lazy = Lazy::new(|| { let mut tera = Tera::default(); tera.add_raw_templates(vec![ diff --git a/little-hesinde/static/pico.min.css b/rusty-library/static/pico.min.css similarity index 100% rename from little-hesinde/static/pico.min.css rename to rusty-library/static/pico.min.css diff --git a/little-hesinde/static/style.css b/rusty-library/static/style.css similarity index 89% rename from little-hesinde/static/style.css rename to rusty-library/static/style.css index 6a9a15b..e0e813b 100644 --- a/little-hesinde/static/style.css +++ b/rusty-library/static/style.css @@ -39,8 +39,3 @@ nav ul li { text-align: center; height: 6rem; } - -footer small { - display: flex; - justify-content: space-between; -} diff --git a/little-hesinde/templates/authors.html b/rusty-library/templates/authors.html similarity index 100% rename from little-hesinde/templates/authors.html rename to rusty-library/templates/authors.html diff --git a/little-hesinde/templates/base.html b/rusty-library/templates/base.html similarity index 79% rename from little-hesinde/templates/base.html rename to rusty-library/templates/base.html index b0c38cf..2ade26a 100644 --- a/little-hesinde/templates/base.html +++ b/rusty-library/templates/base.html @@ -6,7 +6,7 @@ - Little Hesinde + Rusty Library
@@ -27,11 +27,7 @@ diff --git a/little-hesinde/templates/book_card.html b/rusty-library/templates/book_card.html similarity index 80% rename from little-hesinde/templates/book_card.html rename to rusty-library/templates/book_card.html index db491eb..318fb93 100644 --- a/little-hesinde/templates/book_card.html +++ b/rusty-library/templates/book_card.html @@ -1,7 +1,7 @@
-
{{ book.data.title }}
+
{{ book.title }}

{{ book.author.name }}

@@ -14,7 +14,7 @@ {% endif %}
- book cover + book cover