Skip to content

Commit 87da04e

Browse files
phildenhoffclaude
andauthored
perf: Authors page at scale — has_cover, O(n) counts, virtualized list (#122)
* feat(libcalibre): one-pass author book counts Add Library::author_book_counts: a single GROUP BY over books_authors_link returning a HashMap<AuthorId, i64> (authors with no linked books are absent). Mirrors the list_series/SeriesSummary precedent and measures 0.25ms median for 617 authors / 5,000 links in the bench example, which gains an author_book_counts scenario. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * perf(tauri): trust has_cover; put book_count on LibraryAuthor book_cover_image statted cover.jpg per book (PathBuf::exists), which dominated clb_query_list_all_books at 5k books (~90% of 340ms vs 33ms of hydration). Trust the books.has_cover flag instead — Calibre keeps it accurate and our own cover writes (Library::set_book_cover, Library::add_book) set it alongside writing the file. A stale flag now yields a dangling asset URL that the frontend cover onerror fallback absorbs. Applies to clb_query_list_all_books, clb_query_search_books, and clb_query_books alike (shared to_library_book path). LibraryAuthor gains book_count (u32), populated from Library::author_book_counts everywhere the DTO is built — the authors listing, and each book's author_list (one extra ~0.3ms map fetch per query call) — so the Authors page no longer needs the whole book list to render counts. bindings.ts hand-edited in specta style (field order matches the Rust struct; LibraryBook/LibraryAuthor shapes otherwise unchanged). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * perf(authors): backend counts + virtualized rows, drop useAllBooks The Authors page computed each row's book count by scanning all books per author (O(authors x books): ~480ms warm render at 617 authors / 5,000 books) and pulled the entire library via useAllBooks just for counts and the authors-without-books filter — triggering the 340ms list-all on page load. Both now ride on LibraryAuthor.book_count from the authors payload; the page no longer touches the book list at all. invalidateBooks reloads authors in the background so counts stay fresh after book mutations (the page previously got this for free from the stale-books reload). The 617 author rows are also windowed with @tanstack/react-virtual (same pattern as BookGrid), against the app's shared scroll region so the sticky filter bar, column header, and footer behave exactly as before. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 4141149 commit 87da04e

16 files changed

Lines changed: 337 additions & 67 deletions

File tree

crates/libcalibre/examples/bench_library_queries.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -485,6 +485,15 @@ fn run() -> Result<(), String> {
485485
let authors = library.authors().expect("authors()");
486486
format!("{} rows", authors.len())
487487
}));
488+
results.push(bench(
489+
"author_book_counts() [Authors page counts, one GROUP BY]",
490+
runs,
491+
warmup,
492+
|| {
493+
let counts = library.author_book_counts().expect("author_book_counts()");
494+
format!("{} authors with books", counts.len())
495+
},
496+
));
488497

489498
// ---- Report ----------------------------------------------------------
490499
println!("\n## Benchmark results ({runs} runs after {warmup} warmup, ms)\n");

crates/libcalibre/src/library.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -511,6 +511,13 @@ impl Library {
511511
operations::authors::all(&mut self.conn)
512512
}
513513

514+
/// Linked-book count per author, computed in one GROUP BY pass over
515+
/// `books_authors_link` (no per-author scans). Authors with no linked
516+
/// books are absent from the map — treat a missing entry as 0.
517+
pub fn author_book_counts(&mut self) -> Result<HashMap<AuthorId, i64>, CalibreError> {
518+
author_queries::book_counts(&mut self.conn)
519+
}
520+
514521
pub fn get_author(&mut self, author_id: AuthorId) -> Result<Author, CalibreError> {
515522
operations::authors::get(&mut self.conn, author_id)
516523
}

crates/libcalibre/src/queries/authors.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,28 @@ pub(crate) fn all(conn: &mut SqliteConnection) -> Result<Vec<Author>, CalibreErr
6969
.map_err(CalibreError::from)
7070
}
7171

