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