This commit is contained in:
Sebastian Hugentobler 2024-05-06 10:55:18 +02:00
parent 5e9f49311e
commit 352d4e0a7a
Signed by: shu
GPG key ID: BB32CF3CA052C2F0
22 changed files with 69 additions and 22 deletions

19
rusty-library/Cargo.toml Normal file
View file

@ -0,0 +1,19 @@
[package]
name = "rusty-library"
version = "0.1.0"
edition = "2021"
[dependencies]
calibre-db = { path = "../calibre-db/" }
clap = { version = "4.5.4", features = ["derive"] }
once_cell = "1.19.0"
poem = { version = "3.0.0", features = ["embed", "static-files"] }
rust-embed = "8.3.0"
serde = { workspace = true }
serde_json = "1.0.116"
tera = "1.19.1"
thiserror = { workspace = true }
tokio = { version = "1.37.0", features = ["rt-multi-thread", "macros"] }
tracing = "0.1.40"
tracing-subscriber = "0.3.18"
uuid = { version = "1.8.0", features = ["v4", "fast-rng"] }

View file

@ -0,0 +1,8 @@
use calibre_db::calibre::Calibre;
use crate::config::Config;
pub struct AppState {
pub calibre: Calibre,
pub config: Config,
}

View file

@ -0,0 +1,44 @@
use poem::{
http::StatusCode,
web::{
headers,
headers::{authorization::Basic, HeaderMapExt},
},
Endpoint, Error, Middleware, Request, Result,
};
pub struct BasicAuth {
pub username: String,
pub password: String,
}
impl<E: Endpoint> Middleware<E> for BasicAuth {
type Output = BasicAuthEndpoint<E>;
fn transform(&self, ep: E) -> Self::Output {
BasicAuthEndpoint {
ep,
username: self.username.clone(),
password: self.password.clone(),
}
}
}
pub struct BasicAuthEndpoint<E> {
ep: E,
username: String,
password: String,
}
impl<E: Endpoint> Endpoint for BasicAuthEndpoint<E> {
type Output = E::Output;
async fn call(&self, req: Request) -> Result<Self::Output> {
if let Some(auth) = req.headers().typed_get::<headers::Authorization<Basic>>() {
if auth.0.username() == self.username && auth.0.password() == self.password {
return self.ep.call(req).await;
}
}
Err(Error::from_status(StatusCode::UNAUTHORIZED))
}
}

10
rusty-library/src/cli.rs Normal file
View file

@ -0,0 +1,10 @@
use clap::Parser;
/// Simple odps server for calibre
#[derive(Parser)]
#[command(version, about, long_about = None)]
pub struct Cli {
/// Calibre library path
#[arg(last = true)]
pub library_path: String,
}

View file

@ -0,0 +1,48 @@
use std::path::{Path, PathBuf};
use thiserror::Error;
use crate::cli::Cli;
#[derive(Error, Debug)]
pub enum ConfigError {
#[error("no folder at {0}")]
LibraryPathNotFound(String),
#[error("no metadata.db in {0}")]
MetadataNotFound(String),
}
pub struct Config {
pub library_path: PathBuf,
pub metadata_path: PathBuf,
}
impl Config {
pub fn load(args: &Cli) -> Result<Self, ConfigError> {
let library_path = Path::new(&args.library_path).to_path_buf();
if !library_path.exists() {
let library_path = library_path
.as_os_str()
.to_str()
.unwrap_or("<failed to parse path>")
.to_string();
return Err(ConfigError::LibraryPathNotFound(library_path));
}
let metadata_path = library_path.join("metadata.db");
if !metadata_path.exists() {
let metadata_path = metadata_path
.as_os_str()
.to_str()
.unwrap_or("<failed to parse path>")
.to_string();
return Err(ConfigError::MetadataNotFound(metadata_path));
}
Ok(Self {
library_path,
metadata_path,
})
}
}

View file

