book feed

This commit is contained in:
Sebastian Hugentobler 2024-05-09 11:18:47 +02:00
parent 603c2fbe48
commit 12341d01a6
Signed by: shu
GPG Key ID: BB32CF3CA052C2F0
15 changed files with 219 additions and 41 deletions

2
Cargo.lock generated
View File

@ -193,6 +193,7 @@ dependencies = [
"rusqlite", "rusqlite",
"serde", "serde",
"thiserror", "thiserror",
"time",
] ]
[[package]] [[package]]
@ -1310,6 +1311,7 @@ dependencies = [
"hashlink", "hashlink",
"libsqlite3-sys", "libsqlite3-sys",
"smallvec", "smallvec",
"time",
] ]
[[package]] [[package]]

View File

@ -7,6 +7,7 @@ members = [
[workspace.dependencies] [workspace.dependencies]
serde = "1.0.200" serde = "1.0.200"
thiserror = "1.0.59" thiserror = "1.0.59"
time = { version = "0.3.36", features = ["macros", "serde", "formatting", "parsing" ] }
[workspace.package] [workspace.package]
license = "AGPL-3.0" license = "AGPL-3.0"

View File

@ -7,6 +7,7 @@ license = { workspace = true }
[dependencies] [dependencies]
r2d2 = "0.8.10" r2d2 = "0.8.10"
r2d2_sqlite = "0.24.0" r2d2_sqlite = "0.24.0"
rusqlite = { version = "0.31.0", features = ["bundled"] } rusqlite = { version = "0.31.0", features = ["bundled", "time"] }
serde = { workspace = true } serde = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
time = { workspace = true }

View File

@ -1,5 +1,6 @@
use rusqlite::{named_params, Connection, Row}; use rusqlite::{named_params, Connection, Row};
use serde::Serialize; use serde::Serialize;
use time::OffsetDateTime;
use super::{ use super::{
error::DataStoreError, error::DataStoreError,
@ -12,6 +13,9 @@ pub struct Book {
pub title: String, pub title: String,
pub sort: String, pub sort: String,
pub path: String, pub path: String,
pub uuid: String,
pub last_modified: OffsetDateTime,
pub description: Option<String>,
} }
impl Book { impl Book {
@ -21,6 +25,9 @@ impl Book {
title: row.get(1)?, title: row.get(1)?,
sort: row.get(2)?, sort: row.get(2)?,
path: row.get(3)?, 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); let pagination = Pagination::new("sort", cursor, limit, *sort_order);
pagination.paginate( pagination.paginate(
conn, 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, Self::from_row,
) )
@ -49,8 +57,9 @@ impl Book {
let pagination = Pagination::new("books.sort", cursor, limit, sort_order); let pagination = Pagination::new("books.sort", cursor, limit, sort_order);
pagination.paginate( pagination.paginate(
conn, 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 \ 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", WHERE books_authors_link.author = (:author_id) AND",
&[(":author_id", &author_id)], &[(":author_id", &author_id)],
Self::from_row, Self::from_row,
@ -59,9 +68,10 @@ impl Book {
pub fn series_books(conn: &Connection, id: u64) -> Result<Vec<Book>, DataStoreError> { pub fn series_books(conn: &Connection, id: u64) -> Result<Vec<Book>, DataStoreError> {
let mut stmt = conn.prepare( 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_series_link ON series.id = books_series_link.series \
INNER JOIN books ON books.id = books_series_link.book \ INNER JOIN books ON books.id = books_series_link.book \
LEFT JOIN comments ON books.id = comments.book \
WHERE books_series_link.series = (:id) \ WHERE books_series_link.series = (:id) \
ORDER BY books.series_index", ORDER BY books.series_index",
)?; )?;
@ -72,7 +82,8 @@ impl Book {
pub fn recents(conn: &Connection, limit: u64) -> Result<Vec<Self>, DataStoreError> { pub fn recents(conn: &Connection, limit: u64) -> Result<Vec<Self>, DataStoreError> {
let mut stmt = conn.prepare( 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 params = named_params! { ":limit": limit };
let iter = stmt.query_map(params, Self::from_row)?; let iter = stmt.query_map(params, Self::from_row)?;
@ -80,7 +91,10 @@ impl Book {
} }
pub fn scalar_book(conn: &Connection, id: u64) -> Result<Self, DataStoreError> { pub fn scalar_book(conn: &Connection, id: u64) -> Result<Self, DataStoreError> {
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 }; let params = named_params! { ":id": id };
Ok(stmt.query_row(params, Self::from_row)?) Ok(stmt.query_row(params, Self::from_row)?)
} }

View File

@ -1,4 +1,5 @@
use thiserror::Error; use thiserror::Error;
use time::error::Parse;
#[derive(Error, Debug)] #[derive(Error, Debug)]
#[error("data store error")] #[error("data store error")]
@ -9,6 +10,8 @@ pub enum DataStoreError {
SqliteError(rusqlite::Error), SqliteError(rusqlite::Error),
#[error("connection error")] #[error("connection error")]
ConnectionError(#[from] r2d2::Error), ConnectionError(#[from] r2d2::Error),
#[error("failed to parse datetime")]
DateTimeError(#[from] Parse),
} }
impl From<rusqlite::Error> for DataStoreError { impl From<rusqlite::Error> for DataStoreError {

View File

@ -6,7 +6,7 @@ use super::{
pagination::{Pagination, SortOrder}, pagination::{Pagination, SortOrder},
}; };
#[derive(Debug, Serialize)] #[derive(Debug, Clone, Serialize)]
pub struct Series { pub struct Series {
pub id: u64, pub id: u64,
pub name: String, pub name: String,

View File

@ -15,7 +15,7 @@ serde_json = "1.0.116"
serde_with = "3.8.1" serde_with = "3.8.1"
tera = "1.19.1" tera = "1.19.1"
thiserror = { workspace = true } 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"] } tokio = { version = "1.37.0", features = ["rt-multi-thread", "macros"] }
tracing = "0.1.40" tracing = "0.1.40"
tracing-subscriber = "0.3.18" tracing-subscriber = "0.3.18"

View File

@ -1,21 +1,39 @@
use std::{collections::HashMap, path::Path}; use std::{collections::HashMap, fmt::Display, path::Path};
use calibre_db::data::{ use calibre_db::data::{
author::Author as DbAuthor, book::Book as DbBook, series::Series as DbSeries, author::Author as DbAuthor, book::Book as DbBook, series::Series as DbSeries,
}; };
use serde::Serialize; use serde::Serialize;
use time::OffsetDateTime;
use crate::app_state::AppState; use crate::app_state::AppState;
#[derive(Debug, Serialize)] #[derive(Debug, Clone, Serialize, Eq, PartialEq, Hash)]
pub struct Format(pub String);
pub type Formats = HashMap<Format, String>;
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 struct Book {
pub id: u64, pub id: u64,
pub title: String, pub title: String,
pub sort: String, pub sort: String,
pub path: String, pub path: String,
pub uuid: String,
pub last_modified: OffsetDateTime,
pub description: Option<String>,
pub author: DbAuthor, pub author: DbAuthor,
pub series: Option<(DbSeries, f64)>, pub series: Option<(DbSeries, f64)>,
pub formats: HashMap<String, String>, pub formats: Formats,
} }
impl Book { impl Book {
@ -23,20 +41,23 @@ impl Book {
db_book: &DbBook, db_book: &DbBook,
db_series: Option<(DbSeries, f64)>, db_series: Option<(DbSeries, f64)>,
author: DbAuthor, author: DbAuthor,
formats: HashMap<String, String>, formats: Formats,
) -> Self { ) -> Self {
Self { Self {
id: db_book.id, id: db_book.id,
title: db_book.title.clone(), title: db_book.title.clone(),
sort: db_book.sort.clone(), sort: db_book.sort.clone(),
path: db_book.path.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(), author: author.clone(),
series: db_series.map(|x| (x.0, x.1)), series: db_series.map(|x| (x.0, x.1)),
formats, formats,
} }
} }
fn formats(book: &DbBook, library_path: &Path) -> HashMap<String, String> { fn formats(book: &DbBook, library_path: &Path) -> Formats {
let book_path = library_path.join(&book.path); let book_path = library_path.join(&book.path);
let mut formats = HashMap::new(); let mut formats = HashMap::new();
@ -48,7 +69,10 @@ impl Book {
_ => None, _ => None,
}; };
if let Some(format) = format { 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(),
);
} }
} }
} }

View File

@ -7,7 +7,11 @@ use poem::{
IntoResponse, 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] #[handler]
pub async fn handler( pub async fn handler(
@ -19,18 +23,14 @@ pub async fn handler(
.scalar_book(id) .scalar_book(id)
.map_err(HandlerError::DataError)?; .map_err(HandlerError::DataError)?;
let book = Book::full_book(&book, &state).ok_or(NotFoundError)?; let book = Book::full_book(&book, &state).ok_or(NotFoundError)?;
let format: &str = format.as_str(); let format = Format(format);
let file_name = book.formats.get(format).ok_or(NotFoundError)?; let file_name = book.formats.get(&format).ok_or(NotFoundError)?;
let file_path = state.config.library_path.join(book.path).join(file_name); 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 file = File::open(file_path).map_err(|_| NotFoundError)?;
let mut data = Vec::new(); let mut data = Vec::new();
file.read_to_end(&mut data).map_err(|_| NotFoundError)?; file.read_to_end(&mut data).map_err(|_| NotFoundError)?;
let content_type = match format { let content_type = format.0;
"pdf" => "application/pdf",
"epub" => "application/epub+zip",
_ => unreachable!(),
};
Ok(data Ok(data
.with_content_type(content_type) .with_content_type(content_type)

View File

@ -1,5 +1,6 @@
use std::sync::Arc; use std::sync::Arc;
use calibre_db::data::pagination::SortOrder;
use poem::{ use poem::{
handler, handler,
web::{Data, WithContentType}, web::{Data, WithContentType},
@ -9,6 +10,7 @@ use time::OffsetDateTime;
use crate::{ use crate::{
app_state::AppState, app_state::AppState,
data::book::Book,
handlers::error::HandlerError, handlers::error::HandlerError,
opds::{ opds::{
author::Author, content::Content, entry::Entry, feed::Feed, link::Link, author::Author, content::Content, entry::Entry, feed::Feed, link::Link,
@ -16,22 +18,72 @@ use crate::{
}, },
}; };
#[handler] fn create_feed(
pub async fn handler(state: Data<&Arc<AppState>>) -> Result<WithContentType<String>, poem::Error> { now: OffsetDateTime,
let now = OffsetDateTime::now_utc(); self_link: Link,
mut additional_links: Vec<Link>,
entries: Vec<Entry>,
) -> Feed {
let author = Author { let author = Author {
name: "Thallian".to_string(), name: "Thallian".to_string(),
uri: "https://code.vanwa.ch/shu/rusty-library".to_string(), uri: "https://code.vanwa.ch/shu/rusty-library".to_string(),
email: None, email: None,
}; };
let home_link = Link { let mut links = vec![
Link {
href: "/opds".to_string(), href: "/opds".to_string(),
media_type: MediaType::Navigation, media_type: MediaType::Navigation,
rel: Relation::Start, rel: Relation::Start,
title: Some("Home".to_string()), title: Some("Home".to_string()),
count: None, 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<AppState>>) -> Result<WithContentType<String>, poem::Error> {
let books: Vec<Book> = 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<Entry> = 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::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<AppState>>) -> Result<WithContentType<String>, poem::Error> {
let now = OffsetDateTime::now_utc();
let self_link = Link { let self_link = Link {
href: "/opds".to_string(), href: "/opds".to_string(),
media_type: MediaType::Navigation, media_type: MediaType::Navigation,
@ -47,6 +99,7 @@ pub async fn handler(state: Data<&Arc<AppState>>) -> Result<WithContentType<Stri
media_type: MediaType::Text, media_type: MediaType::Text,
content: "Index of all books".to_string(), content: "Index of all books".to_string(),
}, },
author: None,
links: vec![Link { links: vec![Link {
href: "/opds/books".to_string(), href: "/opds/books".to_string(),
media_type: MediaType::Navigation, media_type: MediaType::Navigation,
@ -56,15 +109,7 @@ pub async fn handler(state: Data<&Arc<AppState>>) -> Result<WithContentType<Stri
}], }],
}; };
let feed = Feed { let feed = create_feed(now, self_link, vec![], vec![books_entry]);
title: "rusty-library".to_string(),
id: "rusty:catalog".to_string(),
updated: now,
icon: "favicon.ico".to_string(),
author,
links: vec![home_link, self_link],
entries: vec![books_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,6 +43,7 @@ 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("/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,7 +1,11 @@
use serde::Serialize; use serde::Serialize;
use time::OffsetDateTime; 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)] #[derive(Debug, Serialize)]
#[serde(rename = "entry")] #[serde(rename = "entry")]
@ -11,10 +15,46 @@ pub struct Entry {
#[serde(with = "time::serde::rfc3339")] #[serde(with = "time::serde::rfc3339")]
pub updated: OffsetDateTime, pub updated: OffsetDateTime,
pub content: Content, pub content: Content,
pub author: Option<Author>,
#[serde(rename = "link")] #[serde(rename = "link")]
pub links: Vec<Link>, pub links: Vec<Link>,
} }
impl From<Book> 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<Link> = 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)] #[cfg(test)]
mod tests { mod tests {
use quick_xml::se::to_string; use quick_xml::se::to_string;
@ -33,6 +73,7 @@ mod tests {
media_type: MediaType::Text, media_type: MediaType::Text,
content: "All authors".to_string(), content: "All authors".to_string(),
}, },
author: None,
links: vec![ links: vec![
Link { Link {
href: "/opds".to_string(), href: "/opds".to_string(),

View File

@ -1,5 +1,7 @@
use serde::Serialize; use serde::Serialize;
use crate::data::book::{Book, Format};
use super::{media_type::MediaType, relation::Relation}; use super::{media_type::MediaType, relation::Relation};
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
@ -19,6 +21,20 @@ pub struct Link {
pub count: Option<u64>, pub count: Option<u64>,
} }
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)] #[cfg(test)]
mod tests { mod tests {
use quick_xml::se::to_string; use quick_xml::se::to_string;

View File

@ -1,6 +1,8 @@
use serde_with::SerializeDisplay; use serde_with::SerializeDisplay;
#[derive(Debug, SerializeDisplay)] use crate::data::book::Format;
#[derive(Debug, Copy, Clone, SerializeDisplay)]
pub enum MediaType { pub enum MediaType {
Acquisition, Acquisition,
Epub, Epub,
@ -11,6 +13,16 @@ pub enum MediaType {
Text, Text,
} }
impl From<Format> 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 { impl std::fmt::Display for MediaType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self { match self {

View File

@ -1,5 +1,7 @@
use serde_with::SerializeDisplay; use serde_with::SerializeDisplay;
use super::media_type::MediaType;
#[derive(Debug, SerializeDisplay)] #[derive(Debug, SerializeDisplay)]
pub enum Relation { pub enum Relation {
Image, Image,
@ -7,6 +9,21 @@ pub enum Relation {
Start, Start,
Subsection, Subsection,
Thumbnail, Thumbnail,
Acquisition,
}
impl From<MediaType> 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 { impl std::fmt::Display for Relation {
@ -17,6 +34,7 @@ impl std::fmt::Display for Relation {
Relation::Start => write!(f, "start"), Relation::Start => write!(f, "start"),
Relation::Subsection => write!(f, "subsection"), Relation::Subsection => write!(f, "subsection"),
Relation::Thumbnail => write!(f, "http://opds-spec.org/image/thumbnail"), Relation::Thumbnail => write!(f, "http://opds-spec.org/image/thumbnail"),
Relation::Acquisition => write!(f, "http://opds-spec.org/acquisition"),
} }
} }
} }