diff --git a/Cargo.lock b/Cargo.lock index c4efe4d..7fa3889 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -305,7 +305,7 @@ checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" [[package]] name = "calibre-db" -version = "0.1.0" +version = "0.1.1" dependencies = [ "r2d2", "r2d2_sqlite", @@ -1222,7 +1222,7 @@ checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" [[package]] name = "little-hesinde" -version = "0.3.4" +version = "0.3.5" dependencies = [ "axum", "calibre-db", diff --git a/calibre-db/Cargo.toml b/calibre-db/Cargo.toml index 6c8185a..fe95390 100644 --- a/calibre-db/Cargo.toml +++ b/calibre-db/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "calibre-db" -version = "0.1.0" +version = "0.1.1" edition = "2024" license = { workspace = true } authors = { workspace = true } diff --git a/little-hesinde/Cargo.toml b/little-hesinde/Cargo.toml index 042a162..29bc55e 100644 --- a/little-hesinde/Cargo.toml +++ b/little-hesinde/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "little-hesinde" -version = "0.3.4" +version = "0.3.5" edition = "2024" license = { workspace = true } authors = { workspace = true } @@ -9,7 +9,7 @@ description = "A very simple ebook server for a calibre library, providing a htm [dependencies] axum = { version = "0.8.4", features = ["http2", "tracing"] } -calibre-db = { path = "../calibre-db/", version = "0.1.0" } +calibre-db = { path = "../calibre-db/", version = "0.1.1" } clap = { version = "4.5.40", features = ["derive", "env"] } image = { version = "0.25.6", default-features = false, features = ["jpeg", "rayon"] } mime_guess = "2.0.5" diff --git a/little-hesinde/src/api/opds.rs b/little-hesinde/src/api/opds.rs index 1efe96c..bf47892 100644 --- a/little-hesinde/src/api/opds.rs +++ b/little-hesinde/src/api/opds.rs @@ -1,6 +1,7 @@ //! Handlers for OPDS feeds. pub mod authors; pub mod books; +pub mod feed; pub mod recent; pub mod search; pub mod series; diff --git a/little-hesinde/src/api/opds/authors.rs b/little-hesinde/src/api/opds/authors.rs index 50b37c6..fff754e 100644 --- a/little-hesinde/src/api/opds/authors.rs +++ b/little-hesinde/src/api/opds/authors.rs @@ -12,10 +12,9 @@ use time::OffsetDateTime; use crate::{ APP_NAME, api::{ - SortOrder, + OPDS_TAG, SortOrder, authors::{self, SingleAuthorError}, error::{ErrorResponse, HttpStatus}, - OPDS_TAG, }, app_state::AppState, http_error, diff --git a/little-hesinde/src/api/opds/books.rs b/little-hesinde/src/api/opds/books.rs index 0cb0388..cad7b60 100644 --- a/little-hesinde/src/api/opds/books.rs +++ b/little-hesinde/src/api/opds/books.rs @@ -12,9 +12,8 @@ use time::OffsetDateTime; use crate::{ APP_NAME, api::{ - SortOrder, + OPDS_TAG, SortOrder, error::{ErrorResponse, HttpStatus}, - OPDS_TAG, }, app_state::AppState, data::book::Book, diff --git a/little-hesinde/src/api/opds/feed.rs b/little-hesinde/src/api/opds/feed.rs new file mode 100644 index 0000000..43f0daa --- /dev/null +++ b/little-hesinde/src/api/opds/feed.rs @@ -0,0 +1,140 @@ +use axum::{ + http::{StatusCode, header}, + response::{IntoResponse, Response}, +}; +use snafu::{ResultExt, Snafu}; +use time::OffsetDateTime; + +use crate::{ + APP_NAME, + api::{ + OPDS_TAG, + error::{ErrorResponse, HttpStatus}, + }, + http_error, + opds::{ + content::Content, entry::Entry, error::AsXmlError, feed::Feed, link::Link, + media_type::MediaType, relation::Relation, + }, +}; + +#[derive(Debug, Snafu)] +/// Errors that can occur when rendering the root OPDS feed. +pub enum OdpsFeedError { + /// A failure to create the OPDS feed. + #[snafu(display("Failed to create opds feed."))] + Feed { source: AsXmlError }, +} +impl HttpStatus for OdpsFeedError { + fn status_code(&self) -> StatusCode { + match self { + OdpsFeedError::Feed { source: _ } => StatusCode::INTERNAL_SERVER_ERROR, + } + } +} +http_error!(OdpsFeedError); + +/// Render all books as OPDS entries embedded in a feed. +#[utoipa::path( + get, + path = "/", + tag = OPDS_TAG, + responses( + (status = OK, content_type = "application/atom+xml"), + (status = 500, description = "Server failure.", body = ErrorResponse) + ) +)] +pub async fn handler() -> Result { + 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: format!("{APP_NAME}:books"), + 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: format!("{APP_NAME}:authors"), + 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: format!("{APP_NAME}:series"), + 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: format!("{APP_NAME}:recentbooks"), + 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, + &format!("{APP_NAME}:catalog"), + "Little Hesinde", + self_link, + vec![], + vec![authors_entry, series_entry, books_entry, recents_entry], + ); + let xml = feed.as_xml().context(FeedSnafu)?; + + Ok(([(header::CONTENT_TYPE, "application/atom+xml")], xml).into_response()) +} diff --git a/little-hesinde/src/api/opds/recent.rs b/little-hesinde/src/api/opds/recent.rs index 3f0c8e9..43bf133 100644 --- a/little-hesinde/src/api/opds/recent.rs +++ b/little-hesinde/src/api/opds/recent.rs @@ -11,9 +11,9 @@ use time::OffsetDateTime; use crate::{ APP_NAME, api::{ + OPDS_TAG, books::{self, RecentBooksError}, error::{ErrorResponse, HttpStatus}, - OPDS_TAG, }, app_state::AppState, http_error, diff --git a/little-hesinde/src/api/opds/search.rs b/little-hesinde/src/api/opds/search.rs index 0dde73b..853eb84 100644 --- a/little-hesinde/src/api/opds/search.rs +++ b/little-hesinde/src/api/opds/search.rs @@ -12,9 +12,9 @@ use super::books::{RenderError, render_books}; use crate::{ APP_NAME, api::{ + OPDS_TAG, error::{ErrorResponse, HttpStatus}, search::{self, SearchQueryError}, - OPDS_TAG, }, app_state::AppState, http_error, diff --git a/little-hesinde/src/api/opds/series.rs b/little-hesinde/src/api/opds/series.rs index a96a2bf..20a78dd 100644 --- a/little-hesinde/src/api/opds/series.rs +++ b/little-hesinde/src/api/opds/series.rs @@ -12,10 +12,9 @@ use time::OffsetDateTime; use crate::{ APP_NAME, api::{ - SortOrder, + OPDS_TAG, SortOrder, error::{ErrorResponse, HttpStatus}, series::{self, SingleSeriesError}, - OPDS_TAG, }, app_state::AppState, http_error, diff --git a/little-hesinde/src/api/routes.rs b/little-hesinde/src/api/routes.rs index 3b648ce..61c83ef 100644 --- a/little-hesinde/src/api/routes.rs +++ b/little-hesinde/src/api/routes.rs @@ -16,6 +16,7 @@ pub fn router(state: AppState) -> OpenApiRouter { let store = Arc::new(state); let opds_routes = OpenApiRouter::new() + .routes(routes!(opds::feed::handler)) .routes(routes!(opds::books::handler)) .routes(routes!(opds::recent::handler)) .routes(routes!(opds::series::handler))