@ -0,0 +1,63 @@
use std::{collections::HashMap, path::Path};
use calibre_db::data::{book::Book as DbBook, series::Series as DbSeries};
use serde::Serialize;
use crate::app_state::AppState;
#[derive(Debug, Serialize)]
pub struct Book {
pub id: u64,
pub title: String,
pub sort: String,
pub path: String,
pub author: String,
pub series: Option<(String, f64)>,
pub formats: HashMap<String, String>,
}
impl Book {
pub fn from_db_book(
db_book: &DbBook,
db_series: Option<(DbSeries, f64)>,
author: &str,
formats: HashMap<String, String>,
) -> Self {
Self {
id: db_book.id,
title: db_book.title.clone(),
sort: db_book.sort.clone(),
path: db_book.path.clone(),
author: author.to_string(),
series: db_series.map(|x| (x.0.name, x.1)),
formats,
}
}
fn formats(book: &DbBook, library_path: &Path) -> HashMap<String, String> {
let book_path = library_path.join(&book.path);
let mut formats = HashMap::new();
for entry in book_path.read_dir().unwrap().flatten() {
if let Some(extension) = entry.path().extension() {
let format = match extension.to_string_lossy().to_string().as_str() {
"pdf" => Some("pdf".to_string()),
"epub" => Some("epub".to_string()),
_ => None,
};
if let Some(format) = format {
formats.insert(format, entry.file_name().to_string_lossy().to_string());
}
}
}
formats
}
pub fn full_book(book: &DbBook, state: &AppState) -> Option<Book> {
let formats = Book::formats(book, &state.config.library_path);
let author = state.calibre.book_author(book.id).ok()?;
let series = state.calibre.book_series(book.id).ok()?;
Some(Book::from_db_book(book, series, &author.name, formats))
}
}

View file

@ -0,0 +1,10 @@
use std::sync::Arc;
use poem::{handler, web::Data};
use crate::app_state::AppState;
#[handler]
pub async fn handler(state: Data<&Arc<AppState>>) -> Result<String, poem::Error> {
Ok("authors".to_string())
}

View file

@ -0,0 +1,10 @@
use std::sync::Arc;
use poem::{handler, web::Data};
use crate::app_state::AppState;
#[handler]
pub async fn handler(state: Data<&Arc<AppState>>) -> Result<String, poem::Error> {
Ok("books".to_string())
}

View file

@ -0,0 +1,25 @@
use std::{fs::File, io::Read, sync::Arc};
use poem::{
error::NotFoundError,
handler,
web::{headers::ContentType, Data, Path, WithContentType},
IntoResponse,
};
use crate::{app_state::AppState, handlers::error::SqliteError};
#[handler]
pub async fn handler(
id: Path<u64>,
state: Data<&Arc<AppState>>,
) -> Result<WithContentType<Vec<u8>>, poem::Error> {
let book = state.calibre.scalar_book(*id).map_err(SqliteError)?;
let cover_path = state.config.library_path.join(book.path).join("cover.jpg");
let mut cover = File::open(cover_path).map_err(|_| NotFoundError)?;
let mut data = Vec::new();
cover.read_to_end(&mut data).map_err(|_| NotFoundError)?;
Ok(data.with_content_type(ContentType::jpeg().to_string()))
}

View file

@ -0,0 +1,35 @@
use std::{fs::File, io::Read, sync::Arc};
use poem::{
error::NotFoundError,
handler,
web::{Data, Path, WithContentType, WithHeader},
IntoResponse,
};
use crate::{app_state::AppState, data::book::Book, handlers::error::SqliteError};
#[handler]
pub async fn handler(
Path((id, format)): Path<(u64, String)>,
state: Data<&Arc<AppState>>,
) -> Result<WithHeader<WithContentType<Vec<u8>>>, poem::Error> {
let book = state.calibre.scalar_book(id).map_err(SqliteError)?;
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 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!(),
};
Ok(data
.with_content_type(content_type)
.with_header("Content-Disposition", format!("filename={file_name};")))
}

View file

@ -0,0 +1,40 @@
use calibre_db::data::error::DataStoreError;
use poem::{error::ResponseError, http::StatusCode, Body, Response};
use tracing::error;
use uuid::Uuid;
#[derive(Debug, thiserror::Error)]
#[error("sqlite error")]
pub struct SqliteError(pub DataStoreError);
impl From<DataStoreError> for SqliteError {
fn from(item: DataStoreError) -> Self {
SqliteError(item)
}
}
impl ResponseError for SqliteError {
fn status(&self) -> StatusCode {
match &self.0 {
DataStoreError::NoResults(_) => StatusCode::NOT_FOUND,
_ => StatusCode::BAD_GATEWAY,
}
}
fn as_response(&self) -> Response {
let id = Uuid::new_v4();
let internal_msg = self.to_string();
let external_msg = match &self.0 {
DataStoreError::NoResults(_) => "item not found",
_ => "internal server error",
};
error!("{id}: {internal_msg}");
let body = Body::from_json(serde_json::json!({
"id": id.to_string(),
"message": external_msg,
}))
.unwrap();
Response::builder().status(self.status()).body(body)
}
}

View file

@ -0,0 +1,28 @@
use std::sync::Arc;
use poem::{
error::InternalServerError,
handler,
web::{Data, Html},
};
use tera::Context;
use crate::{
app_state::AppState, data::book::Book, handlers::error::SqliteError, templates::TEMPLATES,
};
#[handler]
pub async fn handler(state: Data<&Arc<AppState>>) -> Result<Html<String>, poem::Error> {
let recent_books = state.calibre.recent_books(50).map_err(SqliteError)?;
let recent_books = recent_books
.iter()
.filter_map(|x| Book::full_book(x, &state))
.collect::<Vec<Book>>();
let mut context = Context::new();
context.insert("books", &recent_books);
TEMPLATES
.render("recents", &context)
.map_err(InternalServerError)
.map(Html)
}

