From 491598aaafce857ae382933ba8637c6f25f62e31 Mon Sep 17 00:00:00 2001 From: Sebastian Hugentobler Date: Thu, 3 Jul 2025 19:30:56 +0200 Subject: [PATCH] add lost opds root feed --- Cargo.lock | 4 +- calibre-db/Cargo.toml | 2 +- little-hesinde/Cargo.toml | 4 +- little-hesinde/src/api/opds.rs | 1 + little-hesinde/src/api/opds/feed.rs | 140 ++++++++++++++++++++++++++++ little-hesinde/src/api/routes.rs | 1 + 6 files changed, 147 insertions(+), 5 deletions(-) create mode 100644 little-hesinde/src/api/opds/feed.rs 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/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/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))