quick & dirty opds implementation
This commit is contained in:
parent
12341d01a6
commit
cccd3cbdc9
@ -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"))
|
||||||
|
@ -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))
|
||||||
|
@ -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 {
|
||||||
|
Loading…
Reference in New Issue
Block a user