Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
683 changes: 631 additions & 52 deletions crates/citadel-opds/src/catalog.rs

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions crates/citadel-opds/src/identity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ pub(crate) fn book_identity(library_uuid: &str, raw_uuid: Option<&str>, book_id:
}
}

pub(crate) fn navigation_identity(library_uuid: &str, key: &str) -> String {
format!(
"{}:navigation:{}",
library_identity(library_uuid),
hex(key.as_bytes())
)
}

fn hex(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut result = String::with_capacity(bytes.len() * 2);
Expand Down
4 changes: 3 additions & 1 deletion crates/citadel-opds/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ pub mod service;
mod xml;

pub use auth::OpdsBasicAuth;
pub use catalog::{router, CatalogSource};
pub use catalog::{
router, CatalogBookQuery, CatalogFacet, CatalogFilter, CatalogSort, CatalogSource,
};
pub use credentials::{GeneratedOpdsCredentials, OpdsCredentialStatus};
pub use network::{OpdsInterfaceKind, OpdsInterfaceState, OpdsNetworkInterface};
pub use service::{
Expand Down
3 changes: 1 addition & 2 deletions crates/citadel-opds/src/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -935,8 +935,7 @@ mod tests {

fn book_page(
&self,
_limit: i64,
_offset: i64,
_query: crate::catalog::CatalogBookQuery,
) -> Result<
(String, Option<chrono::NaiveDateTime>, libcalibre::BookPage),
libcalibre::CalibreError,
Expand Down
65 changes: 60 additions & 5 deletions crates/citadel-server/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ use std::{

use chrono::NaiveDateTime;
use citadel_opds::{
CatalogSource, OpdsErrorCode, OpdsLifecycleState, OpdsService, OpdsServiceStatus,
OpdsStartConfig,
CatalogBookQuery, CatalogFacet, CatalogSource, OpdsErrorCode, OpdsLifecycleState, OpdsService,
OpdsServiceStatus, OpdsStartConfig,
};
use libcalibre::{BookId, BookPage, CalibreError, Library, ResolvedBookAsset};
use serde::Deserialize;
Expand Down Expand Up @@ -61,16 +61,71 @@ impl CatalogSource for CalibreCatalogSource {

fn book_page(
&self,
limit: i64,
offset: i64,
query: CatalogBookQuery,
) -> Result<(String, Option<NaiveDateTime>, BookPage), CalibreError> {
let mut library = self.library.lock().expect("server library mutex poisoned");
let library_id = library.library_uuid()?;
let updated_at = library.catalog_updated_at()?;
let page = library.query_acquirable_books(limit, offset)?;
let page = match query.into_calibre() {
Some(query) => library.query_acquirable_books_with(query)?,
None => BookPage {
items: Vec::new(),
total: 0,
},
};
Ok((library_id, updated_at, page))
}

fn authors(&self) -> Result<Vec<CatalogFacet>, CalibreError> {
self.library
.lock()
.expect("server library mutex poisoned")
.list_authors()
.map(|authors| {
authors
.into_iter()
.map(|author| CatalogFacet {
id: author.id.as_i32(),
title: author.name,
book_count: Some(author.book_count),
})
.collect()
})
}

fn series(&self) -> Result<Vec<CatalogFacet>, CalibreError> {
self.library
.lock()
.expect("server library mutex poisoned")
.list_series()
.map(|series| {
series
.into_iter()
.map(|series| CatalogFacet {
id: series.id,
title: series.name,
book_count: Some(series.book_count),
})
.collect()
})
}

fn tags(&self) -> Result<Vec<CatalogFacet>, CalibreError> {
self.library
.lock()
.expect("server library mutex poisoned")
.list_tags()
.map(|tags| {
tags.into_iter()
.map(|tag| CatalogFacet {
id: tag.id,
title: tag.name,
book_count: Some(tag.book_count),
})
.collect()
})
}

fn book_file(&self, book_id: BookId, format: &str) -> Result<ResolvedBookAsset, CalibreError> {
self.library
.lock()
Expand Down
12 changes: 10 additions & 2 deletions crates/citadel-server/tests/headless_process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -120,15 +120,23 @@ passwordEnvironment = "CITADEL_TEST_OPDS_PASSWORD"
.build()
.unwrap();
assert_eq!(client.get(&catalog_url).send().unwrap().status(), 401);
let feed = client
let navigation = client
.get(&catalog_url)
.basic_auth("reader", Some("secret"))
.send()
.unwrap();
assert!(navigation.status().is_success());
assert!(navigation.text().unwrap().contains("All Books"));

let origin = catalog_url.strip_suffix("/opds").unwrap();
let feed = client
.get(format!("{origin}/opds/all"))
.basic_auth("reader", Some("secret"))
.send()
.unwrap();
assert!(feed.status().is_success());
assert!(feed.text().unwrap().contains("Headless Citadel"));

let origin = catalog_url.strip_suffix("/opds").unwrap();
let acquisition = client
.get(format!(
"{origin}/opds/books/{}/files/EPUB/book.epub",
Expand Down
6 changes: 3 additions & 3 deletions crates/libcalibre/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@ pub mod util;
pub use custom_columns::{CustomColumn, CustomColumnKind, CustomColumnSpec, CustomValue};
pub use error::CalibreError;
pub use library::{
Author as LibraryAuthor, AuthorAdd, AuthorUpdate, Book as LibraryBook, BookAdd, BookFileInfo,
BookIdentifier, BookPage, BookQuery, BookSortOrder, BookUpdate, Library, ResolvedBookAsset,
SeriesSummary, TagSummary,
Author as LibraryAuthor, AuthorAdd, AuthorSummary, AuthorUpdate, Book as LibraryBook, BookAdd,
BookFileInfo, BookIdentifier, BookPage, BookQuery, BookSortOrder, BookUpdate, Library,
ResolvedBookAsset, SeriesSummary, TagSummary,
};
pub use stats::{library_stats, LibraryStats};
pub use types::{AuthorId, BookFileId, BookId};
Expand Down
85 changes: 80 additions & 5 deletions crates/libcalibre/src/library.rs
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,10 @@ pub enum BookSortOrder {
TitleDesc,
AuthorAsc,
AuthorDesc,
/// Most recently modified first, with book id as a stable tiebreaker.
UpdatedDesc,
/// Calibre series position first, then title and id for deterministic ties.
SeriesIndexAsc,
}

/// A paged, sorted, filtered book query. All filters compose (AND).
Expand All @@ -166,9 +170,13 @@ pub struct BookQuery {
pub author_id: Option<AuthorId>,
/// Only books linked to this series.
pub series_id: Option<i32>,
/// Only books linked to this tag.
pub tag_id: Option<i32>,
/// Exclude books marked read (filtered in SQL, so paging and totals stay
/// correct).
pub hide_read: bool,
/// Only books with at least one Calibre `data` row.
pub require_file: bool,
pub sort: BookSortOrder,
/// Maximum number of books to return. `None` returns all matches.
pub limit: Option<i64>,
Expand Down Expand Up @@ -196,12 +204,21 @@ pub struct SeriesSummary {
pub book_count: i64,
}

/// One author in the library with its linked-book count.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AuthorSummary {
pub id: AuthorId,
pub name: String,
pub book_count: i64,
}

/// One tag in the library. Returned by [`Library::list_tags`]; the full
/// vocabulary feeds tag autocomplete in clients.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TagSummary {
pub id: i32,
pub name: String,
pub book_count: i64,
}

impl Library {
Expand Down Expand Up @@ -815,7 +832,14 @@ impl Library {
}

pub fn get_book_read_state(&mut self, book_id: BookId) -> Result<bool, CalibreError> {
let column = self.get_or_create_read_state_column()?;
let Some(column) = custom_columns::find_by_label_and_kind(
&mut self.conn,
"read",
&CustomColumnKind::Bool,
)?
else {
return Ok(false);
};
let value = custom_columns::get_value(&mut self.conn, &column, book_id)?;
Ok(matches!(value, Some(CustomValue::Bool(true))))
}
Expand All @@ -842,7 +866,14 @@ impl Library {
return Ok(HashMap::new());
}

let column = self.get_or_create_read_state_column()?;
let Some(column) = custom_columns::find_by_label_and_kind(
&mut self.conn,
"read",
&CustomColumnKind::Bool,
)?
else {
return Ok(HashMap::new());
};
let values = custom_columns::batch_get_values(&mut self.conn, &column, book_ids)?;

Ok(values
Expand Down Expand Up @@ -888,7 +919,9 @@ impl Library {
.filter(|text| !text.is_empty()),
author_id: query.author_id,
series_id: query.series_id,
tag_id: query.tag_id,
hide_read_column,
require_file: query.require_file,
};

let total = book_queries::query_count(&mut self.conn, &filters)?;
Expand All @@ -912,9 +945,43 @@ impl Library {
limit: i64,
offset: i64,
) -> Result<BookPage, CalibreError> {
self.query_acquirable_books_with(BookQuery {
limit: Some(limit),
offset,
..BookQuery::default()
})
}

/// Run a filtered, sorted catalog query while excluding books whose file
/// records cannot be resolved safely beneath the library root. Candidate
/// rows are scanned without hydrating books; only the selected page is
/// hydrated.
pub fn query_acquirable_books_with(
&mut self,
query: BookQuery,
) -> Result<BookPage, CalibreError> {
let hide_read_column = if query.hide_read {
custom_columns::find_by_label_and_kind(&mut self.conn, "read", &CustomColumnKind::Bool)?
.map(|column| column.id)
} else {
None
};
let filters = book_queries::BookPageFilters {
text: query
.text
.as_deref()
.map(str::trim)
.filter(|text| !text.is_empty()),
author_id: query.author_id,
series_id: query.series_id,
tag_id: query.tag_id,
hide_read_column,
require_file: true,
};
let mut resolvable_ids = Vec::new();
let mut seen = std::collections::HashSet::new();
for candidate in book_queries::acquisition_candidates(&mut self.conn)? {
for candidate in book_queries::acquisition_candidates(&mut self.conn, &filters, query.sort)?
{
let book_id = BookId(candidate.book_id);
if seen.contains(&book_id) {
continue;
Expand All @@ -930,8 +997,11 @@ impl Library {
}
}
let total = u64::try_from(resolvable_ids.len()).expect("usize fits in u64");
let start = usize::try_from(offset.max(0)).unwrap_or(usize::MAX);
let page_len = usize::try_from(limit.max(0)).unwrap_or(usize::MAX);
let start = usize::try_from(query.offset.max(0)).unwrap_or(usize::MAX);
let page_len = query
.limit
.map(|limit| usize::try_from(limit.max(0)).unwrap_or(usize::MAX))
.unwrap_or(usize::MAX);
let book_ids = resolvable_ids
.into_iter()
.skip(start)
Expand Down Expand Up @@ -962,6 +1032,11 @@ impl Library {
crate::queries::series::list_with_book_counts(&mut self.conn)
}

/// List authors with linked-book counts, sorted case-insensitively.
pub fn list_authors(&mut self) -> Result<Vec<AuthorSummary>, CalibreError> {
author_queries::list_with_book_counts(&mut self.conn)
}

/// List every tag in the library (the whole vocabulary, including tags
/// no longer linked to any book), sorted case-insensitively by name.
pub fn list_tags(&mut self) -> Result<Vec<TagSummary>, CalibreError> {
Expand Down
39 changes: 37 additions & 2 deletions crates/libcalibre/src/queries/authors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,47 @@
use std::collections::HashMap;

use diesel::prelude::*;
use diesel::{QueryDsl, RunQueryDsl, SqliteConnection};
use diesel::sql_query;
use diesel::sql_types::{BigInt, Integer, Text};
use diesel::{QueryDsl, QueryableByName, RunQueryDsl, SqliteConnection};

use crate::entities::author::{NewAuthor, UpdateAuthorData};
use crate::sorting;
use crate::types::BookId;
use crate::{types::AuthorId, Author, CalibreError};
use crate::{library::AuthorSummary, types::AuthorId, Author, CalibreError};

pub(crate) fn list_with_book_counts(
conn: &mut SqliteConnection,
) -> Result<Vec<AuthorSummary>, CalibreError> {
#[derive(QueryableByName)]
struct AuthorCountRow {
#[diesel(sql_type = Integer)]
id: i32,
#[diesel(sql_type = Text)]
name: String,
#[diesel(sql_type = BigInt)]
book_count: i64,
}

let rows: Vec<AuthorCountRow> = sql_query(
"SELECT a.id AS id, a.name AS name, COUNT(bal.book) AS book_count
FROM authors a
LEFT JOIN books_authors_link bal ON bal.author = a.id
GROUP BY a.id, a.name
ORDER BY a.name COLLATE NOCASE, a.name, a.id",
)
.load(conn)
.map_err(CalibreError::from)?;

Ok(rows
.into_iter()
.map(|row| AuthorSummary {
id: AuthorId(row.id),
name: row.name,
book_count: row.book_count,
})
.collect())
}

pub(crate) fn get(
conn: &mut SqliteConnection,
Expand Down
Loading
Loading