View file

@ -0,0 +1,10 @@
use std::sync::Arc;
use poem::{handler, web::Data};
use crate::app_state::AppState;
#[handler]
pub async fn handler(state: Data<&Arc<AppState>>) -> Result<String, poem::Error> {
Ok("series".to_string())
}

62
rusty-library/src/main.rs Normal file
View file

@ -0,0 +1,62 @@
use std::sync::Arc;
use app_state::AppState;
use calibre_db::calibre::Calibre;
use clap::Parser;
use cli::Cli;
use config::Config;
use poem::{
endpoint::EmbeddedFilesEndpoint, get, listener::TcpListener, EndpointExt, Route, Server,
};
use rust_embed::RustEmbed;
mod app_state;
mod basic_auth;
mod cli;
mod config;
mod data {
pub mod book;
}
mod handlers {
pub mod authors;
pub mod books;
pub mod cover;
pub mod download;
pub mod error;
pub mod recents;
pub mod series;
}
mod templates;
#[derive(RustEmbed)]
#[folder = "static"]
pub struct Files;
#[tokio::main]
async fn main() -> Result<(), std::io::Error> {
if std::env::var_os("RUST_LOG").is_none() {
std::env::set_var("RUST_LOG", "debug");
}
tracing_subscriber::fmt::init();
let args = Cli::parse();
let config = Config::load(&args).expect("failed to load configuration");
let calibre = Calibre::load(&config.metadata_path).expect("failed to load calibre database");
let app_state = Arc::new(AppState { calibre, config });
let app = Route::new()
.at("/", get(handlers::recents::handler))
.at("/books", get(handlers::books::handler))
.at("/authors", get(handlers::authors::handler))
.at("/series", get(handlers::series::handler))
.at("/cover/:id", get(handlers::cover::handler))
.at("/book/:id/:format", get(handlers::download::handler))
.nest("/static", EmbeddedFilesEndpoint::<Files>::new())
.data(app_state);
Server::new(TcpListener::bind("[::]:3000"))
.name("cops-web")
.run(app)
.await
}

View file

@ -0,0 +1,14 @@
use once_cell::sync::Lazy;
use tera::Tera;
pub static TEMPLATES: Lazy<Tera> = Lazy::new(|| {
let mut tera = Tera::default();
tera.add_raw_templates(vec![
("base", include_str!("../templates/base.html")),
("book_card", include_str!("../templates/book_card.html")),
("recents", include_str!("../templates/recents.html")),
])
.expect("failed to parse tera templates");
tera
});

4
rusty-library/static/pico.min.css vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,25 @@
.book-card header {
white-space: nowrap;
overflow: scroll;
text-overflow: ellipsis;
}
.book-card hgroup {
margin-bottom: 0;
}
.cover {
width: 100%;
object-fit: contain;
height: 25rem;
}
.grid-container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr));
gap: 10px;
}
.grid-item {
text-align: center;
}

View file

@ -0,0 +1,27 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark" />
<link rel="stylesheet" href="/static/pico.min.css" />
<link rel="stylesheet" href="/static/style.css" />
<title>Rusty Library</title>
</head>
<body>
<main class="container">
<nav>
<ul>
<li><strong>Library</strong></li>
</ul>
<ul>
<li><a href="/">Recent</a></li>
<li><a href="/authors">Authors</a></li>
<li><a href="/books">Books</a></li>
<li><a href="/series">Series</a></li>
</ul>
</nav>
{% block content %}{% endblock content %}
</main>
</body>
</html>

View file

@ -0,0 +1,25 @@
<article class="book-card">
<header class="grid-item">
<hgroup>
<h5>{{ book.title }}</h5>
<p>{{ book.author }}</p>
</hgroup>
</header>
<img class="cover" src="/cover/{{ book.id }}" alt="book cover">
<footer>
<form>
<fieldset role="group">
<details class="dropdown">
<summary role="button" class="outline">
Download
</summary>
<ul>
{% for format, _ in book.formats %}
<li><a href="/book/{{ book.id }}/{{ format }}">{{ format }}</a></li>
{% endfor %}
</ul>
</details>
</fieldset>
</form>
</footer>
</article>

View file

@ -0,0 +1,9 @@
{% extends "base" %}
{% block content %}
<h1>Recent Books</h1>
<div class="grid-container">
{% for book in books %}
{% include "book_card" %}
{% endfor %}
</div>
{% endblock content %}