From 12341d01a653bbd5a0882b92235514dad83379a7 Mon Sep 17 00:00:00 2001 From: Sebastian Hugentobler Date: Thu, 9 May 2024 11:18:47 +0200 Subject: [PATCH] book feed --- Cargo.lock | 2 + Cargo.toml | 1 + calibre-db/Cargo.toml | 3 +- calibre-db/src/data/book.rs | 24 ++++++-- calibre-db/src/data/error.rs | 3 + calibre-db/src/data/series.rs | 2 +- rusty-library/Cargo.toml | 2 +- rusty-library/src/data/book.rs | 36 ++++++++++-- rusty-library/src/handlers/download.rs | 16 +++--- rusty-library/src/handlers/opds.rs | 79 ++++++++++++++++++++------ rusty-library/src/lib.rs | 1 + rusty-library/src/opds/entry.rs | 43 +++++++++++++- rusty-library/src/opds/link.rs | 16 ++++++ rusty-library/src/opds/media_type.rs | 14 ++++- rusty-library/src/opds/relation.rs | 18 ++++++ 15 files changed, 219 insertions(+), 41 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 59e48ab..3f9b5bd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -193,6 +193,7 @@ dependencies = [ "rusqlite", "serde", "thiserror", + "time", ] [[package]] @@ -1310,6 +1311,7 @@ dependencies = [ "hashlink", "libsqlite3-sys", "smallvec", + "time", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index ef16e20..ac2af26 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ members = [ [workspace.dependencies] serde = "1.0.200" thiserror = "1.0.59" +time = { version = "0.3.36", features = ["macros", "serde", "formatting", "parsing" ] } [workspace.package] license = "AGPL-3.0" diff --git a/calibre-db/Cargo.toml b/calibre-db/Cargo.toml index 2f9711c..a694661 100644 --- a/calibre-db/Cargo.toml +++ b/calibre-db/Cargo.toml @@ -7,6 +7,7 @@ license = { workspace = true } [dependencies] r2d2 = "0.8.10" r2d2_sqlite = "0.24.0" -rusqlite = { version = "0.31.0", features = ["bundled"] } +rusqlite = { version = "0.31.0", features = ["bundled", "time"] } serde = { workspace = true } thiserror = { workspace = true } +time = { workspace = true } diff --git a/calibre-db/src/data/book.rs b/calibre-db/src/data/book.rs index fde792b..a893b2e 100644 --- a/calibre-db/src/data/book.rs +++ b/calibre-db/src/data/book.rs @@ -1,5 +1,6 @@ use rusqlite::{named_params, Connection, Row}; use serde::Serialize; +use time::OffsetDateTime; use super::{ error::DataStoreError, @@ -12,6 +13,9 @@ pub struct Book { pub title: String, pub sort: String, pub path: String, + pub uuid: String, + pub last_modified: OffsetDateTime, + pub description: Option, } impl Book { @@ -21,6 +25,9 @@ impl Book { title: row.get(1)?, sort: row.get(2)?, path: row.get(3)?, + uuid: row.get(4)?, + last_modified: row.get(5)?, + description: row.get(6)?, }) } @@ -33,7 +40,8 @@ impl Book { let pagination = Pagination::new("sort", cursor, limit, *sort_order); pagination.paginate( conn, - "SELECT id, title, sort, path FROM books", + "SELECT books.id, books.title, books.sort, books.path, books.uuid, books.last_modified, comments.text \ + FROM books LEFT JOIN comments ON books.id = comments.book", &[], Self::from_row, ) @@ -49,8 +57,9 @@ impl Book { let pagination = Pagination::new("books.sort", cursor, limit, sort_order); pagination.paginate( conn, - "SELECT books.id, books.title, books.sort, books.path FROM books \ + "SELECT books.id, books.title, books.sort, books.path, books.uuid, books.last_modified, comments.text FROM books \ INNER JOIN books_authors_link ON books.id = books_authors_link.book \ + LEFT JOIN comments ON books.id = comments.book \ WHERE books_authors_link.author = (:author_id) AND", &[(":author_id", &author_id)], Self::from_row, @@ -59,9 +68,10 @@ impl Book { pub fn series_books(conn: &Connection, id: u64) -> Result, DataStoreError> { let mut stmt = conn.prepare( - "SELECT books.id, books.title, books.sort, books.path FROM series \ + "SELECT books.id, books.title, books.sort, books.path, books.uuid, books.last_modified, comments.text FROM series \ INNER JOIN books_series_link ON series.id = books_series_link.series \ INNER JOIN books ON books.id = books_series_link.book \ + LEFT JOIN comments ON books.id = comments.book \ WHERE books_series_link.series = (:id) \ ORDER BY books.series_index", )?; @@ -72,7 +82,8 @@ impl Book { pub fn recents(conn: &Connection, limit: u64) -> Result, DataStoreError> { let mut stmt = conn.prepare( - "SELECT id, title, sort, path FROM books ORDER BY timestamp DESC LIMIT (:limit)", + "SELECT books.id, books.title, books.sort, books.path, books.uuid, books.last_modified, comments.text \ + FROM books LEFT JOIN comments ON books.id = comments.book ORDER BY books.timestamp DESC LIMIT (:limit)" )?; let params = named_params! { ":limit": limit }; let iter = stmt.query_map(params, Self::from_row)?; @@ -80,7 +91,10 @@ impl Book { } pub fn scalar_book(conn: &Connection, id: u64) -> Result { - let mut stmt = conn.prepare("SELECT id, title, sort, path FROM books WHERE id = (:id)")?; + let mut stmt = conn.prepare( + "SELECT books.id, books.title, books.sort, books.path, books.uuid, books.last_modified, comments.text \ + FROM books LEFT JOIN comments WHERE books.id = (:id)", + )?; let params = named_params! { ":id": id }; Ok(stmt.query_row(params, Self::from_row)?) } diff --git a/calibre-db/src/data/error.rs b/calibre-db/src/data/error.rs index eef40b2..0680abc 100644 --- a/calibre-db/src/data/error.rs +++ b/calibre-db/src/data/error.rs @@ -1,4 +1,5 @@ use thiserror::Error; +use time::error::Parse; #[derive(Error, Debug)] #[error("data store error")] @@ -9,6 +10,8 @@ pub enum DataStoreError { SqliteError(rusqlite::Error), #[error("connection error")] ConnectionError(#[from] r2d2::Error), + #[error("failed to parse datetime")] + DateTimeError(#[from] Parse), } impl From for DataStoreError { diff --git a/calibre-db/src/data/series.rs b/calibre-db/src/data/series.rs index 6f8fc67..ffe43bf 100644 --- a/calibre-db/src/data/series.rs +++ b/calibre-db/src/data/series.rs @@ -6,7 +6,7 @@ use super::{ pagination::{Pagination, SortOrder}, }; -#[derive(Debug, Serialize)] +#[derive(Debug, Clone, Serialize)] pub struct Series { pub id: u64, pub name: String, diff --git a/rusty-library/Cargo.toml b/rusty-library/Cargo.toml index 3f744a2..dfa2399 100644 --- a/rusty-library/Cargo.toml +++ b/rusty-library/Cargo.toml @@ -15,7 +15,7 @@ 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"] } +time = { workspace = true } tokio = { version = "1.37.0", features = ["rt-multi-thread", "macros"] } tracing = "0.1.40" tracing-subscriber = "0.3.18" diff --git a/rusty-library/src/data/book.rs b/rusty-library/src/data/book.rs index f2f47f0..ac5fc12 100644 --- a/rusty-library/src/data/book.rs +++ b/rusty-library/src/data/book.rs @@ -1,21 +1,39 @@ -use std::{collections::HashMap, path::Path}; +use std::{collections::HashMap, fmt::Display, path::Path}; use calibre_db::data::{ author::Author as DbAuthor, book::Book as DbBook, series::Series as DbSeries, }; use serde::Serialize; +use time::OffsetDateTime; use crate::app_state::AppState; -#[derive(Debug, Serialize)] +#[derive(Debug, Clone, Serialize, Eq, PartialEq, Hash)] +pub struct Format(pub String); +pub type Formats = HashMap; + +impl Display for Format { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self.0.as_ref() { + "pdf" => write!(f, "pdf"), + "epub" => write!(f, "epub"), + _ => write!(f, "unknown"), + } + } +} + +#[derive(Debug, Clone, Serialize)] pub struct Book { pub id: u64, pub title: String, pub sort: String, pub path: String, + pub uuid: String, + pub last_modified: OffsetDateTime, + pub description: Option, pub author: DbAuthor, pub series: Option<(DbSeries, f64)>, - pub formats: HashMap, + pub formats: Formats, } impl Book { @@ -23,20 +41,23 @@ impl Book { db_book: &DbBook, db_series: Option<(DbSeries, f64)>, author: DbAuthor, - formats: HashMap, + formats: Formats, ) -> Self { Self { id: db_book.id, title: db_book.title.clone(), sort: db_book.sort.clone(), path: db_book.path.clone(), + uuid: db_book.uuid.clone(), + description: db_book.description.clone(), + last_modified: db_book.last_modified, author: author.clone(), series: db_series.map(|x| (x.0, x.1)), formats, } } - fn formats(book: &DbBook, library_path: &Path) -> HashMap { + fn formats(book: &DbBook, library_path: &Path) -> Formats { let book_path = library_path.join(&book.path); let mut formats = HashMap::new(); @@ -48,7 +69,10 @@ impl Book { _ => None, }; if let Some(format) = format { - formats.insert(format, entry.file_name().to_string_lossy().to_string()); + formats.insert( + Format(format), + entry.file_name().to_string_lossy().to_string(), + ); } } } diff --git a/rusty-library/src/handlers/download.rs b/rusty-library/src/handlers/download.rs index bbb2e25..4c26d9a 100644 --- a/rusty-library/src/handlers/download.rs +++ b/rusty-library/src/handlers/download.rs @@ -7,7 +7,11 @@ use poem::{ IntoResponse, }; -use crate::{app_state::AppState, data::book::Book, handlers::error::HandlerError}; +use crate::{ + app_state::AppState, + data::book::{Book, Format}, + handlers::error::HandlerError, +}; #[handler] pub async fn handler( @@ -19,18 +23,14 @@ pub async fn handler( .scalar_book(id) .map_err(HandlerError::DataError)?; let book = Book::full_book(&book, &state).ok_or(NotFoundError)?; - let format: &str = format.as_str(); - let file_name = book.formats.get(format).ok_or(NotFoundError)?; + let format = Format(format); + let file_name = book.formats.get(&format).ok_or(NotFoundError)?; let file_path = state.config.library_path.join(book.path).join(file_name); let mut file = File::open(file_path).map_err(|_| NotFoundError)?; let mut data = Vec::new(); file.read_to_end(&mut data).map_err(|_| NotFoundError)?; - let content_type = match format { - "pdf" => "application/pdf", - "epub" => "application/epub+zip", - _ => unreachable!(), - }; + let content_type = format.0; Ok(data .with_content_type(content_type) diff --git a/rusty-library/src/handlers/opds.rs b/rusty-library/src/handlers/opds.rs index a17236b..345ec6a 100644 --- a/rusty-library/src/handlers/opds.rs +++ b/rusty-library/src/handlers/opds.rs @@ -1,5 +1,6 @@ use std::sync::Arc; +use calibre_db::data::pagination::SortOrder; use poem::{ handler, web::{Data, WithContentType}, @@ -9,6 +10,7 @@ use time::OffsetDateTime; use crate::{ app_state::AppState, + data::book::Book, handlers::error::HandlerError, opds::{ author::Author, content::Content, entry::Entry, feed::Feed, link::Link, @@ -16,22 +18,72 @@ use crate::{ }, }; -#[handler] -pub async fn handler(state: Data<&Arc>) -> Result, poem::Error> { - let now = OffsetDateTime::now_utc(); - +fn create_feed( + now: OffsetDateTime, + self_link: Link, + mut additional_links: Vec, + entries: Vec, +) -> Feed { 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(), + let mut links = vec![ + Link { + href: "/opds".to_string(), + media_type: MediaType::Navigation, + rel: Relation::Start, + title: Some("Home".to_string()), + count: None, + }, + self_link, + ]; + links.append(&mut additional_links); + + Feed { + title: "rusty-library".to_string(), + id: "rusty:catalog".to_string(), + updated: now, + icon: "favicon.ico".to_string(), + author, + links, + entries, + } +} + +#[handler] +pub async fn books(state: Data<&Arc>) -> Result, poem::Error> { + let books: Vec = state + .calibre + .books(u32::MAX.into(), None, &SortOrder::ASC) + .map(|x| { + x.iter() + .filter_map(|y| Book::full_book(y, &state)) + .collect() + }) + .map_err(HandlerError::DataError)?; + + let entries: Vec = books.into_iter().map(Entry::from).collect(); + let now = OffsetDateTime::now_utc(); + + let self_link = Link { + href: "/opds/books".to_string(), media_type: MediaType::Navigation, - rel: Relation::Start, - title: Some("Home".to_string()), + rel: Relation::Myself, + title: None, count: None, }; + let feed = create_feed(now, 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>) -> Result, poem::Error> { + let now = OffsetDateTime::now_utc(); + let self_link = Link { href: "/opds".to_string(), media_type: MediaType::Navigation, @@ -47,6 +99,7 @@ pub async fn handler(state: Data<&Arc>) -> Result>) -> Result 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("/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/entry.rs b/rusty-library/src/opds/entry.rs index 0394bcb..dd63192 100644 --- a/rusty-library/src/opds/entry.rs +++ b/rusty-library/src/opds/entry.rs @@ -1,7 +1,11 @@ use serde::Serialize; use time::OffsetDateTime; -use super::{content::Content, link::Link}; +use crate::data::book::Book; + +use super::{ + author::Author, content::Content, link::Link, media_type::MediaType, relation::Relation, +}; #[derive(Debug, Serialize)] #[serde(rename = "entry")] @@ -11,10 +15,46 @@ pub struct Entry { #[serde(with = "time::serde::rfc3339")] pub updated: OffsetDateTime, pub content: Content, + pub author: Option, #[serde(rename = "link")] pub links: Vec, } +impl From for Entry { + fn from(value: Book) -> Self { + let author = Author { + name: value.clone().author.name, + uri: format!("/opds/authors/{}", value.author.id), + email: None, + }; + let mut links = vec![Link { + href: format!("/cover/{}", value.id), + media_type: MediaType::Jpeg, + rel: Relation::Image, + title: None, + count: None, + }]; + let mut format_links: Vec = value + .formats + .iter() + .map(|(key, val)| Link::from((&value, (key, val.as_str())))) + .collect(); + links.append(&mut format_links); + + 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()), + }, + author: Some(author), + links, + } + } +} + #[cfg(test)] mod tests { use quick_xml::se::to_string; @@ -33,6 +73,7 @@ mod tests { media_type: MediaType::Text, content: "All authors".to_string(), }, + author: None, links: vec![ Link { href: "/opds".to_string(), diff --git a/rusty-library/src/opds/link.rs b/rusty-library/src/opds/link.rs index fd0c8e6..1cfe1ba 100644 --- a/rusty-library/src/opds/link.rs +++ b/rusty-library/src/opds/link.rs @@ -1,5 +1,7 @@ use serde::Serialize; +use crate::data::book::{Book, Format}; + use super::{media_type::MediaType, relation::Relation}; #[derive(Debug, Serialize)] @@ -19,6 +21,20 @@ pub struct Link { pub count: Option, } +impl From<(&Book, (&Format, &str))> for Link { + fn from(value: (&Book, (&Format, &str))) -> Self { + let format = value.1 .0.clone(); + let media_type: MediaType = format.into(); + Self { + href: format!("/book/{}/{}", value.0.id, value.1 .0), + media_type, + rel: media_type.into(), + title: Some(value.1 .0 .0.clone()), + count: None, + } + } +} + #[cfg(test)] mod tests { use quick_xml::se::to_string; diff --git a/rusty-library/src/opds/media_type.rs b/rusty-library/src/opds/media_type.rs index 61f46a4..a9547ce 100644 --- a/rusty-library/src/opds/media_type.rs +++ b/rusty-library/src/opds/media_type.rs @@ -1,6 +1,8 @@ use serde_with::SerializeDisplay; -#[derive(Debug, SerializeDisplay)] +use crate::data::book::Format; + +#[derive(Debug, Copy, Clone, SerializeDisplay)] pub enum MediaType { Acquisition, Epub, @@ -11,6 +13,16 @@ pub enum MediaType { Text, } +impl From for MediaType { + fn from(value: Format) -> Self { + match value.0.as_ref() { + "epub" => MediaType::Epub, + "pdf" => MediaType::Pdf, + _ => MediaType::Text, + } + } +} + impl std::fmt::Display for MediaType { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { diff --git a/rusty-library/src/opds/relation.rs b/rusty-library/src/opds/relation.rs index acca3cf..52ac158 100644 --- a/rusty-library/src/opds/relation.rs +++ b/rusty-library/src/opds/relation.rs @@ -1,5 +1,7 @@ use serde_with::SerializeDisplay; +use super::media_type::MediaType; + #[derive(Debug, SerializeDisplay)] pub enum Relation { Image, @@ -7,6 +9,21 @@ pub enum Relation { Start, Subsection, Thumbnail, + Acquisition, +} + +impl From for Relation { + fn from(value: MediaType) -> Self { + match value { + MediaType::Acquisition => Relation::Acquisition, + MediaType::Epub => Relation::Acquisition, + MediaType::Html => Relation::Acquisition, + MediaType::Jpeg => Relation::Image, + MediaType::Navigation => Relation::Myself, + MediaType::Pdf => Relation::Acquisition, + MediaType::Text => Relation::Acquisition, + } + } } impl std::fmt::Display for Relation { @@ -17,6 +34,7 @@ impl std::fmt::Display for Relation { Relation::Start => write!(f, "start"), Relation::Subsection => write!(f, "subsection"), Relation::Thumbnail => write!(f, "http://opds-spec.org/image/thumbnail"), + Relation::Acquisition => write!(f, "http://opds-spec.org/acquisition"), } } }