Skip to main content

cadmus_core/library/db/
mod.rs

1pub mod conversion;
2pub mod models;
3
4use crate::db::Database;
5use crate::db::runtime::RUNTIME;
6use crate::db::types::{FileSize, OptionalUuid7, UnixTimestamp, Uuid7};
7use crate::document::SimpleTocEntry;
8use crate::geom::Point;
9use crate::helpers::Fp;
10use crate::metadata::{
11    CroppingMargins, FileInfo, Info, ReaderInfo, ScrollMode, SortMethod, TextAlign, ZoomMode,
12    alphabetic_author, alphabetic_title, natural_cmp, sorter,
13};
14use crate::settings::FileExtension;
15use anyhow::Error;
16use conversion::{
17    extract_authors, info_to_book_row, reader_info_to_reading_state_row, rows_to_toc_entries,
18};
19use fxhash::{FxHashMap, FxHashSet};
20use models::TocEntryRow;
21use sqlx::sqlite::SqlitePool;
22use std::collections::{BTreeMap, BTreeSet, HashMap};
23use std::path::{Path, PathBuf};
24
25pub use models::{BookHandle, PathUpdate};
26
27/// Gap between adjacent sort ranks assigned by [`Db::compute_sort_keys`].
28///
29/// Ranks are stored as multiples of this value (1 000, 2 000, 3 000, …) so
30/// that a single newly-added book can be placed at the midpoint between its
31/// two neighbours without touching any other row. See [`Db::insert_sort_rank`].
32const SORT_RANK_STRIDE: i64 = 1_000;
33
34/// Computes the rank to assign to a new book being inserted at position `pos`
35/// in a list of existing ranks (which may be `None` for books whose ranks have
36/// not yet been computed).
37///
38/// Returns `None` when the gap between the two neighbours has been fully
39/// exhausted (they differ by ≤ 1), signalling that a full recompute is needed.
40fn midpoint_rank(existing_ranks: &[Option<i64>], pos: usize) -> Option<i64> {
41    let left = if pos == 0 {
42        None
43    } else {
44        existing_ranks.get(pos - 1).copied().flatten()
45    };
46    let right = existing_ranks.get(pos).copied().flatten();
47
48    match (left, right) {
49        (None, None) => Some(SORT_RANK_STRIDE),
50        (None, Some(r)) => {
51            if r <= 1 {
52                None
53            } else {
54                Some(r / 2)
55            }
56        }
57        (Some(l), None) => Some(l + SORT_RANK_STRIDE),
58        (Some(l), Some(r)) => {
59            let mid = (l + r) / 2;
60            if mid <= l { None } else { Some(mid) }
61        }
62    }
63}
64
65/// Lightweight row fetched by [`Db::fetch_title_sort_rows`] for binary search.
66#[derive(sqlx::FromRow)]
67struct TitleSortRow {
68    title: String,
69    language: String,
70    file_path: String,
71    sort_title: Option<i64>,
72}
73
74/// Lightweight row fetched by [`Db::fetch_author_sort_rows`] for binary search.
75#[derive(sqlx::FromRow)]
76struct AuthorSortRow {
77    authors: Option<String>,
78    sort_author: Option<i64>,
79}
80
81/// Lightweight row fetched by [`Db::fetch_filepath_sort_rows`] for binary search.
82#[derive(sqlx::FromRow)]
83struct FilePathSortRow {
84    file_path: String,
85    sort_filepath: Option<i64>,
86}
87
88/// Lightweight row fetched by [`Db::fetch_filename_sort_rows`] for binary search.
89#[derive(sqlx::FromRow)]
90struct FileNameSortRow {
91    file_path: String,
92    sort_filename: Option<i64>,
93}
94
95/// Lightweight row fetched by [`Db::fetch_series_sort_rows`] for binary search.
96#[derive(sqlx::FromRow)]
97struct SeriesSortRow {
98    series: String,
99    number: String,
100    sort_series: Option<i64>,
101}
102
103#[derive(Debug, Clone, sqlx::FromRow)]
104struct StoredBookRow {
105    fingerprint: Fp,
106    title: String,
107    subtitle: String,
108    year: String,
109    language: String,
110    publisher: String,
111    series: String,
112    edition: String,
113    volume: String,
114    number: String,
115    identifier: String,
116    file_path: String,
117    absolute_path: String,
118    file_kind: String,
119    file_size: i64,
120    added_at: UnixTimestamp,
121    opened: Option<UnixTimestamp>,
122    current_page: Option<i64>,
123    pages_count: Option<i64>,
124    finished: Option<i64>,
125    dithered: Option<i64>,
126    zoom_mode: Option<String>,
127    scroll_mode: Option<String>,
128    page_offset_x: Option<i64>,
129    page_offset_y: Option<i64>,
130    rotation: Option<i64>,
131    cropping_margins_json: Option<String>,
132    margin_width: Option<i64>,
133    screen_margin_width: Option<i64>,
134    font_family: Option<String>,
135    font_size: Option<f64>,
136    text_align: Option<String>,
137    line_height: Option<f64>,
138    contrast_exponent: Option<f64>,
139    contrast_gray: Option<f64>,
140    page_names_json: Option<String>,
141    bookmarks_json: Option<String>,
142    annotations_json: Option<String>,
143    authors: Option<String>,
144    categories: Option<String>,
145}
146
147#[derive(Clone)]
148pub struct Db {
149    pool: SqlitePool,
150}
151
152impl Db {
153    pub fn new(database: &Database) -> Self {
154        Self {
155            pool: database.pool().clone(),
156        }
157    }
158
159    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self), fields(path = %path, name = %name)))]
160    pub fn register_library(&self, path: &str, name: &str) -> Result<i64, Error> {
161        tracing::debug!(path = %path, name = %name, "registering library");
162
163        RUNTIME.block_on(async {
164            let now = UnixTimestamp::now();
165
166            let result = sqlx::query!(
167                r#"
168                        INSERT INTO libraries (path, name, created_at)
169                        VALUES (?, ?, ?)
170                        "#,
171                path,
172                name,
173                now
174            )
175            .execute(&self.pool)
176            .await?;
177
178            let library_id = result.last_insert_rowid();
179            tracing::info!(library_id, path = %path, name = %name, "library registered");
180            Ok(library_id)
181        })
182    }
183
184    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self), fields(path = %path)))]
185    pub fn get_library_by_path(&self, path: &str) -> Result<Option<i64>, Error> {
186        tracing::debug!(path = %path, "looking up library by path");
187
188        RUNTIME.block_on(async {
189            let id: Option<Option<i64>> =
190                sqlx::query_scalar!(r#"SELECT id FROM libraries WHERE path = ?"#, path)
191                    .fetch_optional(&self.pool)
192                    .await?;
193
194            Ok(id.flatten())
195        })
196    }
197
198    #[inline]
199    fn parse_zoom_mode(json: Option<&String>) -> Option<ZoomMode> {
200        match json {
201            Some(s) => match serde_json::from_str(s) {
202                Ok(v) => Some(v),
203                Err(e) => {
204                    tracing::warn!(error = %e, "failed to parse zoom_mode JSON field");
205                    None
206                }
207            },
208            None => None,
209        }
210    }
211
212    #[inline]
213    fn parse_scroll_mode(json: Option<&String>) -> Option<ScrollMode> {
214        match json {
215            Some(s) => match serde_json::from_str(s) {
216                Ok(v) => Some(v),
217                Err(e) => {
218                    tracing::warn!(error = %e, "failed to parse scroll_mode JSON field");
219                    None
220                }
221            },
222            None => None,
223        }
224    }
225
226    #[inline]
227    fn parse_text_align(json: Option<&String>) -> Option<TextAlign> {
228        match json {
229            Some(s) => match serde_json::from_str(s) {
230                Ok(v) => Some(v),
231                Err(e) => {
232                    tracing::warn!(error = %e, "failed to parse text_align JSON field");
233                    None
234                }
235            },
236            None => None,
237        }
238    }
239
240    #[inline]
241    fn parse_cropping_margins(json: Option<&String>) -> Option<CroppingMargins> {
242        match json {
243            Some(s) => match serde_json::from_str(s) {
244                Ok(v) => Some(v),
245                Err(e) => {
246                    tracing::warn!(error = %e, "failed to parse cropping_margins JSON field");
247                    None
248                }
249            },
250            None => None,
251        }
252    }
253
254    #[inline]
255    fn parse_page_names(json: Option<&String>) -> BTreeMap<usize, String> {
256        match json {
257            Some(s) => match serde_json::from_str(s) {
258                Ok(v) => v,
259                Err(e) => {
260                    tracing::warn!(error = %e, "failed to parse page_names JSON field");
261                    BTreeMap::default()
262                }
263            },
264            None => BTreeMap::default(),
265        }
266    }
267
268    #[inline]
269    fn parse_bookmarks(json: Option<&String>) -> BTreeSet<usize> {
270        match json {
271            Some(s) => match serde_json::from_str(s) {
272                Ok(v) => v,
273                Err(e) => {
274                    tracing::warn!(error = %e, "failed to parse bookmarks JSON field");
275                    BTreeSet::default()
276                }
277            },
278            None => BTreeSet::default(),
279        }
280    }
281
282    #[inline]
283    fn parse_annotations(json: Option<&String>) -> Vec<crate::metadata::Annotation> {
284        match json {
285            Some(s) => match serde_json::from_str(s) {
286                Ok(v) => v,
287                Err(e) => {
288                    tracing::warn!(error = %e, "failed to parse annotations JSON field");
289                    Vec::new()
290                }
291            },
292            None => Vec::new(),
293        }
294    }
295
296    #[inline]
297    fn parse_page_offset(x: Option<i64>, y: Option<i64>) -> Option<Point> {
298        match (x, y) {
299            (Some(x_val), Some(y_val)) => Some(Point::new(x_val as i32, y_val as i32)),
300            _ => None,
301        }
302    }
303
304    #[inline]
305    fn extract_authors(authors: Option<String>) -> String {
306        authors
307            .map(|s| s.split(',').collect::<Vec<_>>().join(", "))
308            .unwrap_or_default()
309    }
310
311    #[inline]
312    fn extract_categories(categories: Option<String>) -> BTreeSet<String> {
313        categories
314            .unwrap_or_default()
315            .split(',')
316            .filter(|s| !s.is_empty())
317            .map(|s| s.to_string())
318            .collect()
319    }
320
321    #[cfg_attr(feature = "tracing", tracing::instrument(skip(pool)))]
322    async fn fetch_toc_entries_for_book(
323        pool: &SqlitePool,
324        library_id: i64,
325        fingerprint: &str,
326    ) -> Result<Vec<TocEntryRow>, Error> {
327        let rows = sqlx::query_as!(
328            TocEntryRow,
329            r#"
330            SELECT
331                te.book_fingerprint,
332                te.id                as "id: Uuid7",
333                te.parent_id         as "parent_id!: OptionalUuid7",
334                te.position,
335                te.title,
336                te.location_kind,
337                te.location_exact,
338                te.location_uri
339            FROM toc_entries te
340            INNER JOIN library_books lb ON lb.book_fingerprint = te.book_fingerprint
341            WHERE lb.library_id = ? AND te.book_fingerprint = ?
342            ORDER BY te.id ASC
343            "#,
344            library_id,
345            fingerprint,
346        )
347        .fetch_all(pool)
348        .await?;
349
350        Ok(rows)
351    }
352
353    fn stored_book_row_to_info(
354        row: StoredBookRow,
355        toc: Option<Vec<SimpleTocEntry>>,
356    ) -> Result<Info, Error> {
357        let fp = row.fingerprint;
358
359        let mut info = Info {
360            title: row.title,
361            subtitle: row.subtitle,
362            author: Self::extract_authors(row.authors),
363            year: row.year,
364            language: row.language,
365            publisher: row.publisher,
366            series: row.series,
367            edition: row.edition,
368            volume: row.volume,
369            number: row.number,
370            identifier: row.identifier,
371            categories: Self::extract_categories(row.categories),
372            file: FileInfo {
373                path: PathBuf::from(&row.file_path),
374                absolute_path: PathBuf::from(&row.absolute_path),
375                kind: row.file_kind,
376                size: row.file_size as u64,
377                mtime: None,
378            },
379            reader: None,
380            reader_info: None,
381            toc,
382            added: row.added_at.into(),
383            fp: Some(fp),
384        };
385
386        if let Some(opened_ts) = row.opened {
387            let reader_info = ReaderInfo {
388                opened: opened_ts.into(),
389                current_page: row.current_page.unwrap_or(0) as usize,
390                pages_count: row.pages_count.unwrap_or(0) as usize,
391                finished: row.finished.unwrap_or(0) == 1,
392                dithered: row.dithered.unwrap_or(0) == 1,
393                zoom_mode: Self::parse_zoom_mode(row.zoom_mode.as_ref()),
394                scroll_mode: Self::parse_scroll_mode(row.scroll_mode.as_ref()),
395                page_offset: Self::parse_page_offset(row.page_offset_x, row.page_offset_y),
396                rotation: row.rotation.map(|rotation| rotation as i8),
397                cropping_margins: Self::parse_cropping_margins(row.cropping_margins_json.as_ref()),
398                margin_width: row.margin_width.map(|margin| margin as i32),
399                screen_margin_width: row.screen_margin_width.map(|margin| margin as i32),
400                font_family: row.font_family,
401                font_size: row.font_size.map(|size| size as f32),
402                text_align: Self::parse_text_align(row.text_align.as_ref()),
403                line_height: row.line_height.map(|height| height as f32),
404                contrast_exponent: row.contrast_exponent.map(|contrast| contrast as f32),
405                contrast_gray: row.contrast_gray.map(|contrast| contrast as f32),
406                page_names: Self::parse_page_names(row.page_names_json.as_ref()),
407                bookmarks: Self::parse_bookmarks(row.bookmarks_json.as_ref()),
408                annotations: Self::parse_annotations(row.annotations_json.as_ref()),
409            };
410            info.reader = Some(reader_info.clone());
411            info.reader_info = Some(reader_info);
412        }
413
414        Ok(info)
415    }
416
417    #[cfg_attr(feature = "tracing", tracing::instrument(skip(conn, entries), fields(book_fingerprint = %book_fingerprint, parent_id = ?parent_id)))]
418    async fn insert_toc_entries(
419        conn: &mut sqlx::SqliteConnection,
420        book_fingerprint: &str,
421        entries: &[SimpleTocEntry],
422        parent_id: Option<Uuid7>,
423    ) -> Result<(), Error> {
424        for (position, entry) in entries.iter().enumerate() {
425            let (title, location, children) = match entry {
426                SimpleTocEntry::Leaf(t, loc) => (t.as_str(), loc, [].as_slice()),
427                SimpleTocEntry::Container(t, loc, ch) => (t.as_str(), loc, ch.as_slice()),
428            };
429
430            let (location_kind, location_exact, location_uri) =
431                conversion::encode_location(location);
432            let pos = position as i64;
433            let id = Uuid7::now();
434            let parent_id_str = parent_id.as_ref().map(|p| p.to_string());
435
436            sqlx::query!(
437                r#"
438                INSERT INTO toc_entries (id, book_fingerprint, parent_id, position, title, location_kind, location_exact, location_uri)
439                VALUES (?, ?, ?, ?, ?, ?, ?, ?)
440                "#,
441                id,
442                book_fingerprint,
443                parent_id_str,
444                pos,
445                title,
446                location_kind,
447                location_exact,
448                location_uri,
449            )
450            .execute(&mut *conn)
451            .await?;
452
453            if !children.is_empty() {
454                Box::pin(Self::insert_toc_entries(
455                    conn,
456                    book_fingerprint,
457                    children,
458                    Some(id),
459                ))
460                .await?;
461            }
462        }
463
464        Ok(())
465    }
466
467    async fn fetch_all_toc_entries(
468        pool: &SqlitePool,
469        library_id: i64,
470    ) -> Result<HashMap<String, Vec<TocEntryRow>>, Error> {
471        let toc_rows: Vec<TocEntryRow> = sqlx::query_as!(
472            TocEntryRow,
473            r#"
474            SELECT
475                te.book_fingerprint,
476                te.id                as "id: Uuid7",
477                te.parent_id         as "parent_id!: OptionalUuid7",
478                te.position,
479                te.title,
480                te.location_kind,
481                te.location_exact,
482                te.location_uri
483            FROM toc_entries te
484            INNER JOIN library_books lb ON lb.book_fingerprint = te.book_fingerprint
485            WHERE lb.library_id = ?
486            ORDER BY te.book_fingerprint, te.id ASC
487            "#,
488            library_id
489        )
490        .fetch_all(pool)
491        .await?;
492
493        let mut map: HashMap<String, Vec<TocEntryRow>> = HashMap::new();
494
495        for row in toc_rows {
496            map.entry(row.book_fingerprint.clone())
497                .or_default()
498                .push(row);
499        }
500
501        Ok(map)
502    }
503
504    #[cfg_attr(
505        feature = "tracing",
506        tracing::instrument(skip(self), fields(library_id))
507    )]
508    pub fn get_all_books(&self, library_id: i64) -> Result<Vec<Info>, Error> {
509        tracing::debug!(library_id, "fetching all books from database");
510
511        RUNTIME.block_on(async {
512            let book_rows = sqlx::query!(
513                r#"
514                SELECT
515                    fingerprint as "fingerprint: Fp",
516                    title,
517                    subtitle,
518                    year,
519                    language,
520                    publisher,
521                    series,
522                    edition,
523                    volume,
524                    number,
525                    identifier,
526                    file_path,
527                    absolute_path,
528                    file_kind,
529                    file_size,
530                    added_at              as "added_at: UnixTimestamp",
531                    opened                as "opened?: UnixTimestamp",
532                    current_page          as "current_page?: i64",
533                    pages_count           as "pages_count?: i64",
534                    finished              as "finished?: i64",
535                    dithered              as "dithered?: i64",
536                    zoom_mode             as "zoom_mode?: String",
537                    scroll_mode           as "scroll_mode?: String",
538                    page_offset_x         as "page_offset_x?: i64",
539                    page_offset_y         as "page_offset_y?: i64",
540                    rotation              as "rotation?: i64",
541                    cropping_margins_json as "cropping_margins_json?: String",
542                    margin_width          as "margin_width?: i64",
543                    screen_margin_width   as "screen_margin_width?: i64",
544                    font_family           as "font_family?: String",
545                    font_size             as "font_size?: f64",
546                    text_align            as "text_align?: String",
547                    line_height           as "line_height?: f64",
548                    contrast_exponent     as "contrast_exponent?: f64",
549                    contrast_gray         as "contrast_gray?: f64",
550                    page_names_json       as "page_names_json?: String",
551                    bookmarks_json        as "bookmarks_json?: String",
552                    annotations_json      as "annotations_json?: String",
553                    authors               as "authors?: String",
554                    categories            as "categories?: String"
555                FROM library_books_full_info
556                WHERE library_id = ?
557                ORDER BY added_at DESC
558                "#,
559                library_id
560            )
561            .fetch_all(&self.pool)
562            .await?;
563
564            let mut toc_by_fingerprint =
565                Self::fetch_all_toc_entries(&self.pool, library_id).await?;
566
567            let mut result = Vec::new();
568
569            for row in book_rows {
570                let fp = row.fingerprint;
571
572                let toc = toc_by_fingerprint
573                    .remove(&fp.to_string())
574                    .map(|rows| rows_to_toc_entries(&rows))
575                    .transpose()?;
576
577                let mut info = Info {
578                    title: row.title,
579                    subtitle: row.subtitle,
580                    author: Self::extract_authors(row.authors),
581                    year: row.year,
582                    language: row.language,
583                    publisher: row.publisher,
584                    series: row.series,
585                    edition: row.edition,
586                    volume: row.volume,
587                    number: row.number,
588                    identifier: row.identifier,
589                    categories: Self::extract_categories(row.categories),
590                    file: FileInfo {
591                        path: PathBuf::from(&row.file_path),
592                        absolute_path: PathBuf::from(&row.absolute_path),
593                        kind: row.file_kind,
594                        size: row.file_size as u64,
595                        mtime: None,
596                    },
597                    reader: None,
598                    reader_info: None,
599                    toc,
600                    added: row.added_at.into(),
601                    fp: Some(fp),
602                };
603                if let Some(opened_ts) = row.opened {
604                    let reader_info = ReaderInfo {
605                        opened: opened_ts.into(),
606                        current_page: row.current_page.unwrap_or(0) as usize,
607                        pages_count: row.pages_count.unwrap_or(0) as usize,
608                        finished: row.finished.unwrap_or(0) == 1,
609                        dithered: row.dithered.unwrap_or(0) == 1,
610                        zoom_mode: Self::parse_zoom_mode(row.zoom_mode.as_ref()),
611                        scroll_mode: Self::parse_scroll_mode(row.scroll_mode.as_ref()),
612                        page_offset: Self::parse_page_offset(row.page_offset_x, row.page_offset_y),
613                        rotation: row.rotation.map(|r| r as i8),
614                        cropping_margins: Self::parse_cropping_margins(
615                            row.cropping_margins_json.as_ref(),
616                        ),
617                        margin_width: row.margin_width.map(|m| m as i32),
618                        screen_margin_width: row.screen_margin_width.map(|m| m as i32),
619                        font_family: row.font_family.clone(),
620                        font_size: row.font_size.map(|f| f as f32),
621                        text_align: Self::parse_text_align(row.text_align.as_ref()),
622                        line_height: row.line_height.map(|l| l as f32),
623                        contrast_exponent: row.contrast_exponent.map(|c| c as f32),
624                        contrast_gray: row.contrast_gray.map(|c| c as f32),
625                        page_names: Self::parse_page_names(row.page_names_json.as_ref()),
626                        bookmarks: Self::parse_bookmarks(row.bookmarks_json.as_ref()),
627                        annotations: Self::parse_annotations(row.annotations_json.as_ref()),
628                    };
629                    info.reader = Some(reader_info.clone());
630                    info.reader_info = Some(reader_info);
631                }
632
633                result.push(info);
634            }
635
636            tracing::debug!(library_id, count = result.len(), "fetched all books");
637            Ok(result)
638        })
639    }
640
641    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, path), fields(library_id, path = %path.display())))]
642    pub fn get_book_by_path(&self, library_id: i64, path: &Path) -> Result<Option<Info>, Error> {
643        let path = path.to_string_lossy().into_owned();
644
645        RUNTIME.block_on(async {
646            let row = sqlx::query_as!(
647                StoredBookRow,
648                r#"
649                SELECT
650                    fingerprint as "fingerprint: Fp",
651                    title,
652                    subtitle,
653                    year,
654                    language,
655                    publisher,
656                    series,
657                    edition,
658                    volume,
659                    number,
660                    identifier,
661                    file_path,
662                    absolute_path,
663                    file_kind,
664                    file_size,
665                    added_at              as "added_at: UnixTimestamp",
666                    opened                as "opened?: UnixTimestamp",
667                    current_page          as "current_page?: i64",
668                    pages_count           as "pages_count?: i64",
669                    finished              as "finished?: i64",
670                    dithered              as "dithered?: i64",
671                    zoom_mode             as "zoom_mode?: String",
672                    scroll_mode           as "scroll_mode?: String",
673                    page_offset_x         as "page_offset_x?: i64",
674                    page_offset_y         as "page_offset_y?: i64",
675                    rotation              as "rotation?: i64",
676                    cropping_margins_json as "cropping_margins_json?: String",
677                    margin_width          as "margin_width?: i64",
678                    screen_margin_width   as "screen_margin_width?: i64",
679                    font_family           as "font_family?: String",
680                    font_size             as "font_size?: f64",
681                    text_align            as "text_align?: String",
682                    line_height           as "line_height?: f64",
683                    contrast_exponent     as "contrast_exponent?: f64",
684                    contrast_gray         as "contrast_gray?: f64",
685                    page_names_json       as "page_names_json?: String",
686                    bookmarks_json        as "bookmarks_json?: String",
687                    annotations_json      as "annotations_json?: String",
688                    authors               as "authors?: String",
689                    categories            as "categories?: String"
690                FROM library_books_full_info
691                WHERE library_id = ? AND file_path = ?
692                LIMIT 1
693                "#,
694                library_id,
695                path,
696            )
697            .fetch_optional(&self.pool)
698            .await?;
699
700            let Some(row) = row else {
701                return Ok(None);
702            };
703
704            let toc_rows = Self::fetch_toc_entries_for_book(
705                &self.pool,
706                library_id,
707                &row.fingerprint.to_string(),
708            )
709            .await?;
710            let toc = (!toc_rows.is_empty())
711                .then(|| rows_to_toc_entries(&toc_rows))
712                .transpose()?;
713
714            Self::stored_book_row_to_info(row, toc).map(Some)
715        })
716    }
717
718    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self), fields(library_id, fp = %fp)))]
719    pub fn get_book_by_fingerprint(&self, library_id: i64, fp: Fp) -> Result<Option<Info>, Error> {
720        let fingerprint = fp.to_string();
721
722        RUNTIME.block_on(async {
723            let row = sqlx::query_as!(
724                StoredBookRow,
725                r#"
726                SELECT
727                    fingerprint as "fingerprint: Fp",
728                    title,
729                    subtitle,
730                    year,
731                    language,
732                    publisher,
733                    series,
734                    edition,
735                    volume,
736                    number,
737                    identifier,
738                    file_path,
739                    absolute_path,
740                    file_kind,
741                    file_size,
742                    added_at              as "added_at: UnixTimestamp",
743                    opened                as "opened?: UnixTimestamp",
744                    current_page          as "current_page?: i64",
745                    pages_count           as "pages_count?: i64",
746                    finished              as "finished?: i64",
747                    dithered              as "dithered?: i64",
748                    zoom_mode             as "zoom_mode?: String",
749                    scroll_mode           as "scroll_mode?: String",
750                    page_offset_x         as "page_offset_x?: i64",
751                    page_offset_y         as "page_offset_y?: i64",
752                    rotation              as "rotation?: i64",
753                    cropping_margins_json as "cropping_margins_json?: String",
754                    margin_width          as "margin_width?: i64",
755                    screen_margin_width   as "screen_margin_width?: i64",
756                    font_family           as "font_family?: String",
757                    font_size             as "font_size?: f64",
758                    text_align            as "text_align?: String",
759                    line_height           as "line_height?: f64",
760                    contrast_exponent     as "contrast_exponent?: f64",
761                    contrast_gray         as "contrast_gray?: f64",
762                    page_names_json       as "page_names_json?: String",
763                    bookmarks_json        as "bookmarks_json?: String",
764                    annotations_json      as "annotations_json?: String",
765                    authors               as "authors?: String",
766                    categories            as "categories?: String"
767                FROM library_books_full_info
768                WHERE library_id = ? AND fingerprint = ?
769                LIMIT 1
770                "#,
771                library_id,
772                fingerprint,
773            )
774            .fetch_optional(&self.pool)
775            .await?;
776
777            let Some(row) = row else {
778                return Ok(None);
779            };
780
781            let toc_rows = Self::fetch_toc_entries_for_book(
782                &self.pool,
783                library_id,
784                &row.fingerprint.to_string(),
785            )
786            .await?;
787            let toc = (!toc_rows.is_empty())
788                .then(|| rows_to_toc_entries(&toc_rows))
789                .transpose()?;
790
791            Self::stored_book_row_to_info(row, toc).map(Some)
792        })
793    }
794
795    /// Fetches complete `Info` for multiple fingerprints in a single library using one
796    /// pooled connection. Missing fingerprints are silently skipped.
797    ///
798    /// Used by `import()` to retrieve book metadata (title, authors, reading state, etc.)
799    /// for all fingerprint relocations in one batch, before re-inserting under new FPs.
800    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, fps), fields(library_id, count = fps.len())))]
801    pub fn batch_get_books_by_fingerprints(
802        &self,
803        library_id: i64,
804        fps: &[Fp],
805    ) -> Result<FxHashMap<Fp, Info>, Error> {
806        if fps.is_empty() {
807            return Ok(FxHashMap::default());
808        }
809
810        tracing::debug!(
811            library_id,
812            count = fps.len(),
813            "batch fetching books by fingerprints"
814        );
815
816        RUNTIME.block_on(async {
817            let mut result = FxHashMap::default();
818            let mut conn = self.pool.acquire().await?;
819
820            for fp in fps {
821                let fingerprint = fp.to_string();
822
823                let row = sqlx::query_as!(
824                    StoredBookRow,
825                    r#"
826                    SELECT
827                        fingerprint as "fingerprint: Fp",
828                        title,
829                        subtitle,
830                        year,
831                        language,
832                        publisher,
833                        series,
834                        edition,
835                        volume,
836                        number,
837                        identifier,
838                        file_path,
839                        absolute_path,
840                        file_kind,
841                        file_size,
842                        added_at              as "added_at: UnixTimestamp",
843                        opened                as "opened?: UnixTimestamp",
844                        current_page          as "current_page?: i64",
845                        pages_count           as "pages_count?: i64",
846                        finished              as "finished?: i64",
847                        dithered              as "dithered?: i64",
848                        zoom_mode             as "zoom_mode?: String",
849                        scroll_mode           as "scroll_mode?: String",
850                        page_offset_x         as "page_offset_x?: i64",
851                        page_offset_y         as "page_offset_y?: i64",
852                        rotation              as "rotation?: i64",
853                        cropping_margins_json as "cropping_margins_json?: String",
854                        margin_width          as "margin_width?: i64",
855                        screen_margin_width   as "screen_margin_width?: i64",
856                        font_family           as "font_family?: String",
857                        font_size             as "font_size?: f64",
858                        text_align            as "text_align?: String",
859                        line_height           as "line_height?: f64",
860                        contrast_exponent     as "contrast_exponent?: f64",
861                        contrast_gray         as "contrast_gray?: f64",
862                        page_names_json       as "page_names_json?: String",
863                        bookmarks_json        as "bookmarks_json?: String",
864                        annotations_json      as "annotations_json?: String",
865                        authors               as "authors?: String",
866                        categories            as "categories?: String"
867                    FROM library_books_full_info
868                    WHERE library_id = ? AND fingerprint = ?
869                    LIMIT 1
870                    "#,
871                    library_id,
872                    fingerprint,
873                )
874                .fetch_optional(&mut *conn)
875                .await?;
876
877                let Some(row) = row else {
878                    continue;
879                };
880
881                let toc_rows = Self::fetch_toc_entries_for_book(
882                    &self.pool,
883                    library_id,
884                    &row.fingerprint.to_string(),
885                )
886                .await?;
887                let toc = (!toc_rows.is_empty())
888                    .then(|| rows_to_toc_entries(&toc_rows))
889                    .transpose()?;
890
891                if let Ok(info) = Self::stored_book_row_to_info(row, toc) {
892                    result.insert(*fp, info);
893                }
894            }
895
896            Ok(result)
897        })
898    }
899
900    #[cfg_attr(
901        feature = "tracing",
902        tracing::instrument(skip(self), fields(library_id))
903    )]
904    pub fn count_books(&self, library_id: i64) -> Result<usize, Error> {
905        RUNTIME.block_on(async {
906            let count: i64 = sqlx::query_scalar!(
907                r#"SELECT COUNT(*) AS "count!: i64" FROM library_books WHERE library_id = ?"#,
908                library_id,
909            )
910            .fetch_one(&self.pool)
911            .await?;
912
913            Ok(count as usize)
914        })
915    }
916
917    #[cfg_attr(
918        feature = "tracing",
919        tracing::instrument(skip(self, prefix), fields(library_id))
920    )]
921    pub fn list_books_under_prefix(
922        &self,
923        library_id: i64,
924        prefix: &Path,
925    ) -> Result<Vec<Info>, Error> {
926        let prefix =
927            (!prefix.as_os_str().is_empty()).then(|| prefix.to_string_lossy().into_owned());
928
929        RUNTIME.block_on(async {
930            let rows: Vec<StoredBookRow> = sqlx::query_as!(
931                StoredBookRow,
932                r#"
933                SELECT
934                    fingerprint as "fingerprint: Fp",
935                    title,
936                    subtitle,
937                    year,
938                    language,
939                    publisher,
940                    series,
941                    edition,
942                    volume,
943                    number,
944                    identifier,
945                    file_path,
946                    absolute_path,
947                    file_kind,
948                    file_size,
949                    added_at              as "added_at: UnixTimestamp",
950                    opened                as "opened?: UnixTimestamp",
951                    current_page          as "current_page?: i64",
952                    pages_count           as "pages_count?: i64",
953                    finished              as "finished?: i64",
954                    dithered              as "dithered?: i64",
955                    zoom_mode             as "zoom_mode?: String",
956                    scroll_mode           as "scroll_mode?: String",
957                    page_offset_x         as "page_offset_x?: i64",
958                    page_offset_y         as "page_offset_y?: i64",
959                    rotation              as "rotation?: i64",
960                    cropping_margins_json as "cropping_margins_json?: String",
961                    margin_width          as "margin_width?: i64",
962                    screen_margin_width   as "screen_margin_width?: i64",
963                    font_family           as "font_family?: String",
964                    font_size             as "font_size?: f64",
965                    text_align            as "text_align?: String",
966                    line_height           as "line_height?: f64",
967                    contrast_exponent     as "contrast_exponent?: f64",
968                    contrast_gray         as "contrast_gray?: f64",
969                    page_names_json       as "page_names_json?: String",
970                    bookmarks_json        as "bookmarks_json?: String",
971                    annotations_json      as "annotations_json?: String",
972                    authors               as "authors?: String",
973                    categories            as "categories?: String"
974                FROM library_books_full_info
975                WHERE library_id = ?1
976                  AND (?2 IS NULL OR file_path = ?2 OR file_path LIKE (?2 || '/%'))
977                "#,
978                library_id,
979                prefix,
980            )
981            .fetch_all(&self.pool)
982            .await?;
983
984            rows.into_iter()
985                .map(|row| Self::stored_book_row_to_info(row, None))
986                .collect()
987        })
988    }
989
990    pub fn most_recently_opened_reading_book(
991        &self,
992        library_id: i64,
993    ) -> Result<Option<Info>, Error> {
994        RUNTIME.block_on(async {
995            let row: Option<StoredBookRow> = sqlx::query_as!(
996                StoredBookRow,
997                r#"
998                SELECT
999                    fingerprint as "fingerprint: Fp",
1000                    title,
1001                    subtitle,
1002                    year,
1003                    language,
1004                    publisher,
1005                    series,
1006                    edition,
1007                    volume,
1008                    number,
1009                    identifier,
1010                    file_path,
1011                    absolute_path,
1012                    file_kind,
1013                    file_size,
1014                    added_at              as "added_at: UnixTimestamp",
1015                    opened                as "opened?: UnixTimestamp",
1016                    current_page          as "current_page?: i64",
1017                    pages_count           as "pages_count?: i64",
1018                    finished              as "finished?: i64",
1019                    dithered              as "dithered?: i64",
1020                    zoom_mode             as "zoom_mode?: String",
1021                    scroll_mode           as "scroll_mode?: String",
1022                    page_offset_x         as "page_offset_x?: i64",
1023                    page_offset_y         as "page_offset_y?: i64",
1024                    rotation              as "rotation?: i64",
1025                    cropping_margins_json as "cropping_margins_json?: String",
1026                    margin_width          as "margin_width?: i64",
1027                    screen_margin_width   as "screen_margin_width?: i64",
1028                    font_family           as "font_family?: String",
1029                    font_size             as "font_size?: f64",
1030                    text_align            as "text_align?: String",
1031                    line_height           as "line_height?: f64",
1032                    contrast_exponent     as "contrast_exponent?: f64",
1033                    contrast_gray         as "contrast_gray?: f64",
1034                    page_names_json       as "page_names_json?: String",
1035                    bookmarks_json        as "bookmarks_json?: String",
1036                    annotations_json      as "annotations_json?: String",
1037                    authors               as "authors?: String",
1038                    categories            as "categories?: String"
1039                FROM library_books_full_info
1040                WHERE library_id = ?1
1041                  AND finished = 0
1042                  AND opened IS NOT NULL
1043                ORDER BY opened DESC
1044                LIMIT 1
1045                "#,
1046                library_id,
1047            )
1048            .fetch_optional(&self.pool)
1049            .await?;
1050
1051            row.map(|r| Self::stored_book_row_to_info(r, None))
1052                .transpose()
1053        })
1054    }
1055
1056    /// Recomputes sort ranks for all books in a library and writes them to the
1057    /// five pre-computed sort columns (`sort_title`, `sort_author`,
1058    /// `sort_filepath`, `sort_filename`, `sort_series`).
1059    ///
1060    /// # Sparse rank scheme
1061    ///
1062    /// Ranks are stored as **multiples of 1000** rather than consecutive
1063    /// integers (1 → 1 000, 2 → 2 000, …). The gaps allow a single newly
1064    /// added book to be inserted cheaply via [`Self::insert_sort_rank`]:
1065    /// instead of shifting every book above the insertion point, the new book
1066    /// is assigned the midpoint between its two neighbours — a single UPDATE.
1067    ///
1068    /// A full recompute is only needed after bulk changes (i.e. after
1069    /// `import()`). It also restores uniform gaps whenever they have been
1070    /// partially exhausted by many consecutive single-book insertions.
1071    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
1072    pub fn compute_sort_keys(&self, library_id: i64) -> Result<(), Error> {
1073        let books = self.get_all_books(library_id)?;
1074        if books.is_empty() {
1075            return Ok(());
1076        }
1077
1078        let methods: &[(SortMethod, &str)] = &[
1079            (SortMethod::Title, "sort_title"),
1080            (SortMethod::Author, "sort_author"),
1081            (SortMethod::FilePath, "sort_filepath"),
1082            (SortMethod::FileName, "sort_filename"),
1083            (SortMethod::Series, "sort_series"),
1084        ];
1085
1086        RUNTIME.block_on(async {
1087            let mut tx = self.pool.begin().await?;
1088
1089            for (method, col) in methods {
1090                let mut sorted = books.clone();
1091                sorted.sort_by(sorter(*method));
1092
1093                let sql = format!(
1094                    "UPDATE library_books SET {col} = ? WHERE library_id = ? AND book_fingerprint = ?"
1095                );
1096                for (rank, info) in sorted.iter().enumerate() {
1097                    let fp = info.fp.map(|f| f.to_string()).unwrap_or_default();
1098                    // Multiply by SORT_RANK_STRIDE to leave gaps for cheap
1099                    // single-book insertions via insert_sort_rank.
1100                    let rank = (rank as i64 + 1) * SORT_RANK_STRIDE;
1101                    sqlx::query(&sql)
1102                        .bind(rank)
1103                        .bind(library_id)
1104                        .bind(&fp)
1105                        .execute(&mut *tx)
1106                        .await?;
1107                }
1108            }
1109
1110            tx.commit().await?;
1111            Ok(())
1112        })
1113    }
1114
1115    /// Inserts sort ranks for a single newly-added book without recomputing
1116    /// ranks for the entire library.
1117    ///
1118    /// # How it works
1119    ///
1120    /// Because [`Self::compute_sort_keys`] stores ranks as multiples of
1121    /// [`SORT_RANK_STRIDE`] (1 000), there is always a gap between adjacent
1122    /// books. For each sort column this method:
1123    ///
1124    /// 1. Fetches only the two lightweight fields needed to compare the new
1125    ///    book's sort key — e.g. `(book_fingerprint, title, sort_title)` for
1126    ///    the title column — ordered by the existing rank. No full `Info` load
1127    ///    is required.
1128    /// 2. Binary-searches the sorted list to find the insertion position using
1129    ///    the same comparator as `sorter(method)`.
1130    /// 3. Assigns the midpoint between the two neighbouring ranks to the new
1131    ///    book (e.g. between rank 3 000 and 4 000 → 3 500).
1132    ///
1133    /// If any column has exhausted its gaps (two neighbours whose ranks differ
1134    /// by at most 1), it falls back to a full [`Self::compute_sort_keys`]
1135    /// recompute to restore uniform gaps for that library.
1136    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, info)))]
1137    pub fn insert_sort_rank(&self, library_id: i64, fp: Fp, info: &Info) -> Result<(), Error> {
1138        let fp_str = fp.to_string();
1139        let needs_full_recompute = self.try_insert_sort_rank(library_id, &fp_str, info)?;
1140
1141        if needs_full_recompute {
1142            tracing::debug!(
1143                library_id,
1144                "sort rank gaps exhausted, falling back to full recompute"
1145            );
1146            self.compute_sort_keys(library_id)?;
1147        }
1148
1149        Ok(())
1150    }
1151
1152    /// Attempts to insert sort ranks for a single book by midpoint assignment.
1153    ///
1154    /// Returns `true` if any column has gaps too small to split (i.e. a full
1155    /// recompute is needed), `false` if all ranks were assigned successfully.
1156    fn try_insert_sort_rank(
1157        &self,
1158        library_id: i64,
1159        fp_str: &str,
1160        info: &Info,
1161    ) -> Result<bool, Error> {
1162        let title_rank = self.resolve_title_rank(library_id, fp_str, info)?;
1163        let author_rank = self.resolve_author_rank(library_id, fp_str, info)?;
1164        let filepath_rank = self.resolve_filepath_rank(library_id, fp_str, info)?;
1165        let filename_rank = self.resolve_filename_rank(library_id, fp_str, info)?;
1166        let series_rank = self.resolve_series_rank(library_id, fp_str, info)?;
1167
1168        if [
1169            title_rank,
1170            author_rank,
1171            filepath_rank,
1172            filename_rank,
1173            series_rank,
1174        ]
1175        .iter()
1176        .any(|r| r.is_none())
1177        {
1178            return Ok(true);
1179        }
1180
1181        RUNTIME.block_on(async {
1182            let mut tx = self.pool.begin().await?;
1183
1184            sqlx::query!(
1185                "UPDATE library_books SET sort_title = ? WHERE library_id = ? AND book_fingerprint = ?",
1186                title_rank, library_id, fp_str
1187            )
1188            .execute(&mut *tx)
1189            .await?;
1190
1191            sqlx::query!(
1192                "UPDATE library_books SET sort_author = ? WHERE library_id = ? AND book_fingerprint = ?",
1193                author_rank, library_id, fp_str
1194            )
1195            .execute(&mut *tx)
1196            .await?;
1197
1198            sqlx::query!(
1199                "UPDATE library_books SET sort_filepath = ? WHERE library_id = ? AND book_fingerprint = ?",
1200                filepath_rank, library_id, fp_str
1201            )
1202            .execute(&mut *tx)
1203            .await?;
1204
1205            sqlx::query!(
1206                "UPDATE library_books SET sort_filename = ? WHERE library_id = ? AND book_fingerprint = ?",
1207                filename_rank, library_id, fp_str
1208            )
1209            .execute(&mut *tx)
1210            .await?;
1211
1212            sqlx::query!(
1213                "UPDATE library_books SET sort_series = ? WHERE library_id = ? AND book_fingerprint = ?",
1214                series_rank, library_id, fp_str
1215            )
1216            .execute(&mut *tx)
1217            .await?;
1218
1219            tx.commit().await?;
1220            Ok(false)
1221        })
1222    }
1223
1224    fn resolve_title_rank(
1225        &self,
1226        library_id: i64,
1227        fp_str: &str,
1228        info: &Info,
1229    ) -> Result<Option<i64>, Error> {
1230        let key = {
1231            let t = info.alphabetic_title();
1232            if t.is_empty() {
1233                info.file_stem()
1234            } else {
1235                t.to_string()
1236            }
1237        };
1238        let rows = self.fetch_title_sort_rows(library_id, fp_str)?;
1239        let pos = rows.partition_point(|row| {
1240            let row_key = {
1241                let t = alphabetic_title(&row.title, &row.language);
1242                if t.is_empty() {
1243                    Path::new(&row.file_path)
1244                        .file_stem()
1245                        .map(|s| s.to_string_lossy().into_owned())
1246                        .unwrap_or_default()
1247                } else {
1248                    t.to_string()
1249                }
1250            };
1251            matches!(natural_cmp(&row_key, &key), std::cmp::Ordering::Less)
1252        });
1253        Ok(midpoint_rank(
1254            &rows.iter().map(|r| r.sort_title).collect::<Vec<_>>(),
1255            pos,
1256        ))
1257    }
1258
1259    fn resolve_author_rank(
1260        &self,
1261        library_id: i64,
1262        fp_str: &str,
1263        info: &Info,
1264    ) -> Result<Option<i64>, Error> {
1265        let key = info.alphabetic_author().to_string();
1266        let rows = self.fetch_author_sort_rows(library_id, fp_str)?;
1267        let pos = rows.partition_point(|row| {
1268            alphabetic_author(row.authors.as_deref().unwrap_or_default()) < key.as_str()
1269        });
1270        Ok(midpoint_rank(
1271            &rows.iter().map(|r| r.sort_author).collect::<Vec<_>>(),
1272            pos,
1273        ))
1274    }
1275
1276    fn resolve_filepath_rank(
1277        &self,
1278        library_id: i64,
1279        fp_str: &str,
1280        info: &Info,
1281    ) -> Result<Option<i64>, Error> {
1282        let key = info.file.path.to_string_lossy().into_owned();
1283        let rows = self.fetch_filepath_sort_rows(library_id, fp_str)?;
1284        let pos = rows.partition_point(|row| {
1285            matches!(natural_cmp(&row.file_path, &key), std::cmp::Ordering::Less)
1286        });
1287        Ok(midpoint_rank(
1288            &rows.iter().map(|r| r.sort_filepath).collect::<Vec<_>>(),
1289            pos,
1290        ))
1291    }
1292
1293    fn resolve_filename_rank(
1294        &self,
1295        library_id: i64,
1296        fp_str: &str,
1297        info: &Info,
1298    ) -> Result<Option<i64>, Error> {
1299        let key = info
1300            .file
1301            .path
1302            .file_name()
1303            .map(|n| n.to_string_lossy().into_owned())
1304            .unwrap_or_default();
1305        let rows = self.fetch_filename_sort_rows(library_id, fp_str)?;
1306        let pos = rows.partition_point(|row| {
1307            let row_name = Path::new(&row.file_path)
1308                .file_name()
1309                .map(|n| n.to_string_lossy().into_owned())
1310                .unwrap_or_default();
1311            matches!(natural_cmp(&row_name, &key), std::cmp::Ordering::Less)
1312        });
1313        Ok(midpoint_rank(
1314            &rows.iter().map(|r| r.sort_filename).collect::<Vec<_>>(),
1315            pos,
1316        ))
1317    }
1318
1319    fn resolve_series_rank(
1320        &self,
1321        library_id: i64,
1322        fp_str: &str,
1323        info: &Info,
1324    ) -> Result<Option<i64>, Error> {
1325        let series_key = &info.series;
1326        let number_key = &info.number;
1327        let rows = self.fetch_series_sort_rows(library_id, fp_str)?;
1328        let pos = rows.partition_point(|row| {
1329            row.series.cmp(series_key).then_with(|| {
1330                row.number
1331                    .parse::<usize>()
1332                    .ok()
1333                    .zip(number_key.parse::<usize>().ok())
1334                    .map_or_else(|| row.number.cmp(number_key), |(a, b)| a.cmp(&b))
1335            }) == std::cmp::Ordering::Less
1336        });
1337        Ok(midpoint_rank(
1338            &rows.iter().map(|r| r.sort_series).collect::<Vec<_>>(),
1339            pos,
1340        ))
1341    }
1342
1343    fn fetch_title_sort_rows(
1344        &self,
1345        library_id: i64,
1346        fp_str: &str,
1347    ) -> Result<Vec<TitleSortRow>, Error> {
1348        RUNTIME.block_on(async {
1349            sqlx::query_as!(
1350                TitleSortRow,
1351                r#"
1352                SELECT title, language, file_path, sort_title as "sort_title?: i64"
1353                FROM library_books_full_info
1354                WHERE library_id = ? AND fingerprint != ?
1355                ORDER BY sort_title ASC NULLS LAST
1356                "#,
1357                library_id,
1358                fp_str,
1359            )
1360            .fetch_all(&self.pool)
1361            .await
1362            .map_err(Into::into)
1363        })
1364    }
1365
1366    fn fetch_author_sort_rows(
1367        &self,
1368        library_id: i64,
1369        fp_str: &str,
1370    ) -> Result<Vec<AuthorSortRow>, Error> {
1371        RUNTIME.block_on(async {
1372            sqlx::query_as!(
1373                AuthorSortRow,
1374                r#"
1375                SELECT authors as "authors?: String", sort_author as "sort_author?: i64"
1376                FROM library_books_full_info
1377                WHERE library_id = ? AND fingerprint != ?
1378                ORDER BY sort_author ASC NULLS LAST
1379                "#,
1380                library_id,
1381                fp_str,
1382            )
1383            .fetch_all(&self.pool)
1384            .await
1385            .map_err(Into::into)
1386        })
1387    }
1388
1389    fn fetch_filepath_sort_rows(
1390        &self,
1391        library_id: i64,
1392        fp_str: &str,
1393    ) -> Result<Vec<FilePathSortRow>, Error> {
1394        RUNTIME.block_on(async {
1395            sqlx::query_as!(
1396                FilePathSortRow,
1397                r#"
1398                SELECT file_path, sort_filepath as "sort_filepath?: i64"
1399                FROM library_books_full_info
1400                WHERE library_id = ? AND fingerprint != ?
1401                ORDER BY sort_filepath ASC NULLS LAST
1402                "#,
1403                library_id,
1404                fp_str,
1405            )
1406            .fetch_all(&self.pool)
1407            .await
1408            .map_err(Into::into)
1409        })
1410    }
1411
1412    fn fetch_filename_sort_rows(
1413        &self,
1414        library_id: i64,
1415        fp_str: &str,
1416    ) -> Result<Vec<FileNameSortRow>, Error> {
1417        RUNTIME.block_on(async {
1418            sqlx::query_as!(
1419                FileNameSortRow,
1420                r#"
1421                SELECT file_path, sort_filename as "sort_filename?: i64"
1422                FROM library_books_full_info
1423                WHERE library_id = ? AND fingerprint != ?
1424                ORDER BY sort_filename ASC NULLS LAST
1425                "#,
1426                library_id,
1427                fp_str,
1428            )
1429            .fetch_all(&self.pool)
1430            .await
1431            .map_err(Into::into)
1432        })
1433    }
1434
1435    fn fetch_series_sort_rows(
1436        &self,
1437        library_id: i64,
1438        fp_str: &str,
1439    ) -> Result<Vec<SeriesSortRow>, Error> {
1440        RUNTIME.block_on(async {
1441            sqlx::query_as!(
1442                SeriesSortRow,
1443                r#"
1444                SELECT series, number, sort_series as "sort_series?: i64"
1445                FROM library_books_full_info
1446                WHERE library_id = ? AND fingerprint != ?
1447                ORDER BY sort_series ASC NULLS LAST
1448                "#,
1449                library_id,
1450                fp_str,
1451            )
1452            .fetch_all(&self.pool)
1453            .await
1454            .map_err(Into::into)
1455        })
1456    }
1457
1458    /// Returns a page of books under `prefix`, sorted by `sort_method`, along
1459    /// with the total number of matching books.
1460    ///
1461    /// Uses untyped `sqlx::query_as` so the `ORDER BY` column can be selected
1462    /// dynamically.
1463    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
1464    pub fn page_books(
1465        &self,
1466        library_id: i64,
1467        prefix: &Path,
1468        sort_method: SortMethod,
1469        reverse: bool,
1470        limit: i64,
1471        offset: i64,
1472    ) -> Result<(Vec<Info>, i64), Error> {
1473        let prefix_str =
1474            (!prefix.as_os_str().is_empty()).then(|| prefix.to_string_lossy().into_owned());
1475
1476        let dir = if reverse { "DESC" } else { "ASC" };
1477        let order_expr = match sort_method {
1478            SortMethod::Title => format!("sort_title {dir}"),
1479            SortMethod::Author => format!("sort_author {dir}"),
1480            SortMethod::FilePath => format!("sort_filepath {dir}"),
1481            SortMethod::FileName => format!("sort_filename {dir}"),
1482            SortMethod::Series => format!("sort_series {dir}"),
1483            // Status: Finished(0) < New(1) < Reading(2), tiebreak by most-recently used.
1484            // The COALESCE falls back to added_at for New books that have no opened timestamp.
1485            SortMethod::Status => format!(
1486                "CASE WHEN finished = 1 THEN 0 WHEN finished = 0 THEN 2 ELSE 1 END {dir}, \
1487                 COALESCE(opened, added_at) {dir}"
1488            ),
1489            // Progress: Finished(0) < New(1) < Reading(progress fraction 0→1).
1490            SortMethod::Progress => format!(
1491                "CASE WHEN finished = 1 THEN 0 WHEN finished IS NULL THEN 1 ELSE 2 END {dir}, \
1492                 CASE WHEN finished = 0 \
1493                      THEN CAST(current_page AS REAL) / CAST(NULLIF(pages_count, 0) AS REAL) \
1494                      ELSE NULL END {dir}"
1495            ),
1496            SortMethod::Opened => format!("opened {dir}"),
1497            SortMethod::Added => format!("added_at {dir}"),
1498            SortMethod::Year => format!("year {dir}"),
1499            SortMethod::Size => format!("file_size {dir}"),
1500            SortMethod::Kind => format!("file_kind {dir}"),
1501            SortMethod::Pages => format!("pages_count {dir}"),
1502        };
1503
1504        let data_sql = format!(
1505            r#"
1506            SELECT
1507                fingerprint,
1508                title,
1509                subtitle,
1510                year,
1511                language,
1512                publisher,
1513                series,
1514                edition,
1515                volume,
1516                number,
1517                identifier,
1518                file_path,
1519                absolute_path,
1520                file_kind,
1521                file_size,
1522                added_at,
1523                opened,
1524                current_page,
1525                pages_count,
1526                finished,
1527                dithered,
1528                zoom_mode,
1529                scroll_mode,
1530                page_offset_x,
1531                page_offset_y,
1532                rotation,
1533                cropping_margins_json,
1534                margin_width,
1535                screen_margin_width,
1536                font_family,
1537                font_size,
1538                text_align,
1539                line_height,
1540                contrast_exponent,
1541                contrast_gray,
1542                page_names_json,
1543                bookmarks_json,
1544                annotations_json,
1545                authors,
1546                categories
1547            FROM library_books_full_info
1548            WHERE library_id = ?
1549              AND (? IS NULL OR file_path = ? OR file_path LIKE (? || '/%'))
1550            ORDER BY {order_expr}
1551            LIMIT ? OFFSET ?
1552            "#
1553        );
1554
1555        RUNTIME.block_on(async {
1556            let total: i64 = sqlx::query_scalar!(
1557                r#"
1558                SELECT COUNT(*)
1559                FROM library_books_full_info
1560                WHERE library_id = ?
1561                  AND (? IS NULL OR file_path = ? OR file_path LIKE (? || '/%'))
1562                "#,
1563                library_id,
1564                prefix_str,
1565                prefix_str,
1566                prefix_str,
1567            )
1568            .fetch_one(&self.pool)
1569            .await?;
1570
1571            let rows: Vec<StoredBookRow> = sqlx::query_as(&data_sql)
1572                .bind(library_id)
1573                .bind(&prefix_str)
1574                .bind(&prefix_str)
1575                .bind(&prefix_str)
1576                .bind(limit)
1577                .bind(offset)
1578                .fetch_all(&self.pool)
1579                .await?;
1580
1581            let books: Result<Vec<Info>, Error> = rows
1582                .into_iter()
1583                .map(|row| Self::stored_book_row_to_info(row, None))
1584                .collect();
1585
1586            Ok((books?, total))
1587        })
1588    }
1589
1590    #[cfg_attr(
1591        feature = "tracing",
1592        tracing::instrument(skip(self, prefix), fields(library_id))
1593    )]
1594    pub fn list_directories_under_prefix(
1595        &self,
1596        library_id: i64,
1597        prefix: &Path,
1598    ) -> Result<BTreeSet<PathBuf>, Error> {
1599        let prefix =
1600            (!prefix.as_os_str().is_empty()).then(|| prefix.to_string_lossy().into_owned());
1601
1602        RUNTIME.block_on(async {
1603            let children: Vec<String> = match prefix.as_deref() {
1604                Some(prefix) => {
1605                    sqlx::query_scalar!(
1606                        r#"
1607                        SELECT DISTINCT
1608                            substr(
1609                                substr(lb.file_path, length(?2) + 2),
1610                                1,
1611                                instr(substr(lb.file_path, length(?2) + 2), '/') - 1
1612                            ) AS "child!: String"
1613                        FROM library_books lb
1614                        WHERE lb.library_id = ?1
1615                          AND lb.file_path LIKE (?2 || '/%/%')
1616                        "#,
1617                        library_id,
1618                        prefix,
1619                    )
1620                    .fetch_all(&self.pool)
1621                    .await?
1622                }
1623                None => {
1624                    sqlx::query_scalar!(
1625                        r#"
1626                        SELECT DISTINCT
1627                            substr(lb.file_path, 1, instr(lb.file_path, '/') - 1) AS "child!: String"
1628                        FROM library_books lb
1629                        WHERE lb.library_id = ?1
1630                          AND lb.file_path LIKE '%/%'
1631                        "#,
1632                        library_id,
1633                    )
1634                    .fetch_all(&self.pool)
1635                    .await?
1636                }
1637            };
1638
1639            Ok(children
1640                .into_iter()
1641                .map(|child| PathBuf::from(&child))
1642                .collect())
1643        })
1644    }
1645
1646    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, info), fields(fp = %fp, library_id)))]
1647    pub fn insert_book(&self, library_id: i64, fp: Fp, info: &Info) -> Result<(), Error> {
1648        tracing::debug!(fp = %fp, library_id, "inserting book into database");
1649        let fp_str = fp.to_string();
1650
1651        RUNTIME.block_on(async {
1652            let mut tx = self.pool.begin().await?;
1653
1654            let book_row = info_to_book_row(fp, info);
1655
1656            sqlx::query!(
1657                r#"
1658                INSERT OR IGNORE INTO books (
1659                    fingerprint, title, subtitle, year, language, publisher,
1660                    series, edition, volume, number, identifier,
1661                    file_kind, file_size, added_at
1662                ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1663                "#,
1664                book_row.fingerprint,
1665                book_row.title,
1666                book_row.subtitle,
1667                book_row.year,
1668                book_row.language,
1669                book_row.publisher,
1670                book_row.series,
1671                book_row.edition,
1672                book_row.volume,
1673                book_row.number,
1674                book_row.identifier,
1675                book_row.file_kind,
1676                book_row.file_size,
1677                book_row.added_at,
1678            )
1679            .execute(&mut *tx)
1680            .await?;
1681
1682            let file_size_i64 = info.file.size as i64;
1683
1684            sqlx::query!(
1685                r#"
1686                INSERT OR IGNORE INTO library_books (library_id, book_fingerprint, added_to_library_at, file_path, absolute_path, mtime, file_size)
1687                VALUES (?, ?, ?, ?, ?, ?, ?)
1688                "#,
1689                library_id,
1690                fp_str,
1691                book_row.added_at,
1692                book_row.file_path,
1693                book_row.absolute_path,
1694                info.file.mtime,
1695                file_size_i64,
1696            )
1697            .execute(&mut *tx)
1698            .await?;
1699
1700            let authors = extract_authors(&info.author);
1701            for (position, author_name) in authors.iter().enumerate() {
1702                sqlx::query!(
1703                    r#"INSERT OR IGNORE INTO authors (name) VALUES (?)"#,
1704                    author_name
1705                )
1706                .execute(&mut *tx)
1707                .await?;
1708
1709                let author_id: i64 = sqlx::query_scalar!(
1710                    r#"SELECT id FROM authors WHERE name = ?"#,
1711                    author_name
1712                )
1713                .fetch_one(&mut *tx)
1714                .await?;
1715
1716                let pos = position as i64;
1717                sqlx::query!(
1718                    r#"
1719                    INSERT OR IGNORE INTO book_authors (book_fingerprint, author_id, position)
1720                    VALUES (?, ?, ?)
1721                    "#,
1722                    fp_str,
1723                    author_id,
1724                    pos
1725                )
1726                .execute(&mut *tx)
1727                .await?;
1728            }
1729
1730            for category_name in &info.categories {
1731                sqlx::query!(
1732                    r#"INSERT OR IGNORE INTO categories (name) VALUES (?)"#,
1733                    category_name
1734                )
1735                .execute(&mut *tx)
1736                .await?;
1737
1738                let category_id: i64 = sqlx::query_scalar!(
1739                    r#"SELECT id FROM categories WHERE name = ?"#,
1740                    category_name
1741                )
1742                .fetch_one(&mut *tx)
1743                .await?;
1744
1745                sqlx::query!(
1746                        r#"
1747                        INSERT OR IGNORE INTO book_categories (book_fingerprint, category_id)
1748                        VALUES (?, ?)
1749                        "#,
1750                    fp_str,
1751                    category_id
1752                )
1753                .execute(&mut *tx)
1754                .await?;
1755            }
1756
1757            if let Some(reader_info) = &info.reader_info {
1758                let rs_row = reader_info_to_reading_state_row(fp, reader_info);
1759
1760                sqlx::query!(
1761                    r#"
1762                    INSERT INTO reading_states (
1763                        fingerprint, opened, current_page, pages_count, finished, dithered,
1764                        zoom_mode, scroll_mode, page_offset_x, page_offset_y, rotation,
1765                        cropping_margins_json, margin_width, screen_margin_width,
1766                        font_family, font_size, text_align, line_height,
1767                        contrast_exponent, contrast_gray,
1768                        page_names_json, bookmarks_json, annotations_json
1769                    ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1770                    "#,
1771                    rs_row.fingerprint,
1772                    rs_row.opened,
1773                    rs_row.current_page,
1774                    rs_row.pages_count,
1775                    rs_row.finished,
1776                    rs_row.dithered,
1777                    rs_row.zoom_mode,
1778                    rs_row.scroll_mode,
1779                    rs_row.page_offset_x,
1780                    rs_row.page_offset_y,
1781                    rs_row.rotation,
1782                    rs_row.cropping_margins_json,
1783                    rs_row.margin_width,
1784                    rs_row.screen_margin_width,
1785                    rs_row.font_family,
1786                    rs_row.font_size,
1787                    rs_row.text_align,
1788                    rs_row.line_height,
1789                    rs_row.contrast_exponent,
1790                    rs_row.contrast_gray,
1791                    rs_row.page_names_json,
1792                    rs_row.bookmarks_json,
1793                    rs_row.annotations_json,
1794                )
1795                .execute(&mut *tx)
1796                .await?;
1797            }
1798
1799            tx.commit().await?;
1800
1801            tracing::debug!(fp = %fp, "book insert complete");
1802            Ok(())
1803        })
1804    }
1805
1806    /// Rewrites the stored metadata for one book and its library-specific path fields.
1807    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, info), fields(fp = %fp, library_id)))]
1808    pub fn update_book(&self, library_id: i64, fp: Fp, info: &Info) -> Result<(), Error> {
1809        tracing::debug!(fp = %fp, library_id, "updating book in database");
1810        let fp_str = fp.to_string();
1811
1812        RUNTIME.block_on(async {
1813            let mut tx = self.pool.begin().await?;
1814
1815            let book_row = info_to_book_row(fp, info);
1816
1817            sqlx::query!(
1818                r#"
1819                UPDATE books SET
1820                    title = ?, subtitle = ?, year = ?, language = ?, publisher = ?,
1821                    series = ?, edition = ?, volume = ?, number = ?, identifier = ?,
1822                    file_kind = ?, file_size = ?, added_at = ?
1823                WHERE fingerprint = ?
1824                "#,
1825                book_row.title,
1826                book_row.subtitle,
1827                book_row.year,
1828                book_row.language,
1829                book_row.publisher,
1830                book_row.series,
1831                book_row.edition,
1832                book_row.volume,
1833                book_row.number,
1834                book_row.identifier,
1835                book_row.file_kind,
1836                book_row.file_size,
1837                book_row.added_at,
1838                fp_str,
1839            )
1840            .execute(&mut *tx)
1841            .await?;
1842
1843            sqlx::query!(
1844                r#"
1845                UPDATE library_books SET file_path = ?, absolute_path = ?
1846                WHERE library_id = ? AND book_fingerprint = ?
1847                "#,
1848                book_row.file_path,
1849                book_row.absolute_path,
1850                library_id,
1851                fp_str,
1852            )
1853            .execute(&mut *tx)
1854            .await?;
1855
1856            sqlx::query!(
1857                r#"DELETE FROM book_authors WHERE book_fingerprint = ?"#,
1858                fp_str
1859            )
1860            .execute(&mut *tx)
1861            .await?;
1862
1863            let authors = extract_authors(&info.author);
1864            for (position, author_name) in authors.iter().enumerate() {
1865                sqlx::query!(
1866                    r#"INSERT OR IGNORE INTO authors (name) VALUES (?)"#,
1867                    author_name
1868                )
1869                .execute(&mut *tx)
1870                .await?;
1871
1872                let author_id: i64 =
1873                    sqlx::query_scalar!(r#"SELECT id FROM authors WHERE name = ?"#, author_name)
1874                        .fetch_one(&mut *tx)
1875                        .await?;
1876
1877                let pos = position as i64;
1878                sqlx::query!(
1879                    r#"
1880                        INSERT OR IGNORE INTO book_authors (book_fingerprint, author_id, position)
1881                        VALUES (?, ?, ?)
1882                        "#,
1883                    fp_str,
1884                    author_id,
1885                    pos
1886                )
1887                .execute(&mut *tx)
1888                .await?;
1889            }
1890
1891            sqlx::query!(
1892                r#"DELETE FROM book_categories WHERE book_fingerprint = ?"#,
1893                fp_str
1894            )
1895            .execute(&mut *tx)
1896            .await?;
1897
1898            for category_name in &info.categories {
1899                sqlx::query!(
1900                    r#"INSERT OR IGNORE INTO categories (name) VALUES (?)"#,
1901                    category_name
1902                )
1903                .execute(&mut *tx)
1904                .await?;
1905
1906                let category_id: i64 = sqlx::query_scalar!(
1907                    r#"SELECT id FROM categories WHERE name = ?"#,
1908                    category_name
1909                )
1910                .fetch_one(&mut *tx)
1911                .await?;
1912
1913                sqlx::query!(
1914                    r#"
1915                        INSERT OR IGNORE INTO book_categories (book_fingerprint, category_id)
1916                        VALUES (?, ?)
1917                        "#,
1918                    fp_str,
1919                    category_id
1920                )
1921                .execute(&mut *tx)
1922                .await?;
1923            }
1924
1925            tx.commit().await?;
1926
1927            tracing::debug!(fp = %fp, "book update complete");
1928            Ok(())
1929        })
1930    }
1931
1932    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self), fields(fp = %fp)))]
1933    pub fn delete_reading_state(&self, fp: Fp) -> Result<(), Error> {
1934        tracing::debug!(fp = %fp, "deleting reading state from database");
1935
1936        RUNTIME.block_on(async {
1937            let fp_str = fp.to_string();
1938
1939            sqlx::query!(
1940                r#"DELETE FROM reading_states WHERE fingerprint = ?"#,
1941                fp_str
1942            )
1943            .execute(&self.pool)
1944            .await?;
1945
1946            Ok(())
1947        })
1948    }
1949
1950    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self), fields(fp = %fp, library_id)))]
1951    pub fn delete_book(&self, library_id: i64, fp: Fp) -> Result<(), Error> {
1952        tracing::debug!(fp = %fp, library_id, "deleting book from library");
1953
1954        RUNTIME.block_on(async {
1955            let fp_str = fp.to_string();
1956            let mut tx = self.pool.begin().await?;
1957
1958            sqlx::query!(
1959                r#"DELETE FROM library_books WHERE library_id = ? AND book_fingerprint = ?"#,
1960                library_id,
1961                fp_str
1962            )
1963            .execute(&mut *tx)
1964            .await?;
1965
1966            let remaining: i64 = sqlx::query_scalar!(
1967                r#"SELECT COUNT(*) FROM library_books WHERE book_fingerprint = ?"#,
1968                fp_str
1969            )
1970            .fetch_one(&mut *tx)
1971            .await?;
1972
1973            if remaining == 0 {
1974                tracing::debug!(fp = %fp, "book not in any library, deleting completely");
1975                sqlx::query!(r#"DELETE FROM books WHERE fingerprint = ?"#, fp_str)
1976                    .execute(&mut *tx)
1977                    .await?;
1978            }
1979
1980            tx.commit().await?;
1981
1982            tracing::debug!(fp = %fp, library_id, "book delete complete");
1983            Ok(())
1984        })
1985    }
1986
1987    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self), fields(fp = %fp)))]
1988    pub fn get_thumbnail(&self, fp: Fp) -> Result<Option<Vec<u8>>, Error> {
1989        tracing::debug!(fp = %fp, "fetching thumbnail from database");
1990        let fp_str = fp.to_string();
1991
1992        RUNTIME.block_on(async {
1993            sqlx::query_scalar!(
1994                "SELECT thumbnail_data FROM thumbnails WHERE fingerprint = ?",
1995                fp_str
1996            )
1997            .fetch_optional(&self.pool)
1998            .await
1999            .map_err(Error::from)
2000        })
2001    }
2002
2003    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self), fields(library_id, path = %path.display())))]
2004    pub fn get_thumbnail_by_path(
2005        &self,
2006        library_id: i64,
2007        path: &Path,
2008    ) -> Result<Option<Vec<u8>>, Error> {
2009        let path = path.to_string_lossy().into_owned();
2010        tracing::debug!(library_id, path, "fetching thumbnail by path from database");
2011
2012        RUNTIME.block_on(async {
2013            sqlx::query_scalar!(
2014                "SELECT t.thumbnail_data FROM library_books lb INNER JOIN thumbnails t ON lb.book_fingerprint = t.fingerprint WHERE lb.library_id = ? AND lb.file_path = ?",
2015                library_id,
2016                path
2017            )
2018            .fetch_optional(&self.pool)
2019            .await
2020            .map_err(Error::from)
2021        })
2022    }
2023
2024    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, data), fields(fp = %fp, size = data.len())))]
2025    pub fn save_thumbnail(&self, fp: Fp, data: &[u8]) -> Result<(), Error> {
2026        tracing::debug!(fp = %fp, size = data.len(), "saving thumbnail to database");
2027        let fp_str = fp.to_string();
2028
2029        RUNTIME.block_on(async {
2030            sqlx::query!(
2031                r#"
2032                INSERT INTO thumbnails (fingerprint, thumbnail_data)
2033                VALUES (?, ?)
2034                ON CONFLICT(fingerprint) DO UPDATE SET
2035                    thumbnail_data = excluded.thumbnail_data
2036                "#,
2037                fp_str,
2038                data,
2039            )
2040            .execute(&self.pool)
2041            .await?;
2042
2043            tracing::debug!(fp = %fp, "thumbnail save complete");
2044            Ok(())
2045        })
2046    }
2047
2048    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self), fields(fp = %fp)))]
2049    pub fn delete_thumbnail(&self, fp: Fp) -> Result<(), Error> {
2050        tracing::debug!(fp = %fp, "deleting thumbnail from database");
2051        let fp_str = fp.to_string();
2052
2053        RUNTIME.block_on(async {
2054            sqlx::query!("DELETE FROM thumbnails WHERE fingerprint = ?", fp_str)
2055                .execute(&self.pool)
2056                .await?;
2057
2058            tracing::debug!(fp = %fp, "thumbnail delete complete");
2059            Ok(())
2060        })
2061    }
2062
2063    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, fps), fields(count = fps.len())))]
2064    pub fn batch_delete_thumbnails(&self, fps: &[Fp]) -> Result<(), Error> {
2065        if fps.is_empty() {
2066            return Ok(());
2067        }
2068
2069        tracing::debug!(count = fps.len(), "batch deleting thumbnails from database");
2070
2071        RUNTIME.block_on(async {
2072            let mut tx = self.pool.begin().await?;
2073
2074            for fp in fps {
2075                let fp_str = fp.to_string();
2076                sqlx::query!("DELETE FROM thumbnails WHERE fingerprint = ?", fp_str)
2077                    .execute(&mut *tx)
2078                    .await?;
2079            }
2080
2081            tx.commit().await?;
2082            Ok(())
2083        })
2084    }
2085
2086    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self), fields(from = %from_fp, to = %to_fp)))]
2087    pub fn move_thumbnail(&self, from_fp: Fp, to_fp: Fp) -> Result<(), Error> {
2088        tracing::debug!(from = %from_fp, to = %to_fp, "moving thumbnail in database");
2089        let from_fp_str = from_fp.to_string();
2090        let to_fp_str = to_fp.to_string();
2091
2092        RUNTIME.block_on(async {
2093            sqlx::query!(
2094                r#"
2095                UPDATE thumbnails
2096                SET fingerprint = ?
2097                WHERE fingerprint = ?
2098                "#,
2099                to_fp_str,
2100                from_fp_str
2101            )
2102            .execute(&self.pool)
2103            .await?;
2104
2105            Ok(())
2106        })
2107    }
2108
2109    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, moves), fields(count = moves.len())))]
2110    pub fn batch_move_thumbnails(&self, moves: &[(Fp, Fp)]) -> Result<(), Error> {
2111        if moves.is_empty() {
2112            return Ok(());
2113        }
2114
2115        tracing::debug!(count = moves.len(), "batch moving thumbnails in database");
2116
2117        RUNTIME.block_on(async {
2118            let mut tx = self.pool.begin().await?;
2119
2120            for (from_fp, to_fp) in moves {
2121                let from_str = from_fp.to_string();
2122                let to_str = to_fp.to_string();
2123
2124                sqlx::query!(
2125                    r#"UPDATE thumbnails SET fingerprint = ? WHERE fingerprint = ?"#,
2126                    to_str,
2127                    from_str
2128                )
2129                .execute(&mut *tx)
2130                .await?;
2131            }
2132
2133            tx.commit().await?;
2134            Ok(())
2135        })
2136    }
2137
2138    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, reader_info), fields(fp = %fp)))]
2139    pub fn save_reading_state(&self, fp: Fp, reader_info: &ReaderInfo) -> Result<(), Error> {
2140        tracing::debug!(fp = %fp, "saving reading state to database");
2141
2142        RUNTIME.block_on(async {
2143            let rs_row = reader_info_to_reading_state_row(fp, reader_info);
2144
2145            sqlx::query!(
2146                r#"
2147                INSERT INTO reading_states (
2148                    fingerprint, opened, current_page, pages_count, finished, dithered,
2149                    zoom_mode, scroll_mode, page_offset_x, page_offset_y, rotation,
2150                    cropping_margins_json, margin_width, screen_margin_width,
2151                    font_family, font_size, text_align, line_height,
2152                    contrast_exponent, contrast_gray,
2153                    page_names_json, bookmarks_json, annotations_json
2154                ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2155                ON CONFLICT(fingerprint) DO UPDATE SET
2156                    opened = excluded.opened,
2157                    current_page = excluded.current_page,
2158                    pages_count = excluded.pages_count,
2159                    finished = excluded.finished,
2160                    dithered = excluded.dithered,
2161                    zoom_mode = excluded.zoom_mode,
2162                    scroll_mode = excluded.scroll_mode,
2163                    page_offset_x = excluded.page_offset_x,
2164                    page_offset_y = excluded.page_offset_y,
2165                    rotation = excluded.rotation,
2166                    cropping_margins_json = excluded.cropping_margins_json,
2167                    margin_width = excluded.margin_width,
2168                    screen_margin_width = excluded.screen_margin_width,
2169                    font_family = excluded.font_family,
2170                    font_size = excluded.font_size,
2171                    text_align = excluded.text_align,
2172                    line_height = excluded.line_height,
2173                    contrast_exponent = excluded.contrast_exponent,
2174                    contrast_gray = excluded.contrast_gray,
2175                    page_names_json = excluded.page_names_json,
2176                    bookmarks_json = excluded.bookmarks_json,
2177                    annotations_json = excluded.annotations_json
2178                "#,
2179                rs_row.fingerprint,
2180                rs_row.opened,
2181                rs_row.current_page,
2182                rs_row.pages_count,
2183                rs_row.finished,
2184                rs_row.dithered,
2185                rs_row.zoom_mode,
2186                rs_row.scroll_mode,
2187                rs_row.page_offset_x,
2188                rs_row.page_offset_y,
2189                rs_row.rotation,
2190                rs_row.cropping_margins_json,
2191                rs_row.margin_width,
2192                rs_row.screen_margin_width,
2193                rs_row.font_family,
2194                rs_row.font_size,
2195                rs_row.text_align,
2196                rs_row.line_height,
2197                rs_row.contrast_exponent,
2198                rs_row.contrast_gray,
2199                rs_row.page_names_json,
2200                rs_row.bookmarks_json,
2201                rs_row.annotations_json,
2202            )
2203            .execute(&self.pool)
2204            .await?;
2205
2206            tracing::debug!(fp = %fp, "reading state save complete");
2207            Ok(())
2208        })
2209    }
2210
2211    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, toc), fields(fp = %fp, entry_count = toc.len())))]
2212    pub fn save_toc(&self, fp: Fp, toc: &[SimpleTocEntry]) -> Result<(), Error> {
2213        if toc.is_empty() {
2214            return Ok(());
2215        }
2216
2217        tracing::debug!(fp = %fp, entry_count = toc.len(), "saving TOC to database");
2218        let fp_str = fp.to_string();
2219
2220        RUNTIME.block_on(async {
2221            let mut tx = self.pool.begin().await?;
2222
2223            sqlx::query!("DELETE FROM toc_entries WHERE book_fingerprint = ?", fp_str)
2224                .execute(&mut *tx)
2225                .await?;
2226
2227            Self::insert_toc_entries(&mut tx, &fp_str, toc, None).await?;
2228
2229            tx.commit().await?;
2230
2231            tracing::debug!(fp = %fp, "TOC save complete");
2232            Ok(())
2233        })
2234    }
2235
2236    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, books), fields(library_id, count = books.len())))]
2237    pub fn batch_insert_books(&self, library_id: i64, books: &[(Fp, &Info)]) -> Result<(), Error> {
2238        if books.is_empty() {
2239            return Ok(());
2240        }
2241
2242        tracing::debug!(library_id, count = books.len(), "batch inserting books");
2243
2244        RUNTIME.block_on(async {
2245            let mut tx = self.pool.begin().await?;
2246
2247            for (fp, info) in books {
2248                let fp_str = fp.to_string();
2249                let book_row = info_to_book_row(*fp, info);
2250
2251                sqlx::query!(
2252                    r#"
2253                    INSERT OR IGNORE INTO books (
2254                        fingerprint, title, subtitle, year, language, publisher,
2255                        series, edition, volume, number, identifier,
2256                        file_kind, file_size, added_at
2257                    ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2258                    "#,
2259                    book_row.fingerprint,
2260                    book_row.title,
2261                    book_row.subtitle,
2262                    book_row.year,
2263                    book_row.language,
2264                    book_row.publisher,
2265                    book_row.series,
2266                    book_row.edition,
2267                    book_row.volume,
2268                    book_row.number,
2269                    book_row.identifier,
2270                    book_row.file_kind,
2271                    book_row.file_size,
2272                    book_row.added_at,
2273                )
2274                .execute(&mut *tx)
2275                .await?;
2276
2277                let file_size_i64 = info.file.size as i64;
2278
2279                sqlx::query!(
2280                    r#"
2281                    INSERT OR IGNORE INTO library_books (library_id, book_fingerprint, added_to_library_at, file_path, absolute_path, mtime, file_size)
2282                    VALUES (?, ?, ?, ?, ?, ?, ?)
2283                    "#,
2284                    library_id,
2285                    fp_str,
2286                    book_row.added_at,
2287                    book_row.file_path,
2288                    book_row.absolute_path,
2289                    info.file.mtime,
2290                    file_size_i64,
2291                )
2292                .execute(&mut *tx)
2293                .await?;
2294
2295                let authors = extract_authors(&info.author);
2296                for (position, author_name) in authors.iter().enumerate() {
2297                    sqlx::query!(
2298                        r#"INSERT OR IGNORE INTO authors (name) VALUES (?)"#,
2299                        author_name
2300                    )
2301                    .execute(&mut *tx)
2302                    .await?;
2303
2304                    let author_id: i64 = sqlx::query_scalar!(
2305                        r#"SELECT id FROM authors WHERE name = ?"#,
2306                        author_name
2307                    )
2308                    .fetch_one(&mut *tx)
2309                    .await?;
2310
2311                    let pos = position as i64;
2312                    sqlx::query!(
2313                        r#"
2314                         INSERT OR IGNORE INTO book_authors (book_fingerprint, author_id, position)
2315                         VALUES (?, ?, ?)
2316                         "#,
2317                         fp_str,
2318                         author_id,
2319                         pos
2320                     )
2321                     .execute(&mut *tx)
2322                     .await?;
2323                 }
2324
2325                 for category_name in &info.categories {
2326                     sqlx::query!(
2327                         r#"INSERT OR IGNORE INTO categories (name) VALUES (?)"#,
2328                        category_name
2329                    )
2330                    .execute(&mut *tx)
2331                    .await?;
2332
2333                    let category_id: i64 = sqlx::query_scalar!(
2334                        r#"SELECT id FROM categories WHERE name = ?"#,
2335                        category_name
2336                    )
2337                    .fetch_one(&mut *tx)
2338                    .await?;
2339
2340                     sqlx::query!(
2341                         r#"
2342                         INSERT OR IGNORE INTO book_categories (book_fingerprint, category_id)
2343                         VALUES (?, ?)
2344                         "#,
2345                         fp_str,
2346                         category_id
2347                     )
2348                     .execute(&mut *tx)
2349                     .await?;
2350                 }
2351
2352                 if let Some(reader_info) = &info.reader_info {
2353                    let rs_row = reader_info_to_reading_state_row(*fp, reader_info);
2354
2355                    sqlx::query!(
2356                        r#"
2357                        INSERT INTO reading_states (
2358                            fingerprint, opened, current_page, pages_count, finished, dithered,
2359                            zoom_mode, scroll_mode, page_offset_x, page_offset_y, rotation,
2360                            cropping_margins_json, margin_width, screen_margin_width,
2361                            font_family, font_size, text_align, line_height,
2362                            contrast_exponent, contrast_gray,
2363                            page_names_json, bookmarks_json, annotations_json
2364                        ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2365                        "#,
2366                        rs_row.fingerprint,
2367                        rs_row.opened,
2368                        rs_row.current_page,
2369                        rs_row.pages_count,
2370                        rs_row.finished,
2371                        rs_row.dithered,
2372                        rs_row.zoom_mode,
2373                        rs_row.scroll_mode,
2374                        rs_row.page_offset_x,
2375                        rs_row.page_offset_y,
2376                        rs_row.rotation,
2377                        rs_row.cropping_margins_json,
2378                        rs_row.margin_width,
2379                        rs_row.screen_margin_width,
2380                        rs_row.font_family,
2381                        rs_row.font_size,
2382                        rs_row.text_align,
2383                        rs_row.line_height,
2384                        rs_row.contrast_exponent,
2385                        rs_row.contrast_gray,
2386                        rs_row.page_names_json,
2387                        rs_row.bookmarks_json,
2388                        rs_row.annotations_json,
2389                    )
2390                    .execute(&mut *tx)
2391                    .await?;
2392                }
2393
2394                if let Some(ref toc) = info.toc {
2395                    sqlx::query!("DELETE FROM toc_entries WHERE book_fingerprint = ?", fp_str)
2396                        .execute(&mut *tx)
2397                        .await?;
2398                    Self::insert_toc_entries(&mut tx, &fp_str, toc, None).await?;
2399                }
2400            }
2401
2402            tx.commit().await?;
2403
2404            tracing::debug!(count = books.len(), "batch insert complete");
2405            Ok(())
2406        })
2407    }
2408
2409    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, books), fields(library_id, count = books.len())))]
2410    pub fn batch_update_books(&self, library_id: i64, books: &[(Fp, &Info)]) -> Result<(), Error> {
2411        if books.is_empty() {
2412            return Ok(());
2413        }
2414
2415        tracing::debug!(library_id, count = books.len(), "batch updating books");
2416
2417        RUNTIME.block_on(async {
2418            let mut tx = self.pool.begin().await?;
2419
2420            for (fp, info) in books {
2421                let fp_str = fp.to_string();
2422
2423                let book_row = info_to_book_row(*fp, info);
2424
2425                sqlx::query!(
2426                    r#"
2427                    UPDATE books SET
2428                        title = ?, subtitle = ?, year = ?, language = ?, publisher = ?,
2429                        series = ?, edition = ?, volume = ?, number = ?, identifier = ?,
2430                        file_kind = ?, file_size = ?, added_at = ?
2431                    WHERE fingerprint = ?
2432                    "#,
2433                    book_row.title,
2434                    book_row.subtitle,
2435                    book_row.year,
2436                    book_row.language,
2437                    book_row.publisher,
2438                    book_row.series,
2439                    book_row.edition,
2440                    book_row.volume,
2441                    book_row.number,
2442                    book_row.identifier,
2443                    book_row.file_kind,
2444                    book_row.file_size,
2445                    book_row.added_at,
2446                    fp_str,
2447                )
2448                .execute(&mut *tx)
2449                .await?;
2450
2451                sqlx::query!(
2452                    r#"
2453                    UPDATE library_books SET file_path = ?, absolute_path = ?
2454                    WHERE library_id = ? AND book_fingerprint = ?
2455                    "#,
2456                    book_row.file_path,
2457                    book_row.absolute_path,
2458                    library_id,
2459                    fp_str,
2460                )
2461                .execute(&mut *tx)
2462                .await?;
2463
2464                sqlx::query!(
2465                    r#"DELETE FROM book_authors WHERE book_fingerprint = ?"#,
2466                    fp_str
2467                )
2468                .execute(&mut *tx)
2469                .await?;
2470
2471                let authors = extract_authors(&info.author);
2472                for (position, author_name) in authors.iter().enumerate() {
2473                    sqlx::query!(
2474                        r#"INSERT OR IGNORE INTO authors (name) VALUES (?)"#,
2475                        author_name
2476                    )
2477                    .execute(&mut *tx)
2478                    .await?;
2479
2480                    let author_id: i64 = sqlx::query_scalar!(
2481                        r#"SELECT id FROM authors WHERE name = ?"#,
2482                        author_name
2483                    )
2484                    .fetch_one(&mut *tx)
2485                    .await?;
2486
2487                    let pos = position as i64;
2488                    sqlx::query!(
2489                        r#"
2490                        INSERT INTO book_authors (book_fingerprint, author_id, position)
2491                        VALUES (?, ?, ?)
2492                        "#,
2493                        fp_str,
2494                        author_id,
2495                        pos
2496                    )
2497                    .execute(&mut *tx)
2498                    .await?;
2499                }
2500
2501                sqlx::query!(
2502                    r#"DELETE FROM book_categories WHERE book_fingerprint = ?"#,
2503                    fp_str
2504                )
2505                .execute(&mut *tx)
2506                .await?;
2507
2508                for category_name in &info.categories {
2509                    sqlx::query!(
2510                        r#"INSERT OR IGNORE INTO categories (name) VALUES (?)"#,
2511                        category_name
2512                    )
2513                    .execute(&mut *tx)
2514                    .await?;
2515
2516                    let category_id: i64 = sqlx::query_scalar!(
2517                        r#"SELECT id FROM categories WHERE name = ?"#,
2518                        category_name
2519                    )
2520                    .fetch_one(&mut *tx)
2521                    .await?;
2522
2523                    sqlx::query!(
2524                        r#"
2525                        INSERT INTO book_categories (book_fingerprint, category_id)
2526                        VALUES (?, ?)
2527                        "#,
2528                        fp_str,
2529                        category_id
2530                    )
2531                    .execute(&mut *tx)
2532                    .await?;
2533                }
2534
2535                if let Some(ref toc) = info.toc {
2536                    sqlx::query!("DELETE FROM toc_entries WHERE book_fingerprint = ?", fp_str)
2537                        .execute(&mut *tx)
2538                        .await?;
2539                    Self::insert_toc_entries(&mut tx, &fp_str, toc, None).await?;
2540                } else {
2541                    sqlx::query!("DELETE FROM toc_entries WHERE book_fingerprint = ?", fp_str)
2542                        .execute(&mut *tx)
2543                        .await?;
2544                }
2545            }
2546
2547            tx.commit().await?;
2548
2549            tracing::debug!(count = books.len(), "batch update complete");
2550            Ok(())
2551        })
2552    }
2553
2554    /// Returns handles for every book currently linked to a library.
2555    ///
2556    /// Each [`BookHandle`] carries the fingerprint, the relative path stored
2557    /// in the database, the absolute path used to compute it, and the
2558    /// optional `mtime`/`file_size` snapshot used by the incremental import.
2559    #[cfg_attr(
2560        feature = "tracing",
2561        tracing::instrument(skip(self), fields(library_id))
2562    )]
2563    pub fn list_book_handles(&self, library_id: i64) -> Result<Vec<BookHandle>, Error> {
2564        RUNTIME.block_on(async {
2565            let rows = sqlx::query!(
2566                r#"
2567                SELECT lb.book_fingerprint AS "fingerprint!: Fp",
2568                       lb.file_path        AS "file_path!: String",
2569                       lb.absolute_path    AS "absolute_path!: String",
2570                       lb.mtime            AS "mtime?: UnixTimestamp",
2571                       lb.file_size        AS "file_size?: FileSize"
2572                FROM library_books lb
2573                WHERE lb.library_id = ?
2574                "#,
2575                library_id,
2576            )
2577            .fetch_all(&self.pool)
2578            .await?;
2579
2580            Ok(rows
2581                .into_iter()
2582                .map(|row| BookHandle {
2583                    fp: row.fingerprint,
2584                    relat: PathBuf::from(row.file_path),
2585                    abs: PathBuf::from(row.absolute_path),
2586                    mtime: row.mtime,
2587                    file_size: row.file_size,
2588                })
2589                .collect())
2590        })
2591    }
2592
2593    /// Returns `(fingerprint, path)` pairs for every book that does not have a thumbnail cached.
2594    #[cfg_attr(
2595        feature = "tracing",
2596        tracing::instrument(skip(self), fields(library_id))
2597    )]
2598    pub fn books_without_thumbnails(&self, library_id: i64) -> Result<Vec<(Fp, PathBuf)>, Error> {
2599        RUNTIME.block_on(async {
2600            let rows = sqlx::query!(
2601                r#"
2602                SELECT lb.book_fingerprint AS "fingerprint!: Fp",
2603                       lb.file_path        AS "file_path!: String"
2604                FROM library_books lb
2605                LEFT JOIN thumbnails t ON lb.book_fingerprint = t.fingerprint
2606                WHERE lb.library_id = ? AND t.fingerprint IS NULL
2607                "#,
2608                library_id,
2609            )
2610            .fetch_all(&self.pool)
2611            .await?;
2612
2613            rows.into_iter()
2614                .map(|row| Ok((row.fingerprint, PathBuf::from(row.file_path))))
2615                .collect()
2616        })
2617    }
2618
2619    /// Updates both the relative and absolute path of a book in a single transaction.
2620    /// No-op if the book is not found in the library.
2621    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self), fields(library_id, fp = %fp)))]
2622    pub fn update_book_path(
2623        &self,
2624        library_id: i64,
2625        fp: Fp,
2626        rel_path: &Path,
2627        abs_path: &Path,
2628    ) -> Result<(), Error> {
2629        let fp_str = fp.to_string();
2630        let rel_str = rel_path.to_string_lossy().into_owned();
2631        let abs_str = abs_path.to_string_lossy().into_owned();
2632
2633        RUNTIME.block_on(async {
2634            let mut tx = self.pool.begin().await?;
2635
2636            sqlx::query!(
2637                r#"UPDATE library_books SET file_path = ?, absolute_path = ? WHERE library_id = ? AND book_fingerprint = ?"#,
2638                rel_str,
2639                abs_str,
2640                library_id,
2641                fp_str,
2642            )
2643            .execute(&mut *tx)
2644            .await?;
2645
2646            tx.commit().await?;
2647            Ok(())
2648        })
2649    }
2650
2651    /// Updates relative and absolute paths for multiple books in a single transaction,
2652    /// with one combined UPDATE per entry. Used by `import()` after directory scanning
2653    /// to record the final locations of books that were moved or renamed on disk.
2654    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, updates), fields(library_id, count = updates.len())))]
2655    pub fn batch_update_book_paths(
2656        &self,
2657        library_id: i64,
2658        updates: &[PathUpdate],
2659    ) -> Result<(), Error> {
2660        if updates.is_empty() {
2661            return Ok(());
2662        }
2663
2664        tracing::debug!(
2665            library_id,
2666            count = updates.len(),
2667            "batch updating book paths in library"
2668        );
2669
2670        RUNTIME.block_on(async {
2671            let mut tx = self.pool.begin().await?;
2672
2673            for update in updates {
2674                let fp_str = update.fp.to_string();
2675                let rel_str = update.relat.to_string_lossy().into_owned();
2676                let abs_str = update.abs.to_string_lossy().into_owned();
2677
2678                sqlx::query!(
2679                    r#"UPDATE library_books
2680                       SET file_path = ?, absolute_path = ?, mtime = ?, file_size = ?
2681                       WHERE library_id = ? AND book_fingerprint = ?"#,
2682                    rel_str,
2683                    abs_str,
2684                    update.mtime,
2685                    update.file_size,
2686                    library_id,
2687                    fp_str,
2688                )
2689                .execute(&mut *tx)
2690                .await?;
2691            }
2692
2693            tx.commit().await?;
2694            Ok(())
2695        })
2696    }
2697
2698    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, fps), fields(library_id, count = fps.len())))]
2699    pub fn batch_delete_books(&self, library_id: i64, fps: &[Fp]) -> Result<(), Error> {
2700        if fps.is_empty() {
2701            return Ok(());
2702        }
2703
2704        tracing::debug!(
2705            library_id,
2706            count = fps.len(),
2707            "batch deleting books from library"
2708        );
2709
2710        RUNTIME.block_on(async {
2711            let mut tx = self.pool.begin().await?;
2712
2713            for fp in fps {
2714                let fp_str = fp.to_string();
2715
2716                sqlx::query!(
2717                    r#"DELETE FROM library_books WHERE library_id = ? AND book_fingerprint = ?"#,
2718                    library_id,
2719                    fp_str
2720                )
2721                .execute(&mut *tx)
2722                .await?;
2723
2724                let ref_count: i64 = sqlx::query_scalar!(
2725                    r#"SELECT COUNT(*) FROM library_books WHERE book_fingerprint = ?"#,
2726                    fp_str
2727                )
2728                .fetch_one(&mut *tx)
2729                .await?;
2730
2731                if ref_count == 0 {
2732                    sqlx::query!(
2733                        r#"DELETE FROM books WHERE fingerprint = ?"#,
2734                        fp_str
2735                    )
2736                    .execute(&mut *tx)
2737                    .await?;
2738                    tracing::debug!(fp = %fp, "book removed from database (no more library references)");
2739                } else {
2740                    tracing::debug!(fp = %fp, ref_count, "book kept in database (still referenced by other libraries)");
2741                }
2742            }
2743
2744            tx.commit().await?;
2745
2746            tracing::debug!(count = fps.len(), "batch delete complete");
2747            Ok(())
2748        })
2749    }
2750
2751    /// Deletes all `library_books` rows for this library whose `file_kind` is not in
2752    /// `allowed_kinds`, cleans up orphaned `books` rows, and returns the fingerprints
2753    /// of removed entries so callers can purge thumbnails.
2754    ///
2755    /// Called at the start of every import so that books whose kind was later removed
2756    /// from `allowed_kinds` do not persist in the database.
2757    #[cfg_attr(
2758        feature = "tracing",
2759        tracing::instrument(skip(self, allowed_kinds), fields(library_id))
2760    )]
2761    pub fn delete_books_with_disallowed_kinds(
2762        &self,
2763        library_id: i64,
2764        allowed_kinds: &FxHashSet<FileExtension>,
2765    ) -> Result<Vec<Fp>, Error> {
2766        RUNTIME.block_on(async {
2767            let mut tx = self.pool.begin().await?;
2768
2769            let rows = sqlx::query!(
2770                r#"
2771                SELECT
2772                    lb.book_fingerprint AS "fingerprint!: Fp",
2773                    b.file_kind AS "file_kind!: FileExtension"
2774                FROM library_books lb
2775                INNER JOIN books b ON b.fingerprint = lb.book_fingerprint
2776                WHERE lb.library_id = ?
2777                "#,
2778                library_id,
2779            )
2780            .fetch_all(&mut *tx)
2781            .await?;
2782
2783            let mut purged: Vec<Fp> = Vec::new();
2784
2785            for row in rows {
2786                let kind = row.file_kind;
2787
2788                if allowed_kinds.contains(&kind) {
2789                    continue;
2790                }
2791
2792                sqlx::query!(
2793                    r#"DELETE FROM library_books WHERE library_id = ? AND book_fingerprint = ?"#,
2794                    library_id,
2795                    row.fingerprint,
2796                )
2797                .execute(&mut *tx)
2798                .await?;
2799
2800                let ref_count: i64 = sqlx::query_scalar!(
2801                    r#"SELECT COUNT(*) FROM library_books WHERE book_fingerprint = ?"#,
2802                    row.fingerprint,
2803                )
2804                .fetch_one(&mut *tx)
2805                .await?;
2806
2807                if ref_count == 0 {
2808                    sqlx::query!(
2809                        r#"DELETE FROM books WHERE fingerprint = ?"#,
2810                        row.fingerprint,
2811                    )
2812                    .execute(&mut *tx)
2813                    .await?;
2814                }
2815
2816                tracing::info!(fp = %row.fingerprint, kind = %kind, "removed disallowed book from library");
2817                purged.push(row.fingerprint);
2818            }
2819
2820            tx.commit().await?;
2821            tracing::debug!(count = purged.len(), "disallowed kind cleanup complete");
2822            Ok(purged)
2823        })
2824    }
2825}
2826
2827#[cfg(test)]
2828mod tests {
2829    use super::*;
2830    use crate::db::Database;
2831    use crate::metadata::ReaderInfo;
2832    use chrono::Local;
2833    use std::collections::BTreeSet;
2834    use std::path::{Path, PathBuf};
2835    use std::str::FromStr;
2836
2837    fn create_test_db() -> (Database, Db) {
2838        let db = Database::new(":memory:").expect("failed to create in-memory database");
2839        db.migrate().expect("failed to run migrations");
2840        let libdb = Db::new(&db);
2841        (db, libdb)
2842    }
2843
2844    fn register_test_library(libdb: &Db, path: &str, name: &str) -> i64 {
2845        libdb
2846            .register_library(path, name)
2847            .expect("failed to register library")
2848    }
2849
2850    fn make_info(path: &str, title: &str, author: &str) -> Info {
2851        Info {
2852            title: title.to_string(),
2853            author: author.to_string(),
2854            file: FileInfo {
2855                path: PathBuf::from(path),
2856                kind: "epub".to_string(),
2857                size: 1024,
2858                ..Default::default()
2859            },
2860            ..Default::default()
2861        }
2862    }
2863
2864    #[test]
2865    fn midpoint_rank_both_none_returns_stride() {
2866        assert_eq!(midpoint_rank(&[None, None], 0), Some(SORT_RANK_STRIDE));
2867    }
2868
2869    #[test]
2870    fn midpoint_rank_empty_slice_returns_stride() {
2871        assert_eq!(midpoint_rank(&[], 0), Some(SORT_RANK_STRIDE));
2872    }
2873
2874    #[test]
2875    fn midpoint_rank_left_none_right_some_bisects() {
2876        // pos=0 → left=None, right=Some(10) → 10/2 = 5
2877        assert_eq!(midpoint_rank(&[Some(10)], 0), Some(5));
2878    }
2879
2880    #[test]
2881    fn midpoint_rank_left_none_right_some_exactly_one_returns_none() {
2882        assert_eq!(midpoint_rank(&[Some(1)], 0), None);
2883    }
2884
2885    #[test]
2886    fn midpoint_rank_left_none_right_some_zero_returns_none() {
2887        assert_eq!(midpoint_rank(&[Some(0)], 0), None);
2888    }
2889
2890    #[test]
2891    fn midpoint_rank_left_some_right_none_adds_stride() {
2892        // pos=1 → left=Some(5), right=None → 5 + 1000
2893        assert_eq!(midpoint_rank(&[Some(5)], 1), Some(5 + SORT_RANK_STRIDE));
2894    }
2895
2896    #[test]
2897    fn midpoint_rank_left_some_right_some_bisects() {
2898        // pos=1 → left=Some(2), right=Some(10) → (2+10)/2 = 6
2899        assert_eq!(midpoint_rank(&[Some(2), Some(10)], 1), Some(6));
2900    }
2901
2902    #[test]
2903    fn midpoint_rank_adjacent_values_returns_none() {
2904        // pos=1 → left=Some(5), right=Some(6) → mid=5 which is not > l
2905        assert_eq!(midpoint_rank(&[Some(5), Some(6)], 1), None);
2906    }
2907
2908    #[test]
2909    fn midpoint_rank_equal_values_returns_none() {
2910        // pos=1 → left=Some(5), right=Some(5) → mid=5 which is not > l
2911        assert_eq!(midpoint_rank(&[Some(5), Some(5)], 1), None);
2912    }
2913
2914    #[test]
2915    fn midpoint_rank_none_slots_ignored_on_left_side() {
2916        // Slot at pos-1 is None → flattens to left=None, right=Some(20) → 20/2=10
2917        assert_eq!(midpoint_rank(&[None, Some(20)], 1), Some(10));
2918    }
2919
2920    #[test]
2921    fn midpoint_rank_pos_beyond_slice_uses_last_as_left() {
2922        // pos beyond length → right is None; left is the last element
2923        let ranks = vec![Some(500i64)];
2924        assert_eq!(midpoint_rank(&ranks, 1), Some(500 + SORT_RANK_STRIDE));
2925    }
2926
2927    #[test]
2928    fn test_insert_and_get_book() {
2929        let (_db, libdb) = create_test_db();
2930        let fp = Fp::from_u64(1);
2931
2932        let info = Info {
2933            title: "Test Book".to_string(),
2934            subtitle: "A Test".to_string(),
2935            author: "John Doe, Jane Smith".to_string(),
2936            year: "2024".to_string(),
2937            language: "en".to_string(),
2938            publisher: "Test Press".to_string(),
2939            series: "Test Series".to_string(),
2940            number: "1".to_string(),
2941            categories: vec!["Fiction".to_string(), "Science".to_string()]
2942                .into_iter()
2943                .collect(),
2944            file: FileInfo {
2945                path: PathBuf::from("/tmp/test.pdf"),
2946                kind: "pdf".to_string(),
2947                size: 1024,
2948                ..Default::default()
2949            },
2950            added: Local::now().naive_local(),
2951            ..Default::default()
2952        };
2953
2954        let library_id = register_test_library(&libdb, "/tmp/test_library", "Test Library");
2955        libdb
2956            .insert_book(library_id, fp, &info)
2957            .expect("failed to insert book");
2958
2959        let books = libdb
2960            .get_all_books(library_id)
2961            .expect("failed to get books");
2962        let retrieved_info = books.iter().find(|info| info.fp == Some(fp)).cloned();
2963        assert!(retrieved_info.is_some(), "book should exist in database");
2964
2965        let retrieved_info = retrieved_info.unwrap();
2966        assert_eq!(retrieved_info.title, "Test Book");
2967        assert_eq!(retrieved_info.subtitle, "A Test");
2968        assert_eq!(retrieved_info.author, "John Doe, Jane Smith");
2969        assert_eq!(retrieved_info.year, "2024");
2970        assert_eq!(retrieved_info.language, "en");
2971        assert_eq!(retrieved_info.publisher, "Test Press");
2972        assert_eq!(retrieved_info.series, "Test Series");
2973        assert_eq!(retrieved_info.number, "1");
2974        assert_eq!(retrieved_info.file.path, PathBuf::from("/tmp/test.pdf"));
2975        assert_eq!(retrieved_info.file.kind, "pdf");
2976        assert_eq!(retrieved_info.file.size, 1024);
2977    }
2978
2979    #[test]
2980    fn test_insert_book_with_reading_state() {
2981        let (_db, libdb) = create_test_db();
2982        let fp = Fp::from_u64(2);
2983
2984        let reader_info = ReaderInfo {
2985            current_page: 42,
2986            pages_count: 100,
2987            ..Default::default()
2988        };
2989        let info = Info {
2990            title: "Book with Reading State".to_string(),
2991            author: "Test Author".to_string(),
2992            file: FileInfo {
2993                path: PathBuf::from("/tmp/test2.pdf"),
2994                kind: "pdf".to_string(),
2995                size: 2048,
2996                ..Default::default()
2997            },
2998            reader_info: Some(reader_info.clone()),
2999            ..Default::default()
3000        };
3001
3002        let library_id = register_test_library(&libdb, "/tmp/test_library2", "Test Library 2");
3003        libdb
3004            .insert_book(library_id, fp, &info)
3005            .expect("failed to insert book");
3006
3007        let books = libdb
3008            .get_all_books(library_id)
3009            .expect("failed to get books");
3010        let retrieved = books
3011            .iter()
3012            .find(|info| info.fp == Some(fp))
3013            .cloned()
3014            .unwrap();
3015        assert_eq!(retrieved.title, "Book with Reading State");
3016
3017        assert!(
3018            retrieved.reader_info.is_some(),
3019            "reading state should exist"
3020        );
3021        let retrieved_reader = retrieved.reader_info.unwrap();
3022        assert_eq!(retrieved_reader.current_page, 42);
3023        assert_eq!(retrieved_reader.pages_count, 100);
3024        assert!(!retrieved_reader.finished);
3025    }
3026
3027    #[test]
3028    fn test_delete_book() {
3029        let (_db, libdb) = create_test_db();
3030        let fp = Fp::from_u64(3);
3031
3032        let info = Info {
3033            title: "Book to Delete".to_string(),
3034            author: "Delete Author".to_string(),
3035            file: FileInfo {
3036                path: PathBuf::from("/tmp/delete.pdf"),
3037                kind: "pdf".to_string(),
3038                size: 512,
3039                ..Default::default()
3040            },
3041            ..Default::default()
3042        };
3043
3044        let library_id = register_test_library(&libdb, "/tmp/test_library3", "Test Library 3");
3045        libdb
3046            .insert_book(library_id, fp, &info)
3047            .expect("failed to insert book");
3048
3049        let books = libdb
3050            .get_all_books(library_id)
3051            .expect("failed to get books");
3052        assert!(
3053            books.iter().any(|info| info.fp == Some(fp)),
3054            "book should exist before delete"
3055        );
3056
3057        libdb
3058            .delete_book(library_id, fp)
3059            .expect("failed to delete book");
3060
3061        let books = libdb
3062            .get_all_books(library_id)
3063            .expect("failed to get books");
3064        assert!(
3065            !books.iter().any(|info| info.fp == Some(fp)),
3066            "book should not exist after delete"
3067        );
3068    }
3069
3070    #[test]
3071    fn test_multiple_books() {
3072        let (_db, libdb) = create_test_db();
3073        let library_id = register_test_library(&libdb, "/tmp/test_library4", "Test Library 4");
3074
3075        for i in 1..=5 {
3076            let fp = Fp::from_u64(i as u64);
3077            let info = Info {
3078                title: format!("Book {}", i),
3079                author: format!("Author {}", i),
3080                file: FileInfo {
3081                    path: PathBuf::from(format!("/tmp/book{}.pdf", i)),
3082                    kind: "pdf".to_string(),
3083                    size: (i * 100) as u64,
3084                    ..Default::default()
3085                },
3086                ..Default::default()
3087            };
3088
3089            libdb
3090                .insert_book(library_id, fp, &info)
3091                .expect("failed to insert book");
3092        }
3093
3094        let books = libdb
3095            .get_all_books(library_id)
3096            .expect("failed to get books");
3097        for i in 1..=5 {
3098            let fp = Fp::from_u64(i as u64);
3099            let retrieved = books
3100                .iter()
3101                .find(|info| info.fp == Some(fp))
3102                .cloned()
3103                .unwrap();
3104            assert_eq!(retrieved.title, format!("Book {}", i));
3105            assert_eq!(retrieved.author, format!("Author {}", i));
3106        }
3107    }
3108
3109    #[test]
3110    fn test_update_book() {
3111        let (_db, libdb) = create_test_db();
3112        let fp = Fp::from_u64(4);
3113
3114        let mut info = Info {
3115            title: "Original Title".to_string(),
3116            author: "Original Author".to_string(),
3117            file: FileInfo {
3118                path: PathBuf::from("/tmp/update.pdf"),
3119                kind: "pdf".to_string(),
3120                size: 1024,
3121                ..Default::default()
3122            },
3123            ..Default::default()
3124        };
3125
3126        let library_id = register_test_library(&libdb, "/tmp/test_library5", "Test Library 5");
3127        libdb
3128            .insert_book(library_id, fp, &info)
3129            .expect("failed to insert book");
3130
3131        info.title = "Updated Title".to_string();
3132        info.author = "Updated Author".to_string();
3133        info.year = "2025".to_string();
3134
3135        libdb
3136            .update_book(library_id, fp, &info)
3137            .expect("failed to update book");
3138
3139        let books = libdb
3140            .get_all_books(library_id)
3141            .expect("failed to get books");
3142        let updated = books
3143            .iter()
3144            .find(|info| info.fp == Some(fp))
3145            .cloned()
3146            .unwrap();
3147        assert_eq!(updated.title, "Updated Title");
3148        assert_eq!(updated.author, "Updated Author");
3149        assert_eq!(updated.year, "2025");
3150    }
3151
3152    #[test]
3153    fn test_get_all_books() {
3154        let (_db, libdb) = create_test_db();
3155        let library_id = register_test_library(&libdb, "/tmp/test_library6", "Test Library 6");
3156
3157        for i in 1..=3 {
3158            let fp = Fp::from_u64(i as u64);
3159            let info = Info {
3160                title: format!("Book {}", i),
3161                author: format!("Author {}", i),
3162                file: FileInfo {
3163                    path: PathBuf::from(format!("/tmp/book{}.pdf", i)),
3164                    kind: "pdf".to_string(),
3165                    size: (i * 100) as u64,
3166                    ..Default::default()
3167                },
3168                ..Default::default()
3169            };
3170
3171            libdb
3172                .insert_book(library_id, fp, &info)
3173                .expect("failed to insert book");
3174        }
3175
3176        let all_books = libdb
3177            .get_all_books(library_id)
3178            .expect("failed to get all books");
3179        assert_eq!(all_books.len(), 3);
3180
3181        let titles: Vec<String> = all_books.iter().map(|info| info.title.clone()).collect();
3182        assert!(titles.contains(&"Book 1".to_string()));
3183        assert!(titles.contains(&"Book 2".to_string()));
3184        assert!(titles.contains(&"Book 3".to_string()));
3185    }
3186
3187    #[test]
3188    fn test_get_book_by_path_and_fingerprint() {
3189        let (_db, libdb) = create_test_db();
3190        let library_id =
3191            register_test_library(&libdb, "/tmp/test_library_lookup", "Lookup Library");
3192        let fp = Fp::from_str("00000000000000A1").unwrap();
3193
3194        let mut info = make_info("nested/book.pdf", "Lookup Book", "Lookup Author");
3195        info.reader_info = Some(ReaderInfo {
3196            current_page: 7,
3197            pages_count: 21,
3198            ..Default::default()
3199        });
3200
3201        libdb
3202            .insert_book(library_id, fp, &info)
3203            .expect("failed to insert book");
3204
3205        let by_path = libdb
3206            .get_book_by_path(library_id, Path::new("nested/book.pdf"))
3207            .expect("failed to get book by path")
3208            .expect("book should exist by path");
3209        assert_eq!(by_path.fp, Some(fp));
3210        assert_eq!(by_path.title, "Lookup Book");
3211        assert_eq!(by_path.file.path, PathBuf::from("nested/book.pdf"));
3212        assert_eq!(by_path.reader_info.unwrap().current_page, 7);
3213
3214        let by_fp = libdb
3215            .get_book_by_fingerprint(library_id, fp)
3216            .expect("failed to get book by fingerprint")
3217            .expect("book should exist by fingerprint");
3218        assert_eq!(by_fp.fp, Some(fp));
3219        assert_eq!(by_fp.title, "Lookup Book");
3220        assert_eq!(by_fp.file.path, PathBuf::from("nested/book.pdf"));
3221
3222        assert!(
3223            libdb
3224                .get_book_by_path(library_id, Path::new("missing.pdf"))
3225                .expect("lookup should succeed")
3226                .is_none()
3227        );
3228        assert!(
3229            libdb
3230                .get_book_by_fingerprint(library_id, Fp::from_str("00000000000000FF").unwrap())
3231                .expect("lookup should succeed")
3232                .is_none()
3233        );
3234    }
3235
3236    #[test]
3237    fn test_batch_get_books_by_fingerprints() {
3238        let (_db, libdb) = create_test_db();
3239        let library_id =
3240            register_test_library(&libdb, "/tmp/test_library_batch_lookup", "Batch Lookup");
3241
3242        let fp1 = Fp::from_str("00000000000000B1").unwrap();
3243        let fp2 = Fp::from_str("00000000000000B2").unwrap();
3244        let missing = Fp::from_str("00000000000000BF").unwrap();
3245
3246        libdb
3247            .insert_book(
3248                library_id,
3249                fp1,
3250                &make_info("a/book1.pdf", "Book 1", "Author 1"),
3251            )
3252            .expect("failed to insert first book");
3253        libdb
3254            .insert_book(
3255                library_id,
3256                fp2,
3257                &make_info("b/book2.pdf", "Book 2", "Author 2"),
3258            )
3259            .expect("failed to insert second book");
3260
3261        let books = libdb
3262            .batch_get_books_by_fingerprints(library_id, &[fp1, missing, fp2])
3263            .expect("failed to batch get books");
3264
3265        assert_eq!(books.len(), 2);
3266        assert_eq!(books.get(&fp1).expect("missing fp1").title, "Book 1");
3267        assert_eq!(books.get(&fp2).expect("missing fp2").title, "Book 2");
3268        assert!(!books.contains_key(&missing));
3269
3270        let empty = libdb
3271            .batch_get_books_by_fingerprints(library_id, &[])
3272            .expect("empty batch should succeed");
3273        assert!(empty.is_empty());
3274    }
3275
3276    #[test]
3277    fn test_count_books() {
3278        let (_db, libdb) = create_test_db();
3279        let library_id = register_test_library(&libdb, "/tmp/test_library_count", "Count Library");
3280
3281        assert_eq!(libdb.count_books(library_id).expect("count failed"), 0);
3282
3283        let fp1 = Fp::from_str("00000000000000C1").unwrap();
3284        let fp2 = Fp::from_str("00000000000000C2").unwrap();
3285
3286        libdb
3287            .insert_book(
3288                library_id,
3289                fp1,
3290                &make_info("count/one.pdf", "One", "Author"),
3291            )
3292            .expect("failed to insert first book");
3293        libdb
3294            .insert_book(
3295                library_id,
3296                fp2,
3297                &make_info("count/two.pdf", "Two", "Author"),
3298            )
3299            .expect("failed to insert second book");
3300
3301        assert_eq!(libdb.count_books(library_id).expect("count failed"), 2);
3302    }
3303
3304    #[test]
3305    fn test_list_books_under_prefix() {
3306        let (_db, libdb) = create_test_db();
3307        let library_id =
3308            register_test_library(&libdb, "/tmp/test_library_prefix_books", "Prefix Books");
3309
3310        let fp1 = Fp::from_str("00000000000000D1").unwrap();
3311        let fp2 = Fp::from_str("00000000000000D2").unwrap();
3312        let fp3 = Fp::from_str("00000000000000D3").unwrap();
3313
3314        libdb
3315            .insert_book(
3316                library_id,
3317                fp1,
3318                &make_info("dir1/book1.pdf", "Book 1", "Author 1"),
3319            )
3320            .expect("failed to insert book 1");
3321        libdb
3322            .insert_book(
3323                library_id,
3324                fp2,
3325                &make_info("dir1/sub/book2.pdf", "Book 2", "Author 2"),
3326            )
3327            .expect("failed to insert book 2");
3328        libdb
3329            .insert_book(
3330                library_id,
3331                fp3,
3332                &make_info("dir2/book3.pdf", "Book 3", "Author 3"),
3333            )
3334            .expect("failed to insert book 3");
3335
3336        let root_books = libdb
3337            .list_books_under_prefix(library_id, Path::new(""))
3338            .expect("root listing failed");
3339        assert_eq!(root_books.len(), 3);
3340
3341        let dir1_books = libdb
3342            .list_books_under_prefix(library_id, Path::new("dir1"))
3343            .expect("dir1 listing failed");
3344        let dir1_paths: BTreeSet<PathBuf> =
3345            dir1_books.into_iter().map(|info| info.file.path).collect();
3346        assert_eq!(
3347            dir1_paths,
3348            BTreeSet::from([
3349                PathBuf::from("dir1/book1.pdf"),
3350                PathBuf::from("dir1/sub/book2.pdf"),
3351            ])
3352        );
3353
3354        let exact_book = libdb
3355            .list_books_under_prefix(library_id, Path::new("dir2/book3.pdf"))
3356            .expect("exact listing failed");
3357        assert_eq!(exact_book.len(), 1);
3358        assert_eq!(exact_book[0].fp, Some(fp3));
3359    }
3360
3361    #[test]
3362    fn test_list_directories_under_prefix() {
3363        let (_db, libdb) = create_test_db();
3364        let library_id =
3365            register_test_library(&libdb, "/tmp/test_library_prefix_dirs", "Prefix Dirs");
3366
3367        libdb
3368            .insert_book(
3369                library_id,
3370                Fp::from_str("00000000000000E1").unwrap(),
3371                &make_info("dir1/book1.pdf", "Book 1", "Author 1"),
3372            )
3373            .expect("failed to insert book 1");
3374        libdb
3375            .insert_book(
3376                library_id,
3377                Fp::from_str("00000000000000E2").unwrap(),
3378                &make_info("dir1/sub/book2.pdf", "Book 2", "Author 2"),
3379            )
3380            .expect("failed to insert book 2");
3381        libdb
3382            .insert_book(
3383                library_id,
3384                Fp::from_str("00000000000000E3").unwrap(),
3385                &make_info("dir2/book3.pdf", "Book 3", "Author 3"),
3386            )
3387            .expect("failed to insert book 3");
3388
3389        let root_dirs = libdb
3390            .list_directories_under_prefix(library_id, Path::new(""))
3391            .expect("root dir listing failed");
3392        assert_eq!(
3393            root_dirs,
3394            BTreeSet::from([PathBuf::from("dir1"), PathBuf::from("dir2")])
3395        );
3396
3397        let dir1_dirs = libdb
3398            .list_directories_under_prefix(library_id, Path::new("dir1"))
3399            .expect("dir1 dir listing failed");
3400        assert_eq!(dir1_dirs, BTreeSet::from([PathBuf::from("sub")]));
3401
3402        let leaf_dirs = libdb
3403            .list_directories_under_prefix(library_id, Path::new("dir2"))
3404            .expect("leaf dir listing failed");
3405        assert!(leaf_dirs.is_empty());
3406    }
3407
3408    #[test]
3409    fn test_reading_state_crud() {
3410        let (_db, libdb) = create_test_db();
3411        let fp = Fp::from_u64(5);
3412
3413        let info = Info {
3414            title: "Book with State".to_string(),
3415            author: "State Author".to_string(),
3416            file: FileInfo {
3417                path: PathBuf::from("/tmp/state.pdf"),
3418                kind: "pdf".to_string(),
3419                size: 1024,
3420                ..Default::default()
3421            },
3422            ..Default::default()
3423        };
3424
3425        let library_id = register_test_library(&libdb, "/tmp/test_library7", "Test Library 7");
3426        libdb
3427            .insert_book(library_id, fp, &info)
3428            .expect("failed to insert book");
3429
3430        let mut reader_info = ReaderInfo {
3431            current_page: 50,
3432            pages_count: 200,
3433            ..Default::default()
3434        };
3435
3436        libdb
3437            .save_reading_state(fp, &reader_info)
3438            .expect("failed to save reading state");
3439
3440        let books = libdb
3441            .get_all_books(library_id)
3442            .expect("failed to get books");
3443        let retrieved = books
3444            .iter()
3445            .find(|info| info.fp == Some(fp))
3446            .cloned()
3447            .unwrap();
3448        let retrieved_reader = retrieved.reader_info.unwrap();
3449
3450        assert_eq!(retrieved_reader.current_page, 50);
3451        assert_eq!(retrieved_reader.pages_count, 200);
3452        assert!(!retrieved_reader.finished);
3453        reader_info.current_page = 100;
3454        reader_info.finished = true;
3455
3456        libdb
3457            .save_reading_state(fp, &reader_info)
3458            .expect("failed to update reading state");
3459
3460        let books = libdb
3461            .get_all_books(library_id)
3462            .expect("failed to get books");
3463        let updated = books
3464            .iter()
3465            .find(|info| info.fp == Some(fp))
3466            .cloned()
3467            .unwrap();
3468        let updated_reader = updated.reader_info.unwrap();
3469
3470        assert_eq!(updated_reader.current_page, 100);
3471        assert!(updated_reader.finished);
3472    }
3473
3474    #[test]
3475    fn test_batch_insert_books() {
3476        let (_db, libdb) = create_test_db();
3477        let library_id = register_test_library(&libdb, "/tmp/test_library8", "Test Library 8");
3478
3479        let mut books = Vec::new();
3480        for i in 1..=5 {
3481            let fp = Fp::from_u64((i + 100) as u64);
3482            let info = Info {
3483                title: format!("Batch Book {}", i),
3484                author: format!("Batch Author {}, Co-Author {}", i, i + 1),
3485                year: format!("{}", 2020 + i),
3486                file: FileInfo {
3487                    path: PathBuf::from(format!("/tmp/batch{}.pdf", i)),
3488                    kind: "pdf".to_string(),
3489                    size: (i * 100) as u64,
3490                    ..Default::default()
3491                },
3492                ..Default::default()
3493            };
3494            books.push((fp, info));
3495        }
3496
3497        let book_refs: Vec<(Fp, &Info)> = books.iter().map(|(fp, info)| (*fp, info)).collect();
3498
3499        libdb
3500            .batch_insert_books(library_id, &book_refs)
3501            .expect("failed to batch insert books");
3502
3503        let all_books = libdb
3504            .get_all_books(library_id)
3505            .expect("failed to get books");
3506        for (fp, info) in &books {
3507            let retrieved = all_books
3508                .iter()
3509                .find(|info| info.fp == Some(*fp))
3510                .cloned()
3511                .expect("book should exist");
3512            assert_eq!(retrieved.title, info.title);
3513            assert_eq!(retrieved.author, info.author);
3514            assert_eq!(retrieved.year, info.year);
3515        }
3516
3517        let all_books = libdb
3518            .get_all_books(library_id)
3519            .expect("failed to get all books");
3520        assert_eq!(all_books.len(), 5);
3521    }
3522
3523    #[test]
3524    fn test_batch_update_books() {
3525        let (_db, libdb) = create_test_db();
3526        let library_id = register_test_library(&libdb, "/tmp/test_library9", "Test Library 9");
3527
3528        let mut books = Vec::new();
3529        for i in 1..=3 {
3530            let fp = Fp::from_u64((i + 200) as u64);
3531            let mut info = Info {
3532                title: format!("Original Book {}", i),
3533                author: format!("Original Author {}", i),
3534                file: FileInfo {
3535                    path: PathBuf::from(format!("/tmp/update{}.pdf", i)),
3536                    kind: "pdf".to_string(),
3537                    size: (i * 100) as u64,
3538                    ..Default::default()
3539                },
3540                ..Default::default()
3541            };
3542            libdb
3543                .insert_book(library_id, fp, &info)
3544                .expect("failed to insert book");
3545
3546            info.title = format!("Updated Book {}", i);
3547            info.author = format!("Updated Author {}", i);
3548            books.push((fp, info));
3549        }
3550
3551        let book_refs: Vec<(Fp, &Info)> = books.iter().map(|(fp, info)| (*fp, info)).collect();
3552
3553        libdb
3554            .batch_update_books(library_id, &book_refs)
3555            .expect("failed to batch update books");
3556
3557        let all_books = libdb
3558            .get_all_books(library_id)
3559            .expect("failed to get books");
3560        for (fp, info) in &books {
3561            let retrieved = all_books
3562                .iter()
3563                .find(|info| info.fp == Some(*fp))
3564                .cloned()
3565                .expect("book should exist");
3566            assert_eq!(retrieved.title, info.title);
3567            assert_eq!(retrieved.author, info.author);
3568        }
3569    }
3570
3571    #[test]
3572    fn test_delete_reading_state() {
3573        let (_db, libdb) = create_test_db();
3574        let fp = Fp::from_str("0000000000000006").unwrap();
3575
3576        let info = Info {
3577            title: "Book".to_string(),
3578            author: "Author".to_string(),
3579            file: FileInfo {
3580                path: PathBuf::from("/tmp/book.pdf"),
3581                kind: "pdf".to_string(),
3582                size: 100,
3583                ..Default::default()
3584            },
3585            reader_info: Some(ReaderInfo {
3586                current_page: 10,
3587                pages_count: 50,
3588                ..Default::default()
3589            }),
3590            ..Default::default()
3591        };
3592
3593        let library_id = register_test_library(&libdb, "/tmp/test_library10", "Test Library 10");
3594        libdb
3595            .insert_book(library_id, fp, &info)
3596            .expect("failed to insert book");
3597
3598        let books = libdb
3599            .get_all_books(library_id)
3600            .expect("failed to get books");
3601        let retrieved = books
3602            .iter()
3603            .find(|info| info.fp == Some(fp))
3604            .cloned()
3605            .unwrap();
3606        assert!(retrieved.reader_info.is_some());
3607
3608        libdb
3609            .delete_reading_state(fp)
3610            .expect("failed to delete reading state");
3611
3612        let books = libdb
3613            .get_all_books(library_id)
3614            .expect("failed to get books");
3615        let retrieved = books
3616            .iter()
3617            .find(|info| info.fp == Some(fp))
3618            .cloned()
3619            .unwrap();
3620        assert!(retrieved.reader_info.is_none());
3621    }
3622
3623    #[test]
3624    fn test_thumbnail_crud() {
3625        let (_db, libdb) = create_test_db();
3626        let library_id =
3627            register_test_library(&libdb, "/tmp/test_library_thumbnails", "Thumbnail Library");
3628        let fp = Fp::from_str("0000000000000007").unwrap();
3629        let data = vec![1, 2, 3, 4, 5];
3630
3631        libdb
3632            .insert_book(
3633                library_id,
3634                fp,
3635                &make_info("thumbs/book.pdf", "Thumb Book", "Thumb Author"),
3636            )
3637            .expect("failed to insert book");
3638
3639        let thumbnail = libdb.get_thumbnail(fp).expect("failed to get thumbnail");
3640        assert!(thumbnail.is_none());
3641
3642        libdb
3643            .save_thumbnail(fp, &data)
3644            .expect("failed to save thumbnail");
3645
3646        let thumbnail = libdb.get_thumbnail(fp).expect("failed to get thumbnail");
3647        assert_eq!(thumbnail, Some(data.clone()));
3648
3649        libdb
3650            .delete_thumbnail(fp)
3651            .expect("failed to delete thumbnail");
3652
3653        let thumbnail = libdb.get_thumbnail(fp).expect("failed to get thumbnail");
3654        assert!(thumbnail.is_none());
3655    }
3656
3657    #[test]
3658    fn test_books_without_thumbnails() {
3659        let (_db, libdb) = create_test_db();
3660        let library_id = register_test_library(
3661            &libdb,
3662            "/tmp/test_library_thumbnails_missing",
3663            "Missing Thumbs Library",
3664        );
3665        let fp1 = Fp::from_str("0000000000000008").unwrap();
3666        let fp2 = Fp::from_str("0000000000000009").unwrap();
3667
3668        libdb
3669            .insert_book(
3670                library_id,
3671                fp1,
3672                &make_info("thumbs/book1.epub", "Thumb Book 1", "Thumb Author 1"),
3673            )
3674            .expect("failed to insert book 1");
3675
3676        libdb
3677            .insert_book(
3678                library_id,
3679                fp2,
3680                &make_info("thumbs/book2.epub", "Thumb Book 2", "Thumb Author 2"),
3681            )
3682            .expect("failed to insert book 2");
3683
3684        let missing = libdb.books_without_thumbnails(library_id).unwrap();
3685        assert_eq!(missing.len(), 2);
3686        assert!(missing.contains(&(fp1, PathBuf::from("thumbs/book1.epub"))));
3687        assert!(missing.contains(&(fp2, PathBuf::from("thumbs/book2.epub"))));
3688
3689        libdb.save_thumbnail(fp1, &[1, 2, 3]).unwrap();
3690
3691        let missing = libdb.books_without_thumbnails(library_id).unwrap();
3692        assert_eq!(missing.len(), 1);
3693        assert_eq!(missing[0], (fp2, PathBuf::from("thumbs/book2.epub")));
3694
3695        libdb.save_thumbnail(fp2, &[4, 5, 6]).unwrap();
3696
3697        let missing = libdb.books_without_thumbnails(library_id).unwrap();
3698        assert!(missing.is_empty());
3699    }
3700
3701    #[test]
3702    fn test_batch_delete_thumbnails() {
3703        let (_db, libdb) = create_test_db();
3704        let library_id = register_test_library(
3705            &libdb,
3706            "/tmp/test_library_batch_delete_thumbnails",
3707            "Batch Delete Thumbnails",
3708        );
3709        let fp1 = Fp::from_str("00000000000000F1").unwrap();
3710        let fp2 = Fp::from_str("00000000000000F2").unwrap();
3711        let fp3 = Fp::from_str("00000000000000F3").unwrap();
3712
3713        libdb
3714            .insert_book(
3715                library_id,
3716                fp1,
3717                &make_info("thumbs/one.pdf", "One", "Author One"),
3718            )
3719            .expect("failed to insert first book");
3720        libdb
3721            .insert_book(
3722                library_id,
3723                fp2,
3724                &make_info("thumbs/two.pdf", "Two", "Author Two"),
3725            )
3726            .expect("failed to insert second book");
3727
3728        libdb
3729            .save_thumbnail(fp1, &[1, 2, 3])
3730            .expect("failed to save thumbnail 1");
3731        libdb
3732            .save_thumbnail(fp2, &[4, 5, 6])
3733            .expect("failed to save thumbnail 2");
3734
3735        libdb
3736            .batch_delete_thumbnails(&[fp1, fp3])
3737            .expect("failed to batch delete thumbnails");
3738
3739        assert!(
3740            libdb
3741                .get_thumbnail(fp1)
3742                .expect("failed to get thumbnail 1")
3743                .is_none()
3744        );
3745        assert_eq!(
3746            libdb.get_thumbnail(fp2).expect("failed to get thumbnail 2"),
3747            Some(vec![4, 5, 6])
3748        );
3749    }
3750
3751    #[test]
3752    fn test_move_thumbnail() {
3753        let (_db, libdb) = create_test_db();
3754        let library_id =
3755            register_test_library(&libdb, "/tmp/test_library_move_thumbnail", "Move Thumbnail");
3756        let from_fp = Fp::from_str("0000000000000008").unwrap();
3757        let to_fp = Fp::from_str("0000000000000009").unwrap();
3758        let data = vec![9, 8, 7, 6];
3759
3760        libdb
3761            .insert_book(
3762                library_id,
3763                from_fp,
3764                &make_info("thumbs/from.pdf", "From Book", "From Author"),
3765            )
3766            .expect("failed to insert source book");
3767        libdb
3768            .insert_book(
3769                library_id,
3770                to_fp,
3771                &make_info("thumbs/to.pdf", "To Book", "To Author"),
3772            )
3773            .expect("failed to insert destination book");
3774
3775        libdb
3776            .save_thumbnail(from_fp, &data)
3777            .expect("failed to save thumbnail");
3778
3779        libdb
3780            .move_thumbnail(from_fp, to_fp)
3781            .expect("failed to move thumbnail");
3782
3783        let old_thumbnail = libdb
3784            .get_thumbnail(from_fp)
3785            .expect("failed to get old thumbnail");
3786        assert!(old_thumbnail.is_none());
3787
3788        let new_thumbnail = libdb
3789            .get_thumbnail(to_fp)
3790            .expect("failed to get new thumbnail");
3791        assert_eq!(new_thumbnail, Some(data));
3792    }
3793
3794    #[test]
3795    fn test_batch_move_thumbnails() {
3796        let (_db, libdb) = create_test_db();
3797        let library_id = register_test_library(
3798            &libdb,
3799            "/tmp/test_library_batch_move_thumbnails",
3800            "Batch Move Thumbnails",
3801        );
3802        let from_fp1 = Fp::from_str("0000000000000101").unwrap();
3803        let to_fp1 = Fp::from_str("0000000000000102").unwrap();
3804        let from_fp2 = Fp::from_str("0000000000000103").unwrap();
3805        let to_fp2 = Fp::from_str("0000000000000104").unwrap();
3806
3807        libdb
3808            .insert_book(
3809                library_id,
3810                from_fp1,
3811                &make_info("thumbs/from1.pdf", "From 1", "Author 1"),
3812            )
3813            .expect("failed to insert source book 1");
3814        libdb
3815            .insert_book(
3816                library_id,
3817                to_fp1,
3818                &make_info("thumbs/to1.pdf", "To 1", "Author 1"),
3819            )
3820            .expect("failed to insert destination book 1");
3821        libdb
3822            .insert_book(
3823                library_id,
3824                from_fp2,
3825                &make_info("thumbs/from2.pdf", "From 2", "Author 2"),
3826            )
3827            .expect("failed to insert source book 2");
3828        libdb
3829            .insert_book(
3830                library_id,
3831                to_fp2,
3832                &make_info("thumbs/to2.pdf", "To 2", "Author 2"),
3833            )
3834            .expect("failed to insert destination book 2");
3835
3836        libdb
3837            .save_thumbnail(from_fp1, &[1, 1, 1])
3838            .expect("failed to save thumbnail 1");
3839        libdb
3840            .save_thumbnail(from_fp2, &[2, 2, 2])
3841            .expect("failed to save thumbnail 2");
3842
3843        libdb
3844            .batch_move_thumbnails(&[(from_fp1, to_fp1), (from_fp2, to_fp2)])
3845            .expect("failed to batch move thumbnails");
3846
3847        assert!(
3848            libdb
3849                .get_thumbnail(from_fp1)
3850                .expect("failed to get old thumbnail 1")
3851                .is_none()
3852        );
3853        assert!(
3854            libdb
3855                .get_thumbnail(from_fp2)
3856                .expect("failed to get old thumbnail 2")
3857                .is_none()
3858        );
3859        assert_eq!(
3860            libdb
3861                .get_thumbnail(to_fp1)
3862                .expect("failed to get new thumbnail 1"),
3863            Some(vec![1, 1, 1])
3864        );
3865        assert_eq!(
3866            libdb
3867                .get_thumbnail(to_fp2)
3868                .expect("failed to get new thumbnail 2"),
3869            Some(vec![2, 2, 2])
3870        );
3871    }
3872
3873    #[test]
3874    fn test_list_book_handles_and_update_book_path() {
3875        let (_db, libdb) = create_test_db();
3876        let library_id = register_test_library(&libdb, "/tmp/test_library_handles", "Handles");
3877
3878        let fp = Fp::from_str("0000000000000111").unwrap();
3879        libdb
3880            .insert_book(library_id, fp, &make_info("old/path.pdf", "Book", "Author"))
3881            .expect("failed to insert book");
3882
3883        let handles = libdb
3884            .list_book_handles(library_id)
3885            .expect("failed to list handles");
3886        assert_eq!(handles.len(), 1);
3887        assert_eq!(handles[0].fp, fp);
3888        assert_eq!(handles[0].relat, PathBuf::from("old/path.pdf"));
3889        assert_eq!(handles[0].abs, PathBuf::from(""));
3890
3891        libdb
3892            .update_book_path(
3893                library_id,
3894                fp,
3895                Path::new("new/path.pdf"),
3896                Path::new("/abs/new/path.pdf"),
3897            )
3898            .expect("failed to update book path");
3899
3900        let updated = libdb
3901            .get_book_by_fingerprint(library_id, fp)
3902            .expect("failed to get updated book")
3903            .expect("book should exist");
3904        assert_eq!(updated.file.path, PathBuf::from("new/path.pdf"));
3905        assert_eq!(
3906            updated.file.absolute_path,
3907            PathBuf::from("/abs/new/path.pdf")
3908        );
3909
3910        let handles = libdb
3911            .list_book_handles(library_id)
3912            .expect("failed to list handles after update");
3913        assert_eq!(handles.len(), 1);
3914        assert_eq!(handles[0].fp, fp);
3915        assert_eq!(handles[0].relat, PathBuf::from("new/path.pdf"));
3916        assert_eq!(handles[0].abs, PathBuf::from("/abs/new/path.pdf"));
3917    }
3918
3919    #[test]
3920    fn test_batch_update_book_paths() {
3921        let (_db, libdb) = create_test_db();
3922        let library_id =
3923            register_test_library(&libdb, "/tmp/test_library_batch_paths", "Batch Paths");
3924
3925        let fp1 = Fp::from_str("0000000000000121").unwrap();
3926        let fp2 = Fp::from_str("0000000000000122").unwrap();
3927
3928        libdb
3929            .insert_book(library_id, fp1, &make_info("old/one.pdf", "One", "Author"))
3930            .expect("failed to insert first book");
3931        libdb
3932            .insert_book(library_id, fp2, &make_info("old/two.pdf", "Two", "Author"))
3933            .expect("failed to insert second book");
3934
3935        let fp1_mtime = UnixTimestamp::from(1_700_000_001);
3936        let fp1_size = FileSize::from(111);
3937        let fp2_mtime = UnixTimestamp::from(1_700_000_002);
3938        let fp2_size = FileSize::from(222);
3939
3940        libdb
3941            .batch_update_book_paths(
3942                library_id,
3943                &[
3944                    PathUpdate {
3945                        fp: fp1,
3946                        relat: PathBuf::from("new/one.pdf"),
3947                        abs: PathBuf::from("/abs/new/one.pdf"),
3948                        mtime: Some(fp1_mtime),
3949                        file_size: Some(fp1_size),
3950                    },
3951                    PathUpdate {
3952                        fp: fp2,
3953                        relat: PathBuf::from("new/two.pdf"),
3954                        abs: PathBuf::from("/abs/new/two.pdf"),
3955                        mtime: Some(fp2_mtime),
3956                        file_size: Some(fp2_size),
3957                    },
3958                ],
3959            )
3960            .expect("failed to batch update book paths");
3961
3962        let updated1 = libdb
3963            .get_book_by_fingerprint(library_id, fp1)
3964            .expect("failed to get first updated book")
3965            .expect("first book should exist");
3966        let updated2 = libdb
3967            .get_book_by_fingerprint(library_id, fp2)
3968            .expect("failed to get second updated book")
3969            .expect("second book should exist");
3970
3971        assert_eq!(updated1.file.path, PathBuf::from("new/one.pdf"));
3972        assert_eq!(
3973            updated1.file.absolute_path,
3974            PathBuf::from("/abs/new/one.pdf")
3975        );
3976        assert_eq!(updated2.file.path, PathBuf::from("new/two.pdf"));
3977        assert_eq!(
3978            updated2.file.absolute_path,
3979            PathBuf::from("/abs/new/two.pdf")
3980        );
3981
3982        let handles = libdb
3983            .list_book_handles(library_id)
3984            .expect("failed to list handles");
3985        let h1 = handles.iter().find(|h| h.fp == fp1).expect("missing fp1");
3986        let h2 = handles.iter().find(|h| h.fp == fp2).expect("missing fp2");
3987        assert_eq!(h1.mtime, Some(fp1_mtime));
3988        assert_eq!(h1.file_size, Some(fp1_size));
3989        assert_eq!(h2.mtime, Some(fp2_mtime));
3990        assert_eq!(h2.file_size, Some(fp2_size));
3991    }
3992
3993    #[test]
3994    fn test_batch_delete_books() {
3995        let (_db, libdb) = create_test_db();
3996        let library_id = register_test_library(&libdb, "/tmp/test_library11", "Test Library 11");
3997
3998        let mut fps = Vec::new();
3999        for i in 1..=4 {
4000            let fp = Fp::from_u64((i + 300) as u64);
4001            let info = Info {
4002                title: format!("Delete Book {}", i),
4003                author: format!("Delete Author {}", i),
4004                file: FileInfo {
4005                    path: PathBuf::from(format!("/tmp/delete{}.pdf", i)),
4006                    kind: "pdf".to_string(),
4007                    size: (i * 100) as u64,
4008                    ..Default::default()
4009                },
4010                ..Default::default()
4011            };
4012            libdb
4013                .insert_book(library_id, fp, &info)
4014                .expect("failed to insert book");
4015            fps.push(fp);
4016        }
4017
4018        let all_books = libdb
4019            .get_all_books(library_id)
4020            .expect("failed to get books");
4021        assert_eq!(all_books.len(), 4);
4022
4023        libdb
4024            .batch_delete_books(library_id, &fps[0..2])
4025            .expect("failed to batch delete books");
4026
4027        let remaining_books = libdb
4028            .get_all_books(library_id)
4029            .expect("failed to get books");
4030        assert_eq!(remaining_books.len(), 2);
4031        assert!(remaining_books.iter().all(|info| {
4032            let fp = info.fp.expect("book should have fingerprint");
4033            fp == fps[2] || fp == fps[3]
4034        }));
4035    }
4036
4037    #[test]
4038    fn test_batch_operations_with_empty_input() {
4039        let (_db, libdb) = create_test_db();
4040        let library_id = register_test_library(&libdb, "/tmp/test_library12", "Test Library 12");
4041
4042        let empty_books: Vec<(Fp, &Info)> = Vec::new();
4043        let empty_fps: Vec<Fp> = Vec::new();
4044
4045        libdb
4046            .batch_insert_books(library_id, &empty_books)
4047            .expect("empty batch insert should succeed");
4048        libdb
4049            .batch_update_books(library_id, &empty_books)
4050            .expect("empty batch update should succeed");
4051        libdb
4052            .batch_delete_books(library_id, &empty_fps)
4053            .expect("empty batch delete should succeed");
4054    }
4055
4056    #[test]
4057    fn test_categories_round_trip() {
4058        let (_db, libdb) = create_test_db();
4059        let fp = Fp::from_u64(0x99);
4060
4061        let info = Info {
4062            title: "Categorized Book".to_string(),
4063            author: "Cat Author".to_string(),
4064            file: FileInfo {
4065                path: PathBuf::from("/tmp/cat.pdf"),
4066                kind: "pdf".to_string(),
4067                size: 512,
4068                ..Default::default()
4069            },
4070            categories: ["Fiction", "Science", "History"]
4071                .iter()
4072                .map(|s| s.to_string())
4073                .collect(),
4074            ..Default::default()
4075        };
4076
4077        let library_id = libdb
4078            .register_library("/tmp/test_library_cat", "Cat Library")
4079            .expect("failed to register library");
4080        libdb
4081            .insert_book(library_id, fp, &info)
4082            .expect("failed to insert book");
4083
4084        let books = libdb
4085            .get_all_books(library_id)
4086            .expect("failed to get books");
4087        let retrieved = books
4088            .iter()
4089            .find(|info| info.fp == Some(fp))
4090            .cloned()
4091            .expect("book should exist");
4092
4093        assert_eq!(retrieved.categories, info.categories);
4094    }
4095
4096    #[test]
4097    fn test_categories_updated_on_update_book() {
4098        let (_db, libdb) = create_test_db();
4099        let fp = Fp::from_u64(0x9A);
4100
4101        let mut info = Info {
4102            title: "Updateable Book".to_string(),
4103            author: "Update Author".to_string(),
4104            file: FileInfo {
4105                path: PathBuf::from("/tmp/upd_cat.pdf"),
4106                kind: "pdf".to_string(),
4107                size: 512,
4108                ..Default::default()
4109            },
4110            categories: ["OldCat"].iter().map(|s| s.to_string()).collect(),
4111            ..Default::default()
4112        };
4113
4114        let library_id =
4115            register_test_library(&libdb, "/tmp/test_library_upd_cat", "Upd Cat Library");
4116        libdb
4117            .insert_book(library_id, fp, &info)
4118            .expect("failed to insert book");
4119
4120        info.categories = ["NewCat1", "NewCat2"]
4121            .iter()
4122            .map(|s| s.to_string())
4123            .collect();
4124        libdb
4125            .update_book(library_id, fp, &info)
4126            .expect("failed to update book");
4127
4128        let books = libdb
4129            .get_all_books(library_id)
4130            .expect("failed to get books");
4131        let retrieved = books
4132            .iter()
4133            .find(|info| info.fp == Some(fp))
4134            .cloned()
4135            .expect("book should exist");
4136
4137        assert_eq!(retrieved.categories, info.categories);
4138    }
4139
4140    #[test]
4141    fn most_recently_opened_reading_book_none_when_empty() {
4142        let (_db, libdb) = create_test_db();
4143        let library_id = register_test_library(&libdb, "/tmp/mro_empty", "MRO Empty");
4144        assert!(
4145            libdb
4146                .most_recently_opened_reading_book(library_id)
4147                .expect("query failed")
4148                .is_none()
4149        );
4150    }
4151
4152    #[test]
4153    fn most_recently_opened_reading_book_none_when_only_finished() {
4154        let (_db, libdb) = create_test_db();
4155        let library_id = register_test_library(&libdb, "/tmp/mro_finished", "MRO Finished");
4156        let fp = Fp::from_str("AA00000000000001").unwrap();
4157        let mut info = make_info("mro/finished.pdf", "Finished", "Author");
4158        info.reader_info = Some(ReaderInfo {
4159            current_page: 100,
4160            pages_count: 100,
4161            finished: true,
4162            ..Default::default()
4163        });
4164        libdb.insert_book(library_id, fp, &info).unwrap();
4165
4166        assert!(
4167            libdb
4168                .most_recently_opened_reading_book(library_id)
4169                .expect("query failed")
4170                .is_none()
4171        );
4172    }
4173
4174    #[test]
4175    fn most_recently_opened_reading_book_returns_unfinished() {
4176        let (_db, libdb) = create_test_db();
4177        let library_id = register_test_library(&libdb, "/tmp/mro_unfinished", "MRO Unfinished");
4178
4179        let fp1 = Fp::from_str("AA00000000000002").unwrap();
4180        let fp2 = Fp::from_str("AA00000000000003").unwrap();
4181
4182        let mut info1 = make_info("mro/a.pdf", "Older Book", "Author");
4183        info1.reader_info = Some(ReaderInfo {
4184            current_page: 10,
4185            pages_count: 200,
4186            ..Default::default()
4187        });
4188
4189        let mut info2 = make_info("mro/b.pdf", "Newer Book", "Author");
4190        // Sleep is not needed — the in-memory SQLite uses UnixTimestamp::now()
4191        // which has second granularity; we manipulate opened via save_reading_state.
4192        info2.reader_info = Some(ReaderInfo {
4193            current_page: 50,
4194            pages_count: 200,
4195            ..Default::default()
4196        });
4197
4198        libdb.insert_book(library_id, fp1, &info1).unwrap();
4199        libdb.insert_book(library_id, fp2, &info2).unwrap();
4200
4201        // Both unfinished — result should be one of them (not None).
4202        let result = libdb
4203            .most_recently_opened_reading_book(library_id)
4204            .expect("query failed");
4205        assert!(result.is_some());
4206        assert!(!result.unwrap().reader_info.unwrap().finished);
4207    }
4208
4209    #[test]
4210    fn most_recently_opened_reading_book_skips_never_opened() {
4211        let (_db, libdb) = create_test_db();
4212        let library_id = register_test_library(&libdb, "/tmp/mro_new", "MRO New");
4213
4214        // Book with no reading state (never opened).
4215        let fp = Fp::from_str("AA00000000000004").unwrap();
4216        libdb
4217            .insert_book(
4218                library_id,
4219                fp,
4220                &make_info("mro/new.pdf", "New Book", "Author"),
4221            )
4222            .unwrap();
4223
4224        assert!(
4225            libdb
4226                .most_recently_opened_reading_book(library_id)
4227                .expect("query failed")
4228                .is_none()
4229        );
4230    }
4231
4232    #[test]
4233    fn compute_sort_keys_empty_library_is_noop() {
4234        let (_db, libdb) = create_test_db();
4235        let library_id = register_test_library(&libdb, "/tmp/sort_empty", "Sort Empty");
4236        libdb.compute_sort_keys(library_id).expect("compute failed");
4237    }
4238
4239    #[test]
4240    fn compute_sort_keys_assigns_ranks_to_all_books() {
4241        let (_db, libdb) = create_test_db();
4242        let library_id = register_test_library(&libdb, "/tmp/sort_assign", "Sort Assign");
4243
4244        for i in 1u64..=3 {
4245            let fp = Fp::from_str(&format!("BB{:014X}", i)).unwrap();
4246            libdb
4247                .insert_book(
4248                    library_id,
4249                    fp,
4250                    &make_info(&format!("s/{i}.pdf"), &format!("Book {i}"), "Author"),
4251                )
4252                .unwrap();
4253        }
4254
4255        libdb.compute_sort_keys(library_id).expect("compute failed");
4256
4257        // After compute, page_books by Title should return all 3 in order.
4258        let (books, total) = libdb
4259            .page_books(library_id, Path::new(""), SortMethod::Title, false, 10, 0)
4260            .expect("page_books failed");
4261        assert_eq!(total, 3);
4262        assert_eq!(books.len(), 3);
4263    }
4264
4265    #[test]
4266    fn insert_sort_rank_places_new_book_between_neighbours() {
4267        let (_db, libdb) = create_test_db();
4268        let library_id = register_test_library(&libdb, "/tmp/sort_insert", "Sort Insert");
4269
4270        // Insert two books and compute initial sort ranks.
4271        let fp_a = Fp::from_str("CC00000000000001").unwrap();
4272        let fp_z = Fp::from_str("CC00000000000002").unwrap();
4273        let info_a = make_info("s/aardvark.pdf", "Aardvark", "Author");
4274        let info_z = make_info("s/zebra.pdf", "Zebra", "Author");
4275
4276        libdb.insert_book(library_id, fp_a, &info_a).unwrap();
4277        libdb.insert_book(library_id, fp_z, &info_z).unwrap();
4278        libdb.compute_sort_keys(library_id).unwrap();
4279
4280        // Insert a book that should land between the two alphabetically.
4281        let fp_m = Fp::from_str("CC00000000000003").unwrap();
4282        let info_m = make_info("s/mango.pdf", "Mango", "Author");
4283        libdb.insert_book(library_id, fp_m, &info_m).unwrap();
4284        libdb.insert_sort_rank(library_id, fp_m, &info_m).unwrap();
4285
4286        let (books, _) = libdb
4287            .page_books(library_id, Path::new(""), SortMethod::Title, false, 10, 0)
4288            .expect("page_books failed");
4289
4290        let titles: Vec<&str> = books.iter().map(|b| b.title.as_str()).collect();
4291        assert_eq!(titles, vec!["Aardvark", "Mango", "Zebra"]);
4292    }
4293
4294    #[test]
4295    fn insert_sort_rank_falls_back_to_full_recompute_when_gaps_exhausted() {
4296        let (_db, libdb) = create_test_db();
4297        let library_id = register_test_library(&libdb, "/tmp/sort_exhaust", "Sort Exhaust");
4298
4299        // Seed two books with ranks 1 and 2 (no room for a midpoint).
4300        let fp_a = Fp::from_str("DD00000000000001").unwrap();
4301        let fp_b = Fp::from_str("DD00000000000002").unwrap();
4302        libdb
4303            .insert_book(library_id, fp_a, &make_info("s/a.pdf", "Alpha", "Author"))
4304            .unwrap();
4305        libdb
4306            .insert_book(library_id, fp_b, &make_info("s/b.pdf", "Beta", "Author"))
4307            .unwrap();
4308        libdb.compute_sort_keys(library_id).unwrap();
4309
4310        // Drain the gap between Alpha (1000) and Beta (2000) by inserting many
4311        // "Am*" books — each midpoint halves the gap until it exhausts.
4312        for i in 1u64..=12 {
4313            let fp = Fp::from_str(&format!("DD{:014X}", i + 10)).unwrap();
4314            let title = format!("Am{i:012}");
4315            let info = make_info(&format!("s/am{i}.pdf"), &title, "Author");
4316            libdb.insert_book(library_id, fp, &info).unwrap();
4317            // insert_sort_rank will eventually fall back; just verify it doesn't panic.
4318            libdb.insert_sort_rank(library_id, fp, &info).unwrap();
4319        }
4320
4321        let (books, _) = libdb
4322            .page_books(library_id, Path::new(""), SortMethod::Title, false, 20, 0)
4323            .expect("page_books failed");
4324        // All books are present and the first is still Alpha.
4325        assert_eq!(books[0].title, "Alpha");
4326    }
4327
4328    fn insert_books_for_paging(libdb: &Db, library_id: i64) {
4329        let books = [
4330            (
4331                "p/a.pdf", "Alpha", "Zelda", "2020", "epub", 500u64, 100usize,
4332            ),
4333            ("p/b.pdf", "Beta", "Alpha", "2019", "pdf", 300, 50),
4334            ("p/c.pdf", "Gamma", "Mia", "2021", "epub", 700, 200),
4335        ];
4336        for (i, (path, title, author, year, kind, size, pages)) in books.iter().enumerate() {
4337            let fp = Fp::from_str(&format!("EE{:014X}", i + 1)).unwrap();
4338            let mut info = make_info(path, title, author);
4339            info.year = year.to_string();
4340            info.file.kind = kind.to_string();
4341            info.file.size = *size;
4342            info.reader_info = Some(ReaderInfo {
4343                current_page: pages / 2,
4344                pages_count: *pages,
4345                ..Default::default()
4346            });
4347            libdb.insert_book(library_id, fp, &info).unwrap();
4348        }
4349        libdb.compute_sort_keys(library_id).unwrap();
4350    }
4351
4352    #[test]
4353    fn page_books_sort_by_author() {
4354        let (_db, libdb) = create_test_db();
4355        let library_id = register_test_library(&libdb, "/tmp/pb_author", "PB Author");
4356        insert_books_for_paging(&libdb, library_id);
4357
4358        let (books, total) = libdb
4359            .page_books(library_id, Path::new(""), SortMethod::Author, false, 10, 0)
4360            .unwrap();
4361        assert_eq!(total, 3);
4362        assert_eq!(books[0].author, "Alpha");
4363    }
4364
4365    #[test]
4366    fn page_books_sort_by_year() {
4367        let (_db, libdb) = create_test_db();
4368        let library_id = register_test_library(&libdb, "/tmp/pb_year", "PB Year");
4369        insert_books_for_paging(&libdb, library_id);
4370
4371        let (books, _) = libdb
4372            .page_books(library_id, Path::new(""), SortMethod::Year, false, 10, 0)
4373            .unwrap();
4374        assert_eq!(books[0].year, "2019");
4375    }
4376
4377    #[test]
4378    fn page_books_sort_by_size() {
4379        let (_db, libdb) = create_test_db();
4380        let library_id = register_test_library(&libdb, "/tmp/pb_size", "PB Size");
4381        insert_books_for_paging(&libdb, library_id);
4382
4383        let (books, _) = libdb
4384            .page_books(library_id, Path::new(""), SortMethod::Size, false, 10, 0)
4385            .unwrap();
4386        assert_eq!(books[0].file.size, 300);
4387    }
4388
4389    #[test]
4390    fn page_books_sort_by_kind() {
4391        let (_db, libdb) = create_test_db();
4392        let library_id = register_test_library(&libdb, "/tmp/pb_kind", "PB Kind");
4393        insert_books_for_paging(&libdb, library_id);
4394
4395        let (books, _) = libdb
4396            .page_books(library_id, Path::new(""), SortMethod::Kind, false, 10, 0)
4397            .unwrap();
4398        // epub < pdf alphabetically
4399        assert_eq!(books[0].file.kind, "epub");
4400    }
4401
4402    #[test]
4403    fn page_books_sort_by_pages() {
4404        let (_db, libdb) = create_test_db();
4405        let library_id = register_test_library(&libdb, "/tmp/pb_pages", "PB Pages");
4406        insert_books_for_paging(&libdb, library_id);
4407
4408        let (books, _) = libdb
4409            .page_books(library_id, Path::new(""), SortMethod::Pages, false, 10, 0)
4410            .unwrap();
4411        assert_eq!(books[0].reader_info.as_ref().unwrap().pages_count, 50);
4412    }
4413
4414    #[test]
4415    fn page_books_sort_by_opened() {
4416        let (_db, libdb) = create_test_db();
4417        let library_id = register_test_library(&libdb, "/tmp/pb_opened", "PB Opened");
4418        insert_books_for_paging(&libdb, library_id);
4419
4420        // Should not panic even when opened is NULL for some books.
4421        let (books, total) = libdb
4422            .page_books(library_id, Path::new(""), SortMethod::Opened, false, 10, 0)
4423            .unwrap();
4424        assert_eq!(total, 3);
4425        assert_eq!(books.len(), 3);
4426    }
4427
4428    #[test]
4429    fn page_books_sort_by_added() {
4430        let (_db, libdb) = create_test_db();
4431        let library_id = register_test_library(&libdb, "/tmp/pb_added", "PB Added");
4432        insert_books_for_paging(&libdb, library_id);
4433
4434        let (books, _) = libdb
4435            .page_books(library_id, Path::new(""), SortMethod::Added, false, 10, 0)
4436            .unwrap();
4437        assert_eq!(books.len(), 3);
4438    }
4439
4440    #[test]
4441    fn page_books_sort_by_status() {
4442        let (_db, libdb) = create_test_db();
4443        let library_id = register_test_library(&libdb, "/tmp/pb_status", "PB Status");
4444
4445        let fp_new = Fp::from_str("FF00000000000001").unwrap();
4446        let fp_reading = Fp::from_str("FF00000000000002").unwrap();
4447        let fp_finished = Fp::from_str("FF00000000000003").unwrap();
4448
4449        libdb
4450            .insert_book(library_id, fp_new, &make_info("s/new.pdf", "New", "A"))
4451            .unwrap();
4452
4453        let mut reading = make_info("s/reading.pdf", "Reading", "A");
4454        reading.reader_info = Some(ReaderInfo {
4455            current_page: 10,
4456            pages_count: 100,
4457            finished: false,
4458            ..Default::default()
4459        });
4460        libdb.insert_book(library_id, fp_reading, &reading).unwrap();
4461
4462        let mut finished = make_info("s/finished.pdf", "Finished", "A");
4463        finished.reader_info = Some(ReaderInfo {
4464            current_page: 100,
4465            pages_count: 100,
4466            finished: true,
4467            ..Default::default()
4468        });
4469        libdb
4470            .insert_book(library_id, fp_finished, &finished)
4471            .unwrap();
4472
4473        let (books, _) = libdb
4474            .page_books(library_id, Path::new(""), SortMethod::Status, false, 10, 0)
4475            .unwrap();
4476        assert_eq!(books.len(), 3);
4477        // Finished first in ASC order
4478        assert_eq!(books[0].title, "Finished");
4479    }
4480
4481    #[test]
4482    fn page_books_sort_by_progress() {
4483        let (_db, libdb) = create_test_db();
4484        let library_id = register_test_library(&libdb, "/tmp/pb_progress", "PB Progress");
4485
4486        let fp_finished = Fp::from_str("FE00000000000001").unwrap();
4487        let fp_reading = Fp::from_str("FE00000000000002").unwrap();
4488
4489        let mut finished = make_info("s/fin.pdf", "Finished", "A");
4490        finished.reader_info = Some(ReaderInfo {
4491            current_page: 100,
4492            pages_count: 100,
4493            finished: true,
4494            ..Default::default()
4495        });
4496        libdb
4497            .insert_book(library_id, fp_finished, &finished)
4498            .unwrap();
4499
4500        let mut reading = make_info("s/read.pdf", "Reading", "A");
4501        reading.reader_info = Some(ReaderInfo {
4502            current_page: 50,
4503            pages_count: 100,
4504            finished: false,
4505            ..Default::default()
4506        });
4507        libdb.insert_book(library_id, fp_reading, &reading).unwrap();
4508
4509        let (books, _) = libdb
4510            .page_books(
4511                library_id,
4512                Path::new(""),
4513                SortMethod::Progress,
4514                false,
4515                10,
4516                0,
4517            )
4518            .unwrap();
4519        assert_eq!(books.len(), 2);
4520        assert_eq!(books[0].title, "Finished");
4521    }
4522
4523    #[test]
4524    fn page_books_reverse_order() {
4525        let (_db, libdb) = create_test_db();
4526        let library_id = register_test_library(&libdb, "/tmp/pb_reverse", "PB Reverse");
4527        insert_books_for_paging(&libdb, library_id);
4528
4529        let (asc, _) = libdb
4530            .page_books(library_id, Path::new(""), SortMethod::Title, false, 10, 0)
4531            .unwrap();
4532        let (desc, _) = libdb
4533            .page_books(library_id, Path::new(""), SortMethod::Title, true, 10, 0)
4534            .unwrap();
4535
4536        assert_eq!(asc[0].title, desc[desc.len() - 1].title);
4537        assert_eq!(asc[asc.len() - 1].title, desc[0].title);
4538    }
4539
4540    #[test]
4541    fn page_books_pagination_offset() {
4542        let (_db, libdb) = create_test_db();
4543        let library_id = register_test_library(&libdb, "/tmp/pb_pagination", "PB Pagination");
4544        insert_books_for_paging(&libdb, library_id);
4545        libdb.compute_sort_keys(library_id).unwrap();
4546
4547        let (page1, total) = libdb
4548            .page_books(library_id, Path::new(""), SortMethod::Title, false, 2, 0)
4549            .unwrap();
4550        let (page2, _) = libdb
4551            .page_books(library_id, Path::new(""), SortMethod::Title, false, 2, 2)
4552            .unwrap();
4553
4554        assert_eq!(total, 3);
4555        assert_eq!(page1.len(), 2);
4556        assert_eq!(page2.len(), 1);
4557        assert_ne!(page1[0].title, page2[0].title);
4558    }
4559
4560    #[test]
4561    fn parse_zoom_mode_none_returns_none() {
4562        assert!(Db::parse_zoom_mode(None).is_none());
4563    }
4564
4565    #[test]
4566    fn parse_zoom_mode_invalid_json_returns_none() {
4567        assert!(Db::parse_zoom_mode(Some(&"not-valid-json".to_string())).is_none());
4568    }
4569
4570    #[test]
4571    fn parse_scroll_mode_none_returns_none() {
4572        assert!(Db::parse_scroll_mode(None).is_none());
4573    }
4574
4575    #[test]
4576    fn parse_scroll_mode_invalid_json_returns_none() {
4577        assert!(Db::parse_scroll_mode(Some(&"{{bad}}".to_string())).is_none());
4578    }
4579
4580    #[test]
4581    fn parse_text_align_none_returns_none() {
4582        assert!(Db::parse_text_align(None).is_none());
4583    }
4584
4585    #[test]
4586    fn parse_text_align_invalid_json_returns_none() {
4587        assert!(Db::parse_text_align(Some(&"???".to_string())).is_none());
4588    }
4589
4590    #[test]
4591    fn parse_cropping_margins_none_returns_none() {
4592        assert!(Db::parse_cropping_margins(None).is_none());
4593    }
4594
4595    #[test]
4596    fn parse_cropping_margins_invalid_json_returns_none() {
4597        assert!(Db::parse_cropping_margins(Some(&"bad".to_string())).is_none());
4598    }
4599
4600    #[test]
4601    fn parse_page_names_none_returns_empty_map() {
4602        assert!(Db::parse_page_names(None).is_empty());
4603    }
4604
4605    #[test]
4606    fn parse_page_names_invalid_json_returns_empty_map() {
4607        assert!(Db::parse_page_names(Some(&"!".to_string())).is_empty());
4608    }
4609
4610    #[test]
4611    fn parse_bookmarks_none_returns_empty_set() {
4612        assert!(Db::parse_bookmarks(None).is_empty());
4613    }
4614
4615    #[test]
4616    fn parse_bookmarks_invalid_json_returns_empty_set() {
4617        assert!(Db::parse_bookmarks(Some(&"!".to_string())).is_empty());
4618    }
4619
4620    #[test]
4621    fn parse_annotations_none_returns_empty_vec() {
4622        assert!(Db::parse_annotations(None).is_empty());
4623    }
4624
4625    #[test]
4626    fn parse_annotations_invalid_json_returns_empty_vec() {
4627        assert!(Db::parse_annotations(Some(&"!".to_string())).is_empty());
4628    }
4629
4630    #[test]
4631    fn parse_page_offset_both_some_returns_point() {
4632        let p = Db::parse_page_offset(Some(3), Some(7));
4633        assert!(p.is_some());
4634        let p = p.unwrap();
4635        assert_eq!(p.x, 3);
4636        assert_eq!(p.y, 7);
4637    }
4638
4639    #[test]
4640    fn parse_page_offset_one_none_returns_none() {
4641        assert!(Db::parse_page_offset(Some(1), None).is_none());
4642        assert!(Db::parse_page_offset(None, Some(1)).is_none());
4643        assert!(Db::parse_page_offset(None, None).is_none());
4644    }
4645
4646    #[test]
4647    fn extract_authors_none_returns_empty_string() {
4648        assert_eq!(Db::extract_authors(None), "");
4649    }
4650
4651    #[test]
4652    fn extract_authors_comma_separated_joins_with_space() {
4653        assert_eq!(
4654            Db::extract_authors(Some("Alice,Bob,Carol".to_string())),
4655            "Alice, Bob, Carol"
4656        );
4657    }
4658
4659    #[test]
4660    fn extract_categories_none_returns_empty_set() {
4661        assert!(Db::extract_categories(None).is_empty());
4662    }
4663
4664    #[test]
4665    fn extract_categories_filters_empty_strings() {
4666        let cats = Db::extract_categories(Some(",Fiction,,Science,".to_string()));
4667        assert_eq!(cats.len(), 2);
4668        assert!(cats.contains("Fiction"));
4669        assert!(cats.contains("Science"));
4670    }
4671
4672    #[test]
4673    fn test_batch_insert_with_reading_state() {
4674        let (_db, libdb) = create_test_db();
4675        let library_id = register_test_library(&libdb, "/tmp/test_library13", "Test Library 13");
4676
4677        let mut books = Vec::new();
4678        for i in 1..=3 {
4679            let fp = Fp::from_u64((i + 400) as u64);
4680            let reader_info = ReaderInfo {
4681                current_page: i * 10,
4682                pages_count: i * 100,
4683                finished: i % 2 == 0,
4684                ..Default::default()
4685            };
4686            let info = Info {
4687                title: format!("Book with State {}", i),
4688                author: format!("State Author {}", i),
4689                file: FileInfo {
4690                    path: PathBuf::from(format!("/tmp/state{}.pdf", i)),
4691                    kind: "pdf".to_string(),
4692                    size: (i * 100) as u64,
4693                    ..Default::default()
4694                },
4695                reader_info: Some(reader_info),
4696                ..Default::default()
4697            };
4698
4699            books.push((fp, info));
4700        }
4701
4702        let book_refs: Vec<(Fp, &Info)> = books.iter().map(|(fp, info)| (*fp, info)).collect();
4703
4704        libdb
4705            .batch_insert_books(library_id, &book_refs)
4706            .expect("failed to batch insert books with reading state");
4707
4708        let all_books = libdb
4709            .get_all_books(library_id)
4710            .expect("failed to get books");
4711        for (fp, info) in &books {
4712            let retrieved = all_books
4713                .iter()
4714                .find(|info| info.fp == Some(*fp))
4715                .cloned()
4716                .expect("book should exist");
4717            assert_eq!(retrieved.title, info.title);
4718
4719            assert!(
4720                retrieved.reader_info.is_some(),
4721                "reading state should exist"
4722            );
4723            let retrieved_state = retrieved.reader_info.unwrap();
4724            let original_state = info.reader_info.as_ref().unwrap();
4725            assert_eq!(retrieved_state.current_page, original_state.current_page);
4726            assert_eq!(retrieved_state.pages_count, original_state.pages_count);
4727            assert_eq!(retrieved_state.finished, original_state.finished);
4728        }
4729    }
4730
4731    #[test]
4732    fn delete_books_with_disallowed_kinds_removes_wrong_kind() {
4733        use crate::settings::FileExtension;
4734
4735        let (_db, libdb) = create_test_db();
4736        let library_id =
4737            register_test_library(&libdb, "/tmp/test_disallowed_kinds", "Disallowed Kinds");
4738
4739        let epub_fp = Fp::from_u64(9001);
4740        let pdf_fp = Fp::from_u64(9002);
4741
4742        let epub_info = Info {
4743            title: "Epub Book".to_string(),
4744            file: FileInfo {
4745                path: PathBuf::from("book.epub"),
4746                kind: "epub".to_string(),
4747                size: 100,
4748                ..Default::default()
4749            },
4750            ..Default::default()
4751        };
4752        let pdf_info = Info {
4753            title: "Pdf Book".to_string(),
4754            file: FileInfo {
4755                path: PathBuf::from("book.pdf"),
4756                kind: "pdf".to_string(),
4757                size: 200,
4758                ..Default::default()
4759            },
4760            ..Default::default()
4761        };
4762
4763        libdb
4764            .batch_insert_books(library_id, &[(epub_fp, &epub_info), (pdf_fp, &pdf_info)])
4765            .expect("insert books");
4766
4767        let mut allowed = FxHashSet::default();
4768        allowed.insert(FileExtension::Epub);
4769
4770        let purged = libdb
4771            .delete_books_with_disallowed_kinds(library_id, &allowed)
4772            .expect("purge disallowed");
4773
4774        assert_eq!(purged, vec![pdf_fp], "only pdf should be purged");
4775
4776        let handles = libdb.list_book_handles(library_id).expect("handles");
4777        let fps: Vec<Fp> = handles.iter().map(|h| h.fp).collect();
4778
4779        assert!(fps.contains(&epub_fp), "epub should remain");
4780        assert!(!fps.contains(&pdf_fp), "pdf should be gone");
4781    }
4782}