quick & dirty opds implementation

This commit is contained in:
Sebastian Hugentobler 2024-05-09 12:23:26 +02:00
parent 12341d01a6
commit cccd3cbdc9
Signed by: shu
GPG Key ID: BB32CF3CA052C2F0
3 changed files with 319 additions and 18 deletions

View File

@ -1,9 +1,9 @@
use std::sync::Arc; use std::sync::Arc;
use calibre_db::data::pagination::SortOrder; use calibre_db::data::{author::Author as DbAuthor, pagination::SortOrder};
use poem::{ use poem::{
handler, handler,
web::{Data, WithContentType}, web::{Data, Path, WithContentType},
IntoResponse, IntoResponse,
}; };
use time::OffsetDateTime; use time::OffsetDateTime;
@ -20,6 +20,8 @@ use crate::{
fn create_feed( fn create_feed(
now: OffsetDateTime, now: OffsetDateTime,
id: &str,
title: &str,
self_link: Link, self_link: Link,
mut additional_links: Vec<Link>, mut additional_links: Vec<Link>,
entries: Vec<Entry>, entries: Vec<Entry>,
@ -42,8 +44,8 @@ fn create_feed(
links.append(&mut additional_links); links.append(&mut additional_links);
Feed { Feed {
title: "rusty-library".to_string(), title: title.to_string(),
id: "rusty:catalog".to_string(), id: id.to_string(),
updated: now, updated: now,
icon: "favicon.ico".to_string(), icon: "favicon.ico".to_string(),
author, author,
@ -53,7 +55,191 @@ fn create_feed(
} }
#[handler] #[handler]
pub async fn books(state: Data<&Arc<AppState>>) -> Result<WithContentType<String>, poem::Error> { pub async fn recents_handler(
state: Data<&Arc<AppState>>,
) -> Result<WithContentType<String>, 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::<Vec<Book>>();
let entries: Vec<Entry> = 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<u64>,
state: Data<&Arc<AppState>>,
) -> Result<WithContentType<String>, 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::<Vec<Book>>();
let entries: Vec<Entry> = 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<AppState>>,
) -> Result<WithContentType<String>, poem::Error> {
let series = state
.calibre
.series(u32::MAX.into(), None, &SortOrder::ASC)
.map_err(HandlerError::DataError)?;
let entries: Vec<Entry> = 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<u64>,
state: Data<&Arc<AppState>>,
) -> Result<WithContentType<String>, 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::<Vec<Book>>();
let entries: Vec<Entry> = 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<AppState>>,
) -> Result<WithContentType<String>, poem::Error> {
let authors: Vec<DbAuthor> = state
.calibre
.authors(u32::MAX.into(), None, &SortOrder::ASC)
.map_err(HandlerError::DataError)?;
let entries: Vec<Entry> = 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<AppState>>,
) -> Result<WithContentType<String>, poem::Error> {
let books: Vec<Book> = state let books: Vec<Book> = state
.calibre .calibre
.books(u32::MAX.into(), None, &SortOrder::ASC) .books(u32::MAX.into(), None, &SortOrder::ASC)
@ -74,14 +260,14 @@ pub async fn books(state: Data<&Arc<AppState>>) -> Result<WithContentType<String
title: None, title: None,
count: None, count: None,
}; };
let feed = create_feed(now, self_link, vec![], entries); let feed = create_feed(now, "rusty:books", "All Books", self_link, vec![], entries);
let xml = feed.as_xml().map_err(HandlerError::OpdsError)?; let xml = feed.as_xml().map_err(HandlerError::OpdsError)?;
Ok(xml.with_content_type("application/atom+xml")) Ok(xml.with_content_type("application/atom+xml"))
} }
#[handler] #[handler]
pub async fn handler(state: Data<&Arc<AppState>>) -> Result<WithContentType<String>, poem::Error> { pub async fn handler() -> Result<WithContentType<String>, poem::Error> {
let now = OffsetDateTime::now_utc(); let now = OffsetDateTime::now_utc();
let self_link = Link { let self_link = Link {
@ -95,10 +281,10 @@ pub async fn handler(state: Data<&Arc<AppState>>) -> Result<WithContentType<Stri
title: "Books".to_string(), title: "Books".to_string(),
id: "rusty:books".to_string(), id: "rusty:books".to_string(),
updated: now, updated: now,
content: Content { content: Some(Content {
media_type: MediaType::Text, media_type: MediaType::Text,
content: "Index of all books".to_string(), content: "Index of all books".to_string(),
}, }),
author: None, author: None,
links: vec![Link { links: vec![Link {
href: "/opds/books".to_string(), href: "/opds/books".to_string(),
@ -109,7 +295,68 @@ pub async fn handler(state: Data<&Arc<AppState>>) -> Result<WithContentType<Stri
}], }],
}; };
let feed = create_feed(now, self_link, vec![], vec![books_entry]); 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)?; let xml = feed.as_xml().map_err(HandlerError::OpdsError)?;
Ok(xml.with_content_type("application/atom+xml")) Ok(xml.with_content_type("application/atom+xml"))

View File

@ -43,7 +43,15 @@ pub async fn run(config: Config) -> Result<(), std::io::Error> {
let app = Route::new() let app = Route::new()
.at("/", get(handlers::recents::handler)) .at("/", get(handlers::recents::handler))
.at("/opds", get(handlers::opds::handler)) .at("/opds", get(handlers::opds::handler))
.at("/opds/books", get(handlers::opds::books)) .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", get(handlers::books::handler_init))
.at("/books/:cursor/:sort_order", get(handlers::books::handler)) .at("/books/:cursor/:sort_order", get(handlers::books::handler))
.at("/series", get(handlers::series::handler_init)) .at("/series", get(handlers::series::handler_init))

View File

@ -1,3 +1,4 @@
use calibre_db::data::{author::Author as DbAuthor, series::Series};
use serde::Serialize; use serde::Serialize;
use time::OffsetDateTime; use time::OffsetDateTime;
@ -14,7 +15,8 @@ pub struct Entry {
pub id: String, pub id: String,
#[serde(with = "time::serde::rfc3339")] #[serde(with = "time::serde::rfc3339")]
pub updated: OffsetDateTime, pub updated: OffsetDateTime,
pub content: Content, #[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<Content>,
pub author: Option<Author>, pub author: Option<Author>,
#[serde(rename = "link")] #[serde(rename = "link")]
pub links: Vec<Link>, pub links: Vec<Link>,
@ -41,20 +43,64 @@ impl From<Book> for Entry {
.collect(); .collect();
links.append(&mut format_links); links.append(&mut format_links);
let content = value.description.map(|desc| Content {
media_type: MediaType::Html,
content: desc,
});
Self { Self {
title: value.title.clone(), title: value.title.clone(),
id: format!("urn:uuid:{}", value.uuid), id: format!("urn:uuid:{}", value.uuid),
updated: value.last_modified, updated: value.last_modified,
content: Content { content,
media_type: MediaType::Html,
content: value.description.clone().unwrap_or("".to_string()),
},
author: Some(author), author: Some(author),
links, links,
} }
} }
} }
impl From<DbAuthor> for Entry {
fn from(value: DbAuthor) -> Self {
let links = vec![Link {
href: format!("/opds/authors/{}", value.id),
media_type: MediaType::Acquisition,
rel: Relation::Subsection,
title: None,
count: None,
}];
Self {
title: value.name.clone(),
id: format!("rusty:authors:{}", value.id),
updated: OffsetDateTime::now_utc(),
content: None,
author: None,
links,
}
}
}
impl From<Series> for Entry {
fn from(value: Series) -> Self {
let links = vec![Link {
href: format!("/opds/series/{}", value.id),
media_type: MediaType::Acquisition,
rel: Relation::Subsection,
title: None,
count: None,
}];
Self {
title: value.name.clone(),
id: format!("rusty:series:{}", value.id),
updated: OffsetDateTime::now_utc(),
content: None,
author: None,
links,
}
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use quick_xml::se::to_string; use quick_xml::se::to_string;
@ -69,10 +115,10 @@ mod tests {
title: "Authors".to_string(), title: "Authors".to_string(),
id: "rust:authors".to_string(), id: "rust:authors".to_string(),
updated: datetime!(2024-05-06 19:14:54 UTC), updated: datetime!(2024-05-06 19:14:54 UTC),
content: Content { content: Some(Content {
media_type: MediaType::Text, media_type: MediaType::Text,
content: "All authors".to_string(), content: "All authors".to_string(),
}, }),
author: None, author: None,
links: vec![ links: vec![
Link { Link {