From faea154ff54548ff7e961f8b0dba74f50bfaef70 Mon Sep 17 00:00:00 2001 From: Sebastian Hugentobler Date: Wed, 8 May 2024 18:11:39 +0200 Subject: [PATCH] opds wip --- Cargo.lock | 180 ++++++++++++++++++++++++++- rusty-library/Cargo.toml | 3 + rusty-library/src/handlers/opds.rs | 68 ++++++++++ rusty-library/src/main.rs | 3 + rusty-library/src/opds/author.rs | 32 +++++ rusty-library/src/opds/content.rs | 12 ++ rusty-library/src/opds/entry.rs | 64 ++++++++++ rusty-library/src/opds/feed.rs | 59 +++++++++ rusty-library/src/opds/link.rs | 57 +++++++++ rusty-library/src/opds/media_type.rs | 32 +++++ rusty-library/src/opds/mod.rs | 7 ++ rusty-library/src/opds/relation.rs | 22 ++++ 12 files changed, 533 insertions(+), 6 deletions(-) create mode 100644 rusty-library/src/handlers/opds.rs create mode 100644 rusty-library/src/opds/author.rs create mode 100644 rusty-library/src/opds/content.rs create mode 100644 rusty-library/src/opds/entry.rs create mode 100644 rusty-library/src/opds/feed.rs create mode 100644 rusty-library/src/opds/link.rs create mode 100644 rusty-library/src/opds/media_type.rs create mode 100644 rusty-library/src/opds/mod.rs create mode 100644 rusty-library/src/opds/relation.rs diff --git a/Cargo.lock b/Cargo.lock index 028d6d2..59e48ab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -135,6 +135,12 @@ version = "0.21.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bitflags" version = "1.3.2" @@ -216,6 +222,7 @@ dependencies = [ "android-tzdata", "iana-time-zone", "num-traits", + "serde", "windows-targets 0.52.5", ] @@ -260,7 +267,7 @@ dependencies = [ "anstream", "anstyle", "clap_lex", - "strsim", + "strsim 0.11.1", ] [[package]] @@ -337,6 +344,51 @@ dependencies = [ "typenum", ] +[[package]] +name = "darling" +version = "0.20.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54e36fcd13ed84ffdfda6f5be89b31287cbb80c439841fe69e04841435464391" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c2cf1c23a687a1feeb728783b993c4e1ad83d99f351801977dd809b48d0a70f" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim 0.10.0", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a668eda54683121533a393014d8692171709ff57a7d61f187b6e782719f8933f" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "deranged" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" +dependencies = [ + "powerfmt", + "serde", +] + [[package]] name = "deunicode" version = "1.4.4" @@ -502,13 +554,19 @@ dependencies = [ "futures-sink", "futures-util", "http", - "indexmap", + "indexmap 2.2.6", "slab", "tokio", "tokio-util", "tracing", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + [[package]] name = "hashbrown" version = "0.14.5" @@ -525,7 +583,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "692eaaf7f7607518dd3cef090f1474b61edc5301d8012f09579920df68b725ee" dependencies = [ - "hashbrown", + "hashbrown 0.14.5", ] [[package]] @@ -534,7 +592,7 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "322106e6bd0cba2d5ead589ddb8150a13d7c4217cf80d7c4f682ca994ccc6aa9" dependencies = [ - "base64", + "base64 0.21.7", "bytes", "headers-core", "http", @@ -684,6 +742,12 @@ dependencies = [ "cc", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "ignore" version = "0.4.22" @@ -700,6 +764,17 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + [[package]] name = "indexmap" version = "2.2.6" @@ -707,7 +782,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "168fb715dda47215e360912c096649d23d58bf392ac62f73919e831745e40f26" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.14.5", + "serde", ] [[package]] @@ -840,6 +916,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + [[package]] name = "num-traits" version = "0.2.19" @@ -1068,6 +1150,12 @@ dependencies = [ "syn", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.17" @@ -1092,6 +1180,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quick-xml" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1004a344b30a54e2ee58d66a71b32d2db2feb0a31f9a2d302bf0536f15de2a33" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "quote" version = "1.0.36" @@ -1262,11 +1360,14 @@ dependencies = [ "clap", "once_cell", "poem", + "quick-xml", "rust-embed", "serde", "serde_json", + "serde_with", "tera", "thiserror", + "time", "tokio", "tracing", "tracing-subscriber", @@ -1346,6 +1447,36 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad483d2ab0149d5a5ebcd9972a3852711e0153d863bf5a5d0391d28883c4a20" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.2.6", + "serde", + "serde_derive", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65569b702f41443e8bc8bbb1c5779bd0450bbe723b56198980e80ec45780bce2" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "sha1" version = "0.10.6" @@ -1418,6 +1549,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + [[package]] name = "strsim" version = "0.11.1" @@ -1496,6 +1633,37 @@ dependencies = [ "once_cell", ] +[[package]] +name = "time" +version = "0.3.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" + +[[package]] +name = "time-macros" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tokio" version = "1.37.0" @@ -1549,7 +1717,7 @@ version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8534fd7f78b5405e860340ad6575217ce99f38d4d5c8f2442cb5ecb50090e1" dependencies = [ - "indexmap", + "indexmap 2.2.6", "toml_datetime", "winnow", ] diff --git a/rusty-library/Cargo.toml b/rusty-library/Cargo.toml index bffdf15..3f744a2 100644 --- a/rusty-library/Cargo.toml +++ b/rusty-library/Cargo.toml @@ -12,9 +12,12 @@ poem = { version = "3.0.0", features = ["embed", "static-files"] } rust-embed = "8.3.0" serde = { workspace = true } serde_json = "1.0.116" +serde_with = "3.8.1" tera = "1.19.1" thiserror = { workspace = true } +time = { version = "0.3.36", features = ["macros", "serde", "formatting"] } 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"] } +quick-xml = { version = "0.31.0", features = ["serialize"] } diff --git a/rusty-library/src/handlers/opds.rs b/rusty-library/src/handlers/opds.rs new file mode 100644 index 0000000..ce224c2 --- /dev/null +++ b/rusty-library/src/handlers/opds.rs @@ -0,0 +1,68 @@ +use std::sync::Arc; + +use poem::{ + handler, + web::{headers::ContentType, Data, WithContentType}, + IntoResponse, +}; +use quick_xml::se::to_string; +use time::macros::datetime; + +use crate::{ + app_state::AppState, + opds::{ + author::Author, content::Content, entry::Entry, feed::Feed, link::Link, + media_type::MediaType, relation::Relation, + }, +}; + +#[handler] +pub async fn handler(state: Data<&Arc>) -> Result, poem::Error> { + let author = Author { + name: "Thallian".to_string(), + uri: "https://code.vanwa.ch/shu/rusty-library".to_string(), + email: None, + }; + let home_link = Link { + href: "/opds".to_string(), + media_type: MediaType::Navigation, + rel: Relation::Start, + title: Some("Home".to_string()), + count: None, + }; + 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: datetime!(2024-05-06 19:14:54 UTC), + content: Content { + media_type: MediaType::Text, + content: "Index of all books".to_string(), + }, + links: vec![Link { + href: "/opds/books".to_string(), + media_type: MediaType::Navigation, + rel: Relation::Subsection, + title: None, + count: None, + }], + }; + + let feed = Feed { + title: "rusty-library".to_string(), + id: "rusty:catalog".to_string(), + updated: datetime!(2024-05-06 19:14:54 UTC), + icon: "favicon.ico".to_string(), + author, + links: vec![home_link, self_link], + entries: vec![books_entry], + }; + let xml = feed.as_xml(); + Ok(xml.with_content_type("application/atom+xml")) +} diff --git a/rusty-library/src/main.rs b/rusty-library/src/main.rs index 4bf0a8a..f0283cc 100644 --- a/rusty-library/src/main.rs +++ b/rusty-library/src/main.rs @@ -24,11 +24,13 @@ mod handlers { 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; } +mod opds; mod templates; #[derive(RustEmbed)] @@ -50,6 +52,7 @@ async fn main() -> Result<(), std::io::Error> { let app = Route::new() .at("/", get(handlers::recents::handler)) + .at("/opds", get(handlers::opds::handler)) .at("/books", get(handlers::books::handler_init)) .at("/books/:cursor/:sort_order", get(handlers::books::handler)) .at("/series", get(handlers::series::handler_init)) diff --git a/rusty-library/src/opds/author.rs b/rusty-library/src/opds/author.rs new file mode 100644 index 0000000..6350245 --- /dev/null +++ b/rusty-library/src/opds/author.rs @@ -0,0 +1,32 @@ +use serde::Serialize; + +#[derive(Debug, Serialize)] +#[serde(rename = "author")] +pub struct Author { + pub name: String, + pub uri: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub email: Option, +} + +#[cfg(test)] +mod tests { + use quick_xml::se::to_string; + + use super::*; + + fn init() -> Author { + Author { + name: "Rohal der Weise".to_string(), + uri: "https://de.wiki-aventurica.de/wiki/Rohal_der_Weise".to_string(), + email: Some("rohal@aventurien.de".to_string()), + } + } + + #[test] + fn serialize() { + let author = init(); + let xml = to_string(&author).unwrap(); + assert_eq!(xml, "Rohal der Weisehttps://de.wiki-aventurica.de/wiki/Rohal_der_Weiserohal@aventurien.de"); + } +} diff --git a/rusty-library/src/opds/content.rs b/rusty-library/src/opds/content.rs new file mode 100644 index 0000000..accc7ee --- /dev/null +++ b/rusty-library/src/opds/content.rs @@ -0,0 +1,12 @@ +use serde::Serialize; + +use super::media_type::MediaType; + +#[derive(Debug, Serialize)] +#[serde(rename = "content")] +pub struct Content { + #[serde(rename = "@type")] + pub media_type: MediaType, + #[serde(rename = "$value")] + pub content: String, +} diff --git a/rusty-library/src/opds/entry.rs b/rusty-library/src/opds/entry.rs new file mode 100644 index 0000000..0394bcb --- /dev/null +++ b/rusty-library/src/opds/entry.rs @@ -0,0 +1,64 @@ +use serde::Serialize; +use time::OffsetDateTime; + +use super::{content::Content, link::Link}; + +#[derive(Debug, Serialize)] +#[serde(rename = "entry")] +pub struct Entry { + pub title: String, + pub id: String, + #[serde(with = "time::serde::rfc3339")] + pub updated: OffsetDateTime, + pub content: Content, + #[serde(rename = "link")] + pub links: Vec, +} + +#[cfg(test)] +mod tests { + use quick_xml::se::to_string; + use time::macros::datetime; + + use crate::opds::{content::Content, media_type::MediaType, relation::Relation}; + + use super::*; + + fn init() -> Entry { + Entry { + title: "Authors".to_string(), + id: "rust:authors".to_string(), + updated: datetime!(2024-05-06 19:14:54 UTC), + content: Content { + media_type: MediaType::Text, + content: "All authors".to_string(), + }, + links: vec![ + Link { + href: "/opds".to_string(), + media_type: MediaType::Text, + rel: Relation::Start, + title: None, + count: None, + }, + Link { + href: "/opds".to_string(), + media_type: MediaType::Text, + rel: Relation::Start, + title: None, + count: None, + }, + ], + } + } + + #[test] + fn serialize() { + let entry = init(); + let xml = to_string(&entry).unwrap(); + assert_eq!( + xml, + r#"Authorsrust:authors2024-05-06T19:14:54ZAll authors"# + ); + } +} diff --git a/rusty-library/src/opds/feed.rs b/rusty-library/src/opds/feed.rs new file mode 100644 index 0000000..c944874 --- /dev/null +++ b/rusty-library/src/opds/feed.rs @@ -0,0 +1,59 @@ +use std::io::Cursor; + +use quick_xml::{ + events::{BytesDecl, BytesStart, Event}, + se::to_string, + Reader, Writer, +}; +use serde::Serialize; +use time::OffsetDateTime; + +use super::{author::Author, entry::Entry, link::Link}; + +#[derive(Debug, Serialize)] +#[serde(rename = "feed")] +pub struct Feed { + pub title: String, + pub id: String, + #[serde(with = "time::serde::rfc3339")] + pub updated: OffsetDateTime, + pub icon: String, + pub author: Author, + #[serde(rename = "link")] + pub links: Vec, + #[serde(rename = "entry")] + pub entries: Vec, +} + +impl Feed { + pub fn as_xml(&self) -> String { + let xml = to_string(&self).unwrap(); + let mut reader = Reader::from_str(&xml); + reader.trim_text(true); + + let declaration = BytesDecl::new("1.0", Some("UTF-8"), None); + let mut writer = Writer::new(Cursor::new(Vec::new())); + writer.write_event(Event::Decl(declaration)).unwrap(); + + let mut feed_start = BytesStart::new("feed"); + feed_start.push_attribute(("xmlns", "http://www.w3.org/2005/Atom")); + feed_start.push_attribute(("xmlns:xhtml", "http://www.w3.org/1999/xhtml")); + feed_start.push_attribute(("xmlns:opds", "http://opds-spec.org/2010/catalog")); + feed_start.push_attribute(("xmlns:opensearch", "http://a9.com/-/spec/opensearch/1.1/")); + feed_start.push_attribute(("xmlns:dcterms", "http://purl.org/dc/terms/")); + feed_start.push_attribute(("xmlns:thr", "http://purl.org/syndication/thread/1.0")); + + loop { + match reader.read_event() { + Ok(Event::Start(e)) if e.name().as_ref() == b"feed" => writer + .write_event(Event::Start(feed_start.clone())) + .unwrap(), + Ok(Event::Eof) => break, + Ok(e) => writer.write_event(e).unwrap(), + Err(e) => (), + } + } + let result = writer.into_inner().into_inner(); + String::from_utf8(result).unwrap() + } +} diff --git a/rusty-library/src/opds/link.rs b/rusty-library/src/opds/link.rs new file mode 100644 index 0000000..fd0c8e6 --- /dev/null +++ b/rusty-library/src/opds/link.rs @@ -0,0 +1,57 @@ +use serde::Serialize; + +use super::{media_type::MediaType, relation::Relation}; + +#[derive(Debug, Serialize)] +#[serde(rename = "link")] +pub struct Link { + #[serde(rename = "@href")] + pub href: String, + #[serde(rename = "@type")] + pub media_type: MediaType, + #[serde(rename = "@rel")] + pub rel: Relation, + #[serde(rename = "@title")] + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(rename = "@thr:count")] + #[serde(skip_serializing_if = "Option::is_none")] + pub count: Option, +} + +#[cfg(test)] +mod tests { + use quick_xml::se::to_string; + + use super::*; + + fn init() -> Link { + Link { + href: "/opds".to_string(), + media_type: MediaType::Navigation, + rel: Relation::Start, + title: Some("Home".to_string()), + count: Some(10), + } + } + + #[test] + fn serialize() { + let mut link = init(); + let xml = to_string(&link).unwrap(); + + assert_eq!( + xml, + r#""# + ); + + link.title = None; + link.count = None; + let xml = to_string(&link).unwrap(); + + assert_eq!( + xml, + r#""# + ); + } +} diff --git a/rusty-library/src/opds/media_type.rs b/rusty-library/src/opds/media_type.rs new file mode 100644 index 0000000..61f46a4 --- /dev/null +++ b/rusty-library/src/opds/media_type.rs @@ -0,0 +1,32 @@ +use serde_with::SerializeDisplay; + +#[derive(Debug, SerializeDisplay)] +pub enum MediaType { + Acquisition, + Epub, + Html, + Jpeg, + Navigation, + Pdf, + Text, +} + +impl std::fmt::Display for MediaType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + MediaType::Acquisition => write!( + f, + "application/atom+xml;profile=opds-catalog;kind=aqcuisition" + ), + MediaType::Epub => write!(f, "application/epub+zip"), + MediaType::Html => write!(f, "text/html"), + MediaType::Jpeg => write!(f, "image/jpeg"), + MediaType::Navigation => write!( + f, + "application/atom+xml;profile=opds-catalog;kind=navigation" + ), + MediaType::Pdf => write!(f, "application/pdf"), + MediaType::Text => write!(f, "text"), + } + } +} diff --git a/rusty-library/src/opds/mod.rs b/rusty-library/src/opds/mod.rs new file mode 100644 index 0000000..d21cb98 --- /dev/null +++ b/rusty-library/src/opds/mod.rs @@ -0,0 +1,7 @@ +pub mod author; +pub mod content; +pub mod entry; +pub mod feed; +pub mod link; +pub mod media_type; +pub mod relation; diff --git a/rusty-library/src/opds/relation.rs b/rusty-library/src/opds/relation.rs new file mode 100644 index 0000000..acca3cf --- /dev/null +++ b/rusty-library/src/opds/relation.rs @@ -0,0 +1,22 @@ +use serde_with::SerializeDisplay; + +#[derive(Debug, SerializeDisplay)] +pub enum Relation { + Image, + Myself, + Start, + Subsection, + Thumbnail, +} + +impl std::fmt::Display for Relation { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Relation::Image => write!(f, "http://opds-spec.org/image"), + Relation::Myself => write!(f, "self"), + Relation::Start => write!(f, "start"), + Relation::Subsection => write!(f, "subsection"), + Relation::Thumbnail => write!(f, "http://opds-spec.org/image/thumbnail"), + } + } +}