quick & dirty opds implementation
This commit is contained in:
parent
12341d01a6
commit
cccd3cbdc9
@ -1,9 +1,9 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use calibre_db::data::pagination::SortOrder;
|
||||
use calibre_db::data::{author::Author as DbAuthor, pagination::SortOrder};
|
||||
use poem::{
|
||||
handler,
|
||||
web::{Data, WithContentType},
|
||||
web::{Data, Path, WithContentType},
|
||||
IntoResponse,
|
||||
};
|
||||
use time::OffsetDateTime;
|
||||
@ -20,6 +20,8 @@ use crate::{
|
||||
|
||||
fn create_feed(
|
||||
now: OffsetDateTime,
|
||||
id: &str,
|
||||
title: &str,
|
||||
self_link: Link,
|
||||
mut additional_links: Vec<Link>,
|
||||
entries: Vec<Entry>,
|
||||
@ -42,8 +44,8 @@ fn create_feed(
|
||||
links.append(&mut additional_links);
|
||||
|
||||
Feed {
|
||||
title: "rusty-library".to_string(),
|
||||
id: "rusty:catalog".to_string(),
|
||||
title: title.to_string(),
|
||||
id: id.to_string(),
|
||||
updated: now,
|
||||
icon: "favicon.ico".to_string(),
|
||||
author,
|
||||
@ -53,7 +55,191 @@ fn create_feed(
|
||||
}
|
||||
|
||||
#[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
|
||||
.calibre
|
||||
.books(u32::MAX.into(), None, &SortOrder::ASC)
|
||||
@ -74,14 +260,14 @@ pub async fn books(state: Data<&Arc<AppState>>) -> Result<WithContentType<String
|
||||
title: 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)?;
|
||||
|
||||
Ok(xml.with_content_type("application/atom+xml"))
|
||||
}
|
||||
|
||||
#[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 self_link = Link {
|
||||
@ -95,10 +281,10 @@ pub async fn handler(state: Data<&Arc<AppState>>) -> Result<WithContentType<Stri
|
||||
title: "Books".to_string(),
|
||||
id: "rusty:books".to_string(),
|
||||
updated: now,
|
||||
content: Content {
|
||||
content: Some(Content {
|
||||
media_type: MediaType::Text,
|
||||
content: "Index of all books".to_string(),
|
||||
},
|
||||
}),
|
||||
author: None,
|
||||
links: vec![Link {
|
||||
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)?;
|
||||
|
||||
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()
|
||||
.at("/", get(handlers::recents::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/:cursor/:sort_order", get(handlers::books::handler))
|
||||
.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 time::OffsetDateTime;
|
||||
|
||||
@ -14,7 +15,8 @@ pub struct Entry {
|
||||
pub id: String,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub updated: OffsetDateTime,
|
||||
pub content: Content,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub content: Option<Content>,
|
||||
pub author: Option<Author>,
|
||||
#[serde(rename = "link")]
|
||||
pub links: Vec<Link>,
|
||||
@ -41,20 +43,64 @@ impl From<Book> for Entry {
|
||||
.collect();
|
||||
links.append(&mut format_links);
|
||||
|
||||
let content = value.description.map(|desc| Content {
|
||||
media_type: MediaType::Html,
|
||||
content: desc,
|
||||
});
|
||||
|
||||
Self {
|
||||
title: value.title.clone(),
|
||||
id: format!("urn:uuid:{}", value.uuid),
|
||||
updated: value.last_modified,
|
||||
content: Content {
|
||||
media_type: MediaType::Html,
|
||||
content: value.description.clone().unwrap_or("".to_string()),
|
||||
},
|
||||
content,
|
||||
author: Some(author),
|
||||
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)]
|
||||
mod tests {
|
||||
use quick_xml::se::to_string;
|
||||
@ -69,10 +115,10 @@ mod tests {
|
||||
title: "Authors".to_string(),
|
||||
id: "rust:authors".to_string(),
|
||||
updated: datetime!(2024-05-06 19:14:54 UTC),
|
||||
content: Content {
|
||||
content: Some(Content {
|
||||
media_type: MediaType::Text,
|
||||
content: "All authors".to_string(),
|
||||
},
|
||||
}),
|
||||
author: None,
|
||||
links: vec![
|
||||
Link {
|
||||
|
Loading…
Reference in New Issue
Block a user