This commit is contained in:
Sebastian Hugentobler 2024-05-08 18:11:39 +02:00
parent 47e8c74419
commit faea154ff5
Signed by: shu
GPG key ID: BB32CF3CA052C2F0
12 changed files with 533 additions and 6 deletions

View file

@ -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"] }

View file

@ -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<AppState>>) -> Result<WithContentType<String>, 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"))
}

View file

@ -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))

View file

@ -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<String>,
}
#[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, "<author><name>Rohal der Weise</name><uri>https://de.wiki-aventurica.de/wiki/Rohal_der_Weise</uri><email>rohal@aventurien.de</email></author>");
}
}

View file

@ -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,
}

View file

@ -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<Link>,
}
#[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#"<entry><title>Authors</title><id>rust:authors</id><updated>2024-05-06T19:14:54Z</updated><content type="text">All authors</content><link href="/opds" type="text" rel="start"/><link href="/opds" type="text" rel="start"/></entry>"#
);
}
}

View file

@ -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<Link>,
#[serde(rename = "entry")]
pub entries: Vec<Entry>,
}
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()
}
}

View file

@ -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<String>,
#[serde(rename = "@thr:count")]
#[serde(skip_serializing_if = "Option::is_none")]
pub count: Option<u64>,
}
#[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 href="/opds" type="application/atom+xml;profile=opds-catalog;kind=navigation" rel="start" title="Home" thr:count="10"/>"#
);
link.title = None;
link.count = None;
let xml = to_string(&link).unwrap();
assert_eq!(
xml,
r#"<link href="/opds" type="application/atom+xml;profile=opds-catalog;kind=navigation" rel="start"/>"#
);
}
}

View file

@ -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"),
}
}
}

View file

@ -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;

View file

@ -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"),
}
}
}