72+
/// Linked-book count per author, in one GROUP BY pass over
73+
/// `books_authors_link`. Authors with no linked books are absent from the
74+
/// map (their count is 0).
75+
pub(crate) fn book_counts(
76+
conn: &mut SqliteConnection,
77+
) -> Result<HashMap<AuthorId, i64>, CalibreError> {
78+
use diesel::dsl::count_star;
79+
80+
use crate::schema::books_authors_link::dsl::*;
81+
82+
let rows: Vec<(i32, i64)> = books_authors_link
83+
.group_by(author)
84+
.select((author, count_star()))
85+
.load(conn)
86+
.map_err(CalibreError::from)?;
87+
88+
Ok(rows
89+
.into_iter()
90+
.map(|(author_id, book_count)| (AuthorId(author_id), book_count))
91+
.collect())
92+
}
93+
7294
pub(crate) fn create(
7395
conn: &mut SqliteConnection,
7496
new_author: NewAuthor,

crates/libcalibre/tests/authors_api_test.rs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,3 +154,53 @@ fn test_delete_author_with_books_fails() {
154154
let result = lib.remove_author(author.id);
155155
assert!(result.is_err());
156156
}
157+
158+
#[test]
159+
fn test_author_book_counts_empty_library() {
160+
let (_temp, mut lib) = setup_with_library();
161+
162+
let counts = lib.author_book_counts().unwrap();
163+
assert!(counts.is_empty());
164+
}
165+
166+
#[test]
167+
fn test_author_book_counts_groups_links_per_author() {
168+
let (_temp, mut lib) = setup_with_library();
169+
170+
let bookless_author_id = lib
171+
.add_author(AuthorAdd {
172+
name: "No Books".to_string(),
173+
sort: Some("Books, No".to_string()),
174+
link: None,
175+
})
176+
.unwrap();
177+
178+
let book = |title: &str, author_names: Vec<&str>| BookAdd {
179+
title: title.to_string(),
180+
author_names: author_names.into_iter().map(String::from).collect(),
181+
tags: None,
182+
series: None,
183+
series_index: None,
184+
publisher: None,
185+
publication_date: None,
186+
rating: None,
187+
comments: None,
188+
identifiers: HashMap::new(),
189+
file_paths: vec![],
190+
};
191+
192+
lib.add_book(book("Solo Work", vec!["Author A"])).unwrap();
193+
lib.add_book(book("Another Solo Work", vec!["Author A"]))
194+
.unwrap();
195+
lib.add_book(book("Co-Written Work", vec!["Author A", "Author B"]))
196+
.unwrap();
197+
198+
let authors = lib.authors().unwrap();
199+
let id_of = |name: &str| authors.iter().find(|a| a.name == name).unwrap().id;
200+
201+
let counts = lib.author_book_counts().unwrap();
202+
assert_eq!(counts.get(&id_of("Author A")), Some(&3));
203+
assert_eq!(counts.get(&id_of("Author B")), Some(&1));
204+
// Authors with no linked books are absent, not present-with-zero.
205+
assert!(!counts.contains_key(&bookless_author_id));
206+
}

src-tauri/src/book.rs

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,12 +59,20 @@ pub struct LibraryBook {
5959
}
6060

6161
impl LibraryBook {
62-
pub fn from_library_book(book: &libcalibre::library::Book, library_path: &str) -> Self {
62+
pub fn from_library_book(
63+
book: &libcalibre::library::Book,
64+
library_path: &str,
65+
author_book_counts: &HashMap<libcalibre::AuthorId, i64>,
66+
) -> Self {
6367
Self {
6468
id: book.id.as_i32().to_string(),
6569
uuid: Some(book.uuid.clone()),
6670
title: book.title.clone(),
67-
author_list: book.authors.iter().map(LibraryAuthor::from).collect(),
71+
author_list: book
72+
.authors
73+
.iter()
74+
.map(|author| LibraryAuthor::from_author(author, author_book_counts))
75+
.collect(),
6876
tag_list: book.tags.clone(),
6977
sortable_title: book.sortable_title.clone(),
7078
identifier_list: book.identifiers.iter().map(Identifier::from).collect(),
@@ -97,6 +105,11 @@ pub struct LibraryAuthor {
97105
pub id: String,
98106
pub name: String,
99107
pub sortable_name: String,
108+
/// Number of books in the library linked to this author, from
109+
/// `Library::author_book_counts` (one GROUP BY pass over
110+
/// `books_authors_link`). The Authors page renders this directly
111+
/// instead of deriving counts from the whole book list.
112+
pub book_count: u32,
100113
}
101114

102115
#[derive(Serialize, Deserialize, specta::Type)]

src-tauri/src/libs/calibre/author.rs

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,26 @@
1+
use std::collections::HashMap;
2+
13
use serde::{Deserialize, Serialize};
24

35
use crate::book::LibraryAuthor;
46

5-
impl From<&libcalibre::library::Author> for LibraryAuthor {
6-
fn from(author: &libcalibre::library::Author) -> Self {
7+
impl LibraryAuthor {
8+
/// Build the DTO from a hydrated author plus the library-wide
9+
/// `Library::author_book_counts` map (authors absent from the map have
10+
/// no linked books).
11+
pub fn from_author(
12+
author: &libcalibre::library::Author,
13+
book_counts: &HashMap<libcalibre::AuthorId, i64>,
14+
) -> Self {
715
LibraryAuthor {
816
id: author.id.as_i32().to_string(),
917
name: author.name.clone(),
1018
sortable_name: author.sort.clone(),
19+
book_count: book_counts
20+
.get(&author.id)
21+
.copied()
22+
.map(|count| u32::try_from(count).unwrap_or(u32::MAX))
23+
.unwrap_or(0),
1124
}
1225
}
1326
}

src-tauri/src/libs/calibre/book.rs

Lines changed: 74 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,21 @@
1-
use std::path::PathBuf;
1+
use std::{collections::HashMap, path::PathBuf};
22

3-
use libcalibre::Library;
3+
use libcalibre::{AuthorId, Library};
44

55
use crate::{
66
book::{LibraryBook, LocalOrRemote, LocalOrRemoteUrl},
77
libs::util,
88
};
99

10-
/// Generate a LocalOrRemoteUrl for the cover image of a book, if the file exists
11-
/// on disk.
10+
/// Generate a LocalOrRemoteUrl for the cover image of a book.
11+
///
12+
/// Trusts the `has_cover` flag from the books table (Calibre keeps it
13+
/// accurate, and our own cover writes — `Library::set_book_cover` and
14+
/// `Library::add_book` — set it alongside writing cover.jpg) instead of
15+
/// statting cover.jpg per book: at 5k books the per-book
16+
/// `PathBuf::exists` dominated `clb_query_list_all_books` (~90% of 340ms).
17+
/// If the flag is stale (file deleted behind Calibre's back) the URL
18+
/// dangles and the frontend's cover `onerror` fallback takes over.
1219
fn book_cover_image(
1320
library_root: &str,
1421
book: &libcalibre::library::Book,
@@ -19,22 +26,21 @@ fn book_cover_image(
1926

2027
let cover_relative_path = format!("{}/cover.jpg", &book.book_dir_path);
2128
let cover_image_path = PathBuf::from(library_root).join(&cover_relative_path);
29+
let url = util::path_to_asset_url(&cover_image_path);
2230

23-
if cover_image_path.exists() {
24-
let url = util::path_to_asset_url(&cover_image_path);
25-
26-
Some(LocalOrRemoteUrl {
27-
kind: LocalOrRemote::Local,
28-
local_path: Some(cover_image_path),
29-
url,
30-
})
31-
} else {
32-
None
33-
}
31+
Some(LocalOrRemoteUrl {
32+
kind: LocalOrRemote::Local,
33+
local_path: Some(cover_image_path),
34+
url,
35+
})
3436
}
3537

36-
fn to_library_book(library_root: &str, book: &libcalibre::library::Book) -> LibraryBook {
37-
let mut library_book = LibraryBook::from_library_book(book, library_root);
38+
fn to_library_book(
39+
library_root: &str,
40+
book: &libcalibre::library::Book,
41+
author_book_counts: &HashMap<AuthorId, i64>,
42+
) -> LibraryBook {
43+
let mut library_book = LibraryBook::from_library_book(book, library_root, author_book_counts);
3844
library_book.cover_image = book_cover_image(library_root, book);
3945
library_book
4046
}
@@ -44,10 +50,11 @@ pub fn list_all(
4450
lib: &mut Library,
4551
) -> Result<Vec<LibraryBook>, libcalibre::CalibreError> {
4652
let results = lib.books()?;
53+
let author_book_counts = lib.author_book_counts()?;
4754

4855
Ok(results
4956
.iter()
50-
.map(|book| to_library_book(&library_root, book))
57+
.map(|book| to_library_book(&library_root, book, &author_book_counts))
5158
.collect())
5259
}
5360

@@ -57,10 +64,11 @@ pub fn search(
5764
query: &str,
5865
) -> Result<Vec<LibraryBook>, libcalibre::CalibreError> {
5966
let results = lib.search_books(query)?;
67+
let author_book_counts = lib.author_book_counts()?;
6068

6169
Ok(results
6270
.iter()
63-
.map(|book| to_library_book(&library_root, book))
71+
.map(|book| to_library_book(&library_root, book, &author_book_counts))
6472
.collect())
6573
}
6674

@@ -70,12 +78,58 @@ pub fn query_page(
7078
query: libcalibre::BookQuery,
7179
) -> Result<(Vec<LibraryBook>, i64), libcalibre::CalibreError> {
7280
let page = lib.query_books(query)?;
81+
let author_book_counts = lib.author_book_counts()?;
7382

7483
Ok((
7584
page.items
7685
.iter()
77-
.map(|book| to_library_book(&library_root, book))
86+
.map(|book| to_library_book(&library_root, book, &author_book_counts))
7887
.collect(),
7988
page.total,
8089
))
8190
}
91+
92+
#[cfg(test)]
93+
mod tests {
94+
use super::*;
95+
96+
fn test_book(has_cover: bool) -> libcalibre::library::Book {
97+
libcalibre::library::Book {
98+
id: libcalibre::BookId::from(1),
99+
uuid: "test-uuid".to_string(),
100+
title: "Title".to_string(),
101+
sortable_title: None,
102+
authors: vec![],
103+
tags: vec![],
104+
series: None,
105+
series_index: None,
106+
description: None,
107+
identifiers: vec![],
108+
has_cover,
109+
is_read: false,
110+
files: vec![],
111+
created_at: chrono::DateTime::UNIX_EPOCH.naive_utc(),
112+
updated_at: chrono::DateTime::UNIX_EPOCH.naive_utc(),
113+
book_dir_path: "Author/Title (1)".to_string(),
114+
}
115+
}
116+
117+
#[test]
118+
fn no_cover_flag_yields_no_cover_url() {
119+
assert!(book_cover_image("/library", &test_book(false)).is_none());
120+
}
121+
122+
/// `has_cover` is trusted without statting cover.jpg (the per-book
123+
/// `PathBuf::exists` dominated list-all at 5k books). A stale flag
124+
/// yields a dangling URL, which the frontend's onerror fallback absorbs.
125+
#[test]
126+
fn has_cover_flag_trusted_without_filesystem_check() {
127+
let cover = book_cover_image("/definitely/not/a/real/library", &test_book(true))
128+
.expect("has_cover implies a cover URL");
129+
assert!(matches!(cover.kind, LocalOrRemote::Local));
130+
let path = cover.local_path.expect("local cover keeps its path");
131+
assert!(path.ends_with("cover.jpg"));
132+
assert!(!path.exists());
133+
assert!(cover.url.contains("cover.jpg"));
134+
}
135+
}

src-tauri/src/libs/calibre/command.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,9 @@ pub fn clb_cmd_create_authors(
200200
new_authors: Vec<NewAuthor>,
201201
) -> Result<Vec<LibraryAuthor>, String> {
202202
state.with_library(|lib| {
203+
// Freshly created authors have no linked books yet, so an empty
204+
// counts map is exact (from_author defaults missing entries to 0).
205+
let no_book_counts = std::collections::HashMap::new();
203206
let mut created = Vec::new();
204207
for author in &new_authors {
205208
let author_add = libcalibre::AuthorAdd {
@@ -209,7 +212,7 @@ pub fn clb_cmd_create_authors(
209212
};
210213
let author_id = lib.add_author(author_add).map_err(|e| e.to_string())?;
211214
let library_author = lib.get_author(author_id).map_err(|e| e.to_string())?;
212-
created.push(LibraryAuthor::from(&library_author));
215+
created.push(LibraryAuthor::from_author(&library_author, &no_book_counts));
213216
}
214217
Ok(created)
215218
})?

src-tauri/src/libs/calibre/query.rs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -151,8 +151,13 @@ pub fn clb_query_list_all_authors(
151151
) -> Result<Vec<LibraryAuthor>, String> {
152152
state
153153
.with_library(|lib| {
154-
lib.authors()
155-
.map(|author_list| author_list.iter().map(LibraryAuthor::from).collect())
154+
let book_counts = lib.author_book_counts()?;
155+
lib.authors().map(|author_list| {
156+
author_list
157+
.iter()
158+
.map(|author| LibraryAuthor::from_author(author, &book_counts))
159+
.collect()
160+
})
156161
})
157162
.and_then(|result| result.map_err(|e| format!("Failed to list authors: {}", e)))
158163
}

src/bindings.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -304,7 +304,14 @@ path: string; publication_date: string | null;
304304
file_contains_cover: boolean }
305305
export type ImportableBookType = "Epub" | "Pdf" | "Mobi" | "Text"
306306
export type ImportableFile = { path: string }
307-
export type LibraryAuthor = { id: string; name: string; sortable_name: string }
307+
export type LibraryAuthor = { id: string; name: string; sortable_name: string;
308+
/**
309+
* Number of books in the library linked to this author, from
310+
* `Library::author_book_counts` (one GROUP BY pass over
311+
* `books_authors_link`). The Authors page renders this directly
312+
* instead of deriving counts from the whole book list.
313+
*/
314+
book_count: number }
308315
export type LibraryBook = { id: string; uuid: string | null; title: string; author_list: LibraryAuthor[]; tag_list: string[]; sortable_title: string | null; file_list: BookFile[]; cover_image: LocalOrRemoteUrl | null; identifier_list: Identifier[]; description: string | null; is_read: boolean; series: string | null; series_index: number | null }
309316
export type LibraryBookPage = { items: LibraryBook[];
310317
/**

0 commit comments

Comments
 (0)