Skip to main content

cadmus_core/library/
importer.rs

1use crate::db::types::{FileSize, UnixTimestamp};
2use crate::document::file_kind;
3use crate::fl;
4use crate::helpers::{Fingerprint, Fp, IsHidden};
5use crate::library::db::{Db as LibraryDb, PathUpdate};
6use crate::metadata::{FileInfo, Info, extract_metadata_from_document};
7use crate::settings::ImportSettings;
8use crate::task::ShutdownSignal;
9use crate::view::{Event, NotificationEvent, ViewId};
10use fxhash::FxHashMap;
11use std::path::{Path, PathBuf};
12use std::sync::mpsc::Sender;
13use std::time::{Duration, Instant, UNIX_EPOCH};
14use tracing::{debug, error, info};
15use walkdir::{DirEntry, WalkDir};
16
17enum PendingRelocation {
18    FingerprintChanged {
19        new_fp: Fp,
20        old_fp: Fp,
21        file_size: u64,
22    },
23}
24
25impl PendingRelocation {
26    fn old_fp(&self) -> Fp {
27        match self {
28            PendingRelocation::FingerprintChanged { old_fp, .. } => *old_fp,
29        }
30    }
31}
32
33struct ProgressTracker {
34    last_sent: Instant,
35    last_percent: u8,
36    first_tick: bool,
37}
38
39impl ProgressTracker {
40    const SEND_INTERVAL_SEC: u64 = 2;
41
42    fn new() -> Self {
43        Self {
44            last_sent: Instant::now(),
45            last_percent: 0,
46            first_tick: true,
47        }
48    }
49
50    fn should_send(&mut self, idx: usize, total: usize, now: Instant) -> Option<u8> {
51        let percent = ((idx + 1) * 100).checked_div(total)?;
52        let percent = percent.min(100) as u8;
53
54        if self.first_tick
55            || percent == 100
56            || now.checked_duration_since(self.last_sent)
57                >= Some(Duration::from_secs(Self::SEND_INTERVAL_SEC))
58        {
59            self.last_sent = now;
60            self.last_percent = percent;
61            self.first_tick = false;
62            Some(percent)
63        } else {
64            None
65        }
66    }
67}
68
69struct ScanContext<'a> {
70    hub: &'a Sender<Event>,
71    notif_id: ViewId,
72    shutdown: &'a ShutdownSignal,
73}
74
75struct ScanResult {
76    books_to_insert: Vec<(Fp, Info)>,
77    path_updates: Vec<PathUpdate>,
78    books_to_delete: Vec<Fp>,
79    pending_relocations: Vec<PendingRelocation>,
80    thumbnails_to_delete: Vec<Fp>,
81}
82
83#[cfg(feature = "emulator")]
84const IGNORED_TOP_LEVEL_DIRS: &[&str] = &["target", "node_modules", "thirdparty"];
85
86#[cfg_attr(feature = "tracing", tracing::instrument(skip(home)))]
87fn walk_files(home: &Path) -> Vec<DirEntry> {
88    WalkDir::new(home)
89        .min_depth(1)
90        .into_iter()
91        .filter_entry(|e| {
92            if e.is_hidden() {
93                return false;
94            }
95            #[cfg(feature = "emulator")]
96            if e.depth() == 1 && e.file_type().is_dir() {
97                if let Some(name) = e.file_name().to_str() {
98                    if IGNORED_TOP_LEVEL_DIRS.contains(&name) {
99                        return false;
100                    }
101                }
102            }
103            true
104        })
105        .filter_map(|e| e.ok())
106        .filter(|e| !e.file_type().is_dir())
107        .collect()
108}
109
110#[cfg_attr(
111    feature = "tracing",
112    tracing::instrument(
113        skip(
114            home,
115            settings,
116            ctx,
117            tracker,
118            mtime_by_abs,
119            handles_by_fp,
120            handles_by_path
121        ),
122        fields(total)
123    )
124)]
125fn scan_entries(
126    home: &Path,
127    entries: &[DirEntry],
128    settings: &ImportSettings,
129    force: bool,
130    ctx: &ScanContext<'_>,
131    tracker: &mut ProgressTracker,
132    mtime_by_abs: &FxHashMap<PathBuf, (UnixTimestamp, FileSize)>,
133    handles_by_fp: &mut FxHashMap<Fp, (PathBuf, PathBuf)>,
134    handles_by_path: &mut FxHashMap<PathBuf, Fp>,
135) -> Option<ScanResult> {
136    let total = entries.len();
137    tracing::Span::current().record("total", total);
138    let mut skipped_count = 0u32;
139    let mut fingerprinted_count = 0u32;
140    let mut mtime_miss_count = 0u32;
141    debug!(mtime_map_size = mtime_by_abs.len(), "starting scan");
142
143    let mut books_to_insert: Vec<(Fp, Info)> = Vec::new();
144    let mut path_updates: Vec<PathUpdate> = Vec::new();
145    let mut books_to_delete: Vec<Fp> = Vec::new();
146    let mut pending_relocations: Vec<PendingRelocation> = Vec::new();
147    let mut thumbnails_to_delete: Vec<Fp> = Vec::new();
148
149    for (idx, entry) in entries.iter().enumerate() {
150        #[cfg(feature = "tracing")]
151        let _span = tracing::info_span!("procssing entry", entry = ?entry).entered();
152
153        if ctx.shutdown.should_stop() {
154            tracing::info!("import scan interrupted by shutdown");
155            return None;
156        }
157
158        let path = entry.path();
159        let relat = path.strip_prefix(home).unwrap_or(path);
160
161        let kind = file_kind(path);
162        let is_known_to_db = handles_by_path.contains_key(relat);
163        let allowed_kind = kind.filter(|k| settings.is_kind_allowed(*k));
164
165        if !is_known_to_db && allowed_kind.is_none() {
166            send_progress(ctx.hub, ctx.notif_id, tracker, idx, total);
167            continue;
168        }
169
170        let file_meta = match entry.metadata() {
171            Ok(m) => m,
172            Err(e) => {
173                error!(path = ?path, error = %e, "failed to read metadata, skipping");
174                send_progress(ctx.hub, ctx.notif_id, tracker, idx, total);
175                continue;
176            }
177        };
178
179        let current_size = FileSize::from(file_meta.len() as i64);
180        let current_mtime = file_meta
181            .modified()
182            .ok()
183            .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
184            .map(|d| UnixTimestamp::from((d.as_secs().div_ceil(2) * 2) as i64));
185
186        if !force && let Some(mtime) = current_mtime {
187            match mtime_by_abs.get(path) {
188                Some(&(stored_mtime, stored_size)) => {
189                    if stored_mtime == mtime && stored_size == current_size {
190                        skipped_count += 1;
191                        debug!(path = %relat.display(), "mtime and size unchanged, skipping fingerprint");
192                        send_progress(ctx.hub, ctx.notif_id, tracker, idx, total);
193                        continue;
194                    }
195                }
196                None => {
197                    mtime_miss_count += 1;
198                    debug!(
199                        path = %relat.display(),
200                        abs = %path.display(),
201                        "mtime lookup miss: file not in mtime_by_abs map"
202                    );
203                }
204            }
205        }
206
207        let fp = match path.fingerprint() {
208            Ok(fp) => {
209                fingerprinted_count += 1;
210                fp
211            }
212            Err(e) => {
213                error!(path = ?path, error = %e, "failed to compute fingerprint, skipping");
214                send_progress(ctx.hub, ctx.notif_id, tracker, idx, total);
215                continue;
216            }
217        };
218
219        if handles_by_fp.contains_key(&fp) {
220            let (stored_relat, stored_abs) = handles_by_fp[&fp].clone();
221            let path_changed = relat != stored_relat;
222            let abs_stale = path != stored_abs;
223
224            if path_changed {
225                debug!(
226                    fp = %fp,
227                    old_path = %stored_relat.display(),
228                    new_path = %relat.display(),
229                    "updated book path"
230                );
231                handles_by_path.remove(&stored_relat);
232                handles_by_fp.insert(fp, (relat.to_path_buf(), path.to_path_buf()));
233                handles_by_path.insert(relat.to_path_buf(), fp);
234            } else if abs_stale {
235                debug!(
236                    fp = %fp,
237                    path = %relat.display(),
238                    "healing stale absolute_path"
239                );
240                handles_by_fp.insert(fp, (relat.to_path_buf(), path.to_path_buf()));
241            }
242
243            path_updates.push(PathUpdate {
244                fp,
245                relat: relat.to_path_buf(),
246                abs: path.to_path_buf(),
247                mtime: current_mtime,
248                file_size: Some(current_size),
249            });
250
251            send_progress(ctx.hub, ctx.notif_id, tracker, idx, total);
252            continue;
253        }
254
255        if let Some(old_fp) = handles_by_path.get(relat).cloned() {
256            debug!(
257                path = %relat.display(),
258                old_fp = %old_fp,
259                new_fp = %fp,
260                "updated book fingerprint"
261            );
262
263            handles_by_fp.remove(&old_fp);
264            handles_by_path.remove(relat);
265            handles_by_fp.insert(fp, (relat.to_path_buf(), path.to_path_buf()));
266            handles_by_path.insert(relat.to_path_buf(), fp);
267            books_to_delete.push(old_fp);
268
269            pending_relocations.push(PendingRelocation::FingerprintChanged {
270                new_fp: fp,
271                old_fp,
272                file_size: i64::from(current_size) as u64,
273            });
274
275            thumbnails_to_delete.push(old_fp);
276            path_updates.push(PathUpdate {
277                fp,
278                relat: relat.to_path_buf(),
279                abs: path.to_path_buf(),
280                mtime: current_mtime,
281                file_size: Some(current_size),
282            });
283            send_progress(ctx.hub, ctx.notif_id, tracker, idx, total);
284            continue;
285        }
286
287        if let Some(kind) = allowed_kind {
288            info!(fp = %fp, path = %relat.display(), "added new entry");
289            let size = i64::from(current_size) as u64;
290            let mut book_info = Info {
291                file: FileInfo {
292                    path: relat.to_path_buf(),
293                    absolute_path: path.to_path_buf(),
294                    kind: kind.as_str().to_owned(),
295                    size,
296                    mtime: current_mtime,
297                },
298                ..Default::default()
299            };
300            if settings.metadata_kinds.contains(&book_info.file.kind) {
301                extract_metadata_from_document(home, &mut book_info);
302            }
303            handles_by_fp.insert(fp, (relat.to_path_buf(), path.to_path_buf()));
304            handles_by_path.insert(relat.to_path_buf(), fp);
305            books_to_insert.push((fp, book_info));
306        }
307
308        send_progress(ctx.hub, ctx.notif_id, tracker, idx, total);
309    }
310
311    info!(
312        total,
313        skipped = skipped_count,
314        fingerprinted = fingerprinted_count,
315        mtime_misses = mtime_miss_count,
316        "scan complete"
317    );
318
319    Some(ScanResult {
320        books_to_insert,
321        path_updates,
322        books_to_delete,
323        pending_relocations,
324        thumbnails_to_delete,
325    })
326}
327
328fn send_progress(
329    hub: &Sender<Event>,
330    notif_id: ViewId,
331    tracker: &mut ProgressTracker,
332    idx: usize,
333    total: usize,
334) {
335    let Some(percent) = tracker.should_send(idx, total, Instant::now()) else {
336        return;
337    };
338    debug!(percent, "import progress");
339    hub.send(Event::Notification(NotificationEvent::UpdateProgress(
340        notif_id, percent,
341    )))
342    .ok();
343}
344
345#[cfg_attr(
346    feature = "tracing",
347    tracing::instrument(skip(db, home, settings, pending_relocations, books_to_insert))
348)]
349fn resolve_relocations(
350    db: &LibraryDb,
351    library_id: i64,
352    home: &Path,
353    settings: &ImportSettings,
354    pending_relocations: Vec<PendingRelocation>,
355    books_to_insert: &mut Vec<(Fp, Info)>,
356) {
357    let old_fps: Vec<Fp> = pending_relocations
358        .iter()
359        .map(PendingRelocation::old_fp)
360        .collect();
361
362    let mut fetched = db
363        .batch_get_books_by_fingerprints(library_id, &old_fps)
364        .unwrap_or_default();
365
366    for relocation in pending_relocations {
367        match relocation {
368            PendingRelocation::FingerprintChanged {
369                new_fp,
370                old_fp,
371                file_size,
372            } => {
373                if let Some(mut info) = fetched.remove(&old_fp) {
374                    if settings.sync_metadata && settings.metadata_kinds.contains(&info.file.kind) {
375                        extract_metadata_from_document(home, &mut info);
376                    }
377                    info.file.size = file_size;
378                    books_to_insert.push((new_fp, info));
379                }
380            }
381        }
382    }
383}
384
385#[cfg_attr(feature = "tracing", tracing::instrument(skip(handles_by_fp, home)))]
386fn find_deleted_books(handles_by_fp: &FxHashMap<Fp, (PathBuf, PathBuf)>, home: &Path) -> Vec<Fp> {
387    handles_by_fp
388        .iter()
389        .filter(|(_, (relat, _))| relat.as_os_str().is_empty() || !home.join(relat).exists())
390        .map(|(fp, (relat, _))| {
391            info!(fp = %fp, path = %relat.display(), "removing deleted entry");
392            *fp
393        })
394        .collect()
395}
396
397#[cfg_attr(
398    feature = "tracing",
399    tracing::instrument(skip(
400        db,
401        books_to_insert,
402        path_updates,
403        books_to_delete,
404        thumbnails_to_delete
405    ))
406)]
407fn flush_to_db(
408    db: &LibraryDb,
409    library_id: i64,
410    books_to_insert: Vec<(Fp, Info)>,
411    path_updates: Vec<PathUpdate>,
412    books_to_delete: Vec<Fp>,
413    thumbnails_to_delete: Vec<Fp>,
414) {
415    if let Err(e) = db.batch_delete_thumbnails(&thumbnails_to_delete) {
416        error!(
417            error = %e,
418            count = thumbnails_to_delete.len(),
419            "batch delete thumbnails failed"
420        );
421    }
422
423    if !books_to_insert.is_empty() {
424        let book_refs: Vec<(Fp, &Info)> = books_to_insert
425            .iter()
426            .map(|(fp, info)| (*fp, info))
427            .collect();
428        if let Err(e) = db.batch_insert_books(library_id, &book_refs) {
429            error!(error = %e, count = book_refs.len(), "batch insert failed");
430        }
431    }
432
433    if let Err(e) = db.batch_update_book_paths(library_id, &path_updates) {
434        error!(
435            error = %e,
436            count = path_updates.len(),
437            "batch update book paths failed"
438        );
439    }
440
441    if !books_to_delete.is_empty() {
442        if let Err(e) = db.batch_delete_books(library_id, &books_to_delete) {
443            error!(error = %e, count = books_to_delete.len(), "batch delete failed");
444        }
445    }
446
447    if let Err(e) = db.compute_sort_keys(library_id) {
448        error!(error = %e, library_id, "failed to compute sort keys");
449    }
450}
451
452/// Runs a directory scan and syncs the database for one library.
453///
454/// When `force` is `false` (incremental mode), files whose stored `mtime` and
455/// `file_size` have not changed since the last import are skipped without
456/// re-fingerprinting. When `force` is `true` every file is re-fingerprinted
457/// regardless of its stored values.
458///
459/// Sends pinned progress notifications to `hub` via `notif_id` while running.
460/// Checks `shutdown` between entries and exits early if shutdown is requested.
461/// On completion or early exit, closes the notification and returns.
462#[cfg_attr(
463    feature = "tracing",
464    tracing::instrument(skip(db, settings, hub, notif_id, shutdown))
465)]
466pub fn run(
467    db: &LibraryDb,
468    library_id: i64,
469    home: &Path,
470    settings: &ImportSettings,
471    force: bool,
472    hub: &Sender<Event>,
473    notif_id: ViewId,
474    shutdown: &ShutdownSignal,
475) {
476    hub.send(Event::Notification(NotificationEvent::ShowPinned(
477        notif_id,
478        fl!("importer-importing-library"),
479    )))
480    .ok();
481
482    let handles = match db.list_book_handles(library_id) {
483        Ok(h) => h,
484        Err(e) => {
485            error!(error = %e, "failed to load book handles for import");
486            hub.send(Event::Close(notif_id)).ok();
487            return;
488        }
489    };
490
491    let mut handles_by_fp: FxHashMap<Fp, (PathBuf, PathBuf)> = handles
492        .iter()
493        .map(|h| (h.fp, (h.relat.clone(), h.abs.clone())))
494        .collect();
495    let mut handles_by_path: FxHashMap<PathBuf, Fp> =
496        handles.iter().map(|h| (h.relat.clone(), h.fp)).collect();
497    let mtime_by_abs: FxHashMap<PathBuf, (UnixTimestamp, FileSize)> = handles
498        .iter()
499        .filter_map(|h| {
500            let mtime = h.mtime?;
501            let size = h.file_size?;
502            Some((h.abs.clone(), (mtime, size)))
503        })
504        .collect();
505
506    let purged_fps = db
507        .delete_books_with_disallowed_kinds(library_id, &settings.allowed_kinds)
508        .unwrap_or_else(|e| {
509            error!(error = %e, "failed to purge disallowed books");
510            Vec::new()
511        });
512
513    for fp in &purged_fps {
514        if let Some((relat, _abs)) = handles_by_fp.remove(fp) {
515            handles_by_path.remove(&relat);
516        }
517    }
518
519    if !purged_fps.is_empty() {
520        if let Err(e) = db.batch_delete_thumbnails(&purged_fps) {
521            error!(error = %e, count = purged_fps.len(), "failed to delete thumbnails for purged books");
522        }
523    }
524
525    let entries = walk_files(home);
526
527    let ctx = ScanContext {
528        hub,
529        notif_id,
530        shutdown,
531    };
532
533    let mut tracker = ProgressTracker::new();
534
535    let Some(mut result) = scan_entries(
536        home,
537        &entries,
538        settings,
539        force,
540        &ctx,
541        &mut tracker,
542        &mtime_by_abs,
543        &mut handles_by_fp,
544        &mut handles_by_path,
545    ) else {
546        hub.send(Event::Close(notif_id)).ok();
547        return;
548    };
549
550    let mut deleted = find_deleted_books(&handles_by_fp, home);
551    result.books_to_delete.append(&mut deleted);
552
553    if !result.pending_relocations.is_empty() {
554        resolve_relocations(
555            db,
556            library_id,
557            home,
558            settings,
559            result.pending_relocations,
560            &mut result.books_to_insert,
561        );
562    }
563
564    flush_to_db(
565        db,
566        library_id,
567        result.books_to_insert,
568        result.path_updates,
569        result.books_to_delete,
570        result.thumbnails_to_delete,
571    );
572
573    hub.send(Event::Close(notif_id)).ok();
574}
575
576#[cfg(test)]
577mod tests {
578    use super::*;
579    use crate::db::Database;
580    use crate::library::Library;
581    use crate::metadata::{FileInfo, Info};
582    use crate::settings::ImportSettings;
583    use crate::task::ShutdownSignal;
584    use crate::view::ViewId;
585    use std::sync::mpsc;
586
587    fn create_migrated_db() -> Database {
588        let mut db = Database::new(":memory:").expect("in-memory db");
589        db.init(0).expect("migrations");
590        db
591    }
592
593    fn run_import(dir: &Path, db: &Database, shutdown: &ShutdownSignal) -> Vec<Event> {
594        let lib = Library::new(dir, db, "test").expect("failed to create library");
595        let (tx, rx) = mpsc::channel();
596        let notif_id = ViewId::MessageNotif(0);
597        run(
598            &lib.db,
599            lib.library_id,
600            dir,
601            &ImportSettings::default(),
602            false,
603            &tx,
604            notif_id,
605            shutdown,
606        );
607        drop(tx);
608        rx.try_iter().collect()
609    }
610
611    #[test]
612    fn imports_files_when_not_shutdown() {
613        let dir = tempfile::tempdir().expect("tempdir");
614        let db = create_migrated_db();
615        std::fs::write(dir.path().join("book.epub"), b"epub content").expect("write");
616
617        let shutdown = ShutdownSignal::never();
618        let events = run_import(dir.path(), &db, &shutdown);
619
620        assert!(
621            events.iter().any(|e| matches!(e, Event::Close(_))),
622            "expected Close event on normal completion"
623        );
624        assert!(
625            !events.iter().any(|e| matches!(
626                e,
627                Event::Notification(crate::view::NotificationEvent::UpdateProgress(_, 0))
628            )),
629            "progress should advance past 0"
630        );
631    }
632
633    #[test]
634    fn stops_early_when_shutdown_requested() {
635        let dir = tempfile::tempdir().expect("tempdir");
636        let db = create_migrated_db();
637
638        for i in 0..20 {
639            std::fs::write(dir.path().join(format!("book{i}.epub")), b"epub content")
640                .expect("write");
641        }
642
643        let (shutdown_tx, shutdown_rx) = mpsc::channel();
644        let shutdown = ShutdownSignal::new_for_test(shutdown_rx);
645
646        // Signal shutdown before the import starts so scan_entries exits immediately.
647        shutdown_tx.send(()).expect("send shutdown");
648
649        let lib = Library::new(dir.path(), &db, "test").expect("library");
650        let (tx, rx) = mpsc::channel();
651        let notif_id = ViewId::MessageNotif(0);
652        run(
653            &lib.db,
654            lib.library_id,
655            dir.path(),
656            &ImportSettings::default(),
657            false,
658            &tx,
659            notif_id,
660            &shutdown,
661        );
662        drop(tx);
663        let events: Vec<Event> = rx.try_iter().collect();
664
665        assert!(
666            events.iter().any(|e| matches!(e, Event::Close(_))),
667            "notif must be closed even on early exit"
668        );
669
670        let progress_events: Vec<_> = events
671            .iter()
672            .filter(|e| {
673                matches!(
674                    e,
675                    Event::Notification(crate::view::NotificationEvent::UpdateProgress(_, _))
676                )
677            })
678            .collect();
679        assert!(
680            progress_events.len() < 20,
681            "shutdown should have cut the scan short (got {} progress events)",
682            progress_events.len()
683        );
684    }
685
686    #[test]
687    fn progress_sends_at_100_percent_immediately() {
688        let mut tracker = ProgressTracker::new();
689        let base = Instant::now();
690
691        let sent = (0..100)
692            .filter_map(|i| tracker.should_send(i, 100, base))
693            .collect::<Vec<_>>();
694        assert_eq!(
695            sent,
696            vec![1, 100],
697            "Only beginning and end when loop is fast"
698        );
699
700        assert_eq!(tracker.should_send(99, 100, base), Some(100));
701    }
702
703    #[test]
704    fn progress_throttled_within_two_seconds() {
705        let mut tracker = ProgressTracker::new();
706        let base = Instant::now();
707
708        assert_eq!(tracker.should_send(0, 200, base), Some(0));
709
710        assert_eq!(
711            tracker.should_send(50, 200, base + Duration::from_millis(500)),
712            None
713        );
714        assert_eq!(
715            tracker.should_send(100, 200, base + Duration::from_secs(1)),
716            None
717        );
718    }
719
720    #[test]
721    fn progress_sends_after_two_second_gap() {
722        let mut tracker = ProgressTracker::new();
723        let base = Instant::now();
724
725        assert_eq!(tracker.should_send(0, 200, base), Some(0));
726
727        assert_eq!(
728            tracker.should_send(50, 200, base + Duration::from_secs(2)),
729            Some(25)
730        );
731
732        assert_eq!(
733            tracker.should_send(75, 200, base + Duration::from_secs(3)),
734            None
735        );
736
737        assert_eq!(
738            tracker.should_send(150, 200, base + Duration::from_secs(5)),
739            Some(75)
740        );
741    }
742
743    #[test]
744    fn finds_deleted_books_when_file_path_is_empty() {
745        let dir = tempfile::tempdir().expect("tempdir");
746        let db = create_migrated_db();
747        let lib = Library::new(dir.path(), &db, "test").expect("library");
748        let fp = Fp::from_u64(1);
749        let info = Info {
750            title: "test".to_string(),
751            file: FileInfo {
752                path: PathBuf::new(),
753                absolute_path: dir.path().join("missing.epub"),
754                kind: "epub".to_string(),
755                size: 1,
756                mtime: None,
757            },
758            ..Default::default()
759        };
760
761        lib.db
762            .batch_insert_books(lib.library_id, &[(fp, &info)])
763            .expect("insert library book");
764
765        let handles = lib.db.list_book_handles(lib.library_id).expect("handles");
766        let handles_by_fp: FxHashMap<Fp, (PathBuf, PathBuf)> = handles
767            .into_iter()
768            .map(|h| (h.fp, (h.relat, h.abs)))
769            .collect();
770
771        assert_eq!(find_deleted_books(&handles_by_fp, dir.path()), vec![fp]);
772    }
773
774    #[test]
775    fn skips_fingerprinting_disallowed_new_files() {
776        use crate::settings::FileExtension;
777        use fxhash::FxHashSet;
778
779        let dir = tempfile::tempdir().expect("tempdir");
780        let db = create_migrated_db();
781
782        std::fs::write(dir.path().join("book.epub"), b"epub content").expect("write epub");
783        std::fs::write(dir.path().join("ignore.xyz"), b"unsupported content").expect("write xyz");
784
785        let mut allowed: FxHashSet<FileExtension> = FxHashSet::default();
786        allowed.insert(FileExtension::Epub);
787        let settings = ImportSettings {
788            allowed_kinds: allowed,
789            ..ImportSettings::default()
790        };
791
792        let lib = Library::new(dir.path(), &db, "test").expect("library");
793        let (tx, rx) = std::sync::mpsc::channel();
794        let notif_id = ViewId::MessageNotif(0);
795        let shutdown = ShutdownSignal::never();
796
797        run(
798            &lib.db,
799            lib.library_id,
800            dir.path(),
801            &settings,
802            false,
803            &tx,
804            notif_id,
805            &shutdown,
806        );
807        drop(tx);
808        let _events: Vec<Event> = rx.try_iter().collect();
809
810        let handles = lib.db.list_book_handles(lib.library_id).expect("handles");
811        let paths: Vec<_> = handles.iter().map(|h| h.relat.clone()).collect();
812
813        assert!(
814            paths.iter().any(|p| p.ends_with("book.epub")),
815            "epub should be imported"
816        );
817        assert!(
818            !paths.iter().any(|p| p.ends_with("ignore.xyz")),
819            "unsupported kind should not be imported"
820        );
821    }
822
823    #[test]
824    fn purges_disallowed_books_on_import() {
825        use crate::settings::FileExtension;
826        use fxhash::FxHashSet;
827
828        let dir = tempfile::tempdir().expect("tempdir");
829        let db = create_migrated_db();
830
831        std::fs::write(dir.path().join("book.epub"), b"epub content").expect("write epub");
832        std::fs::write(dir.path().join("doc.pdf"), b"pdf content").expect("write pdf");
833
834        let lib = Library::new(dir.path(), &db, "test").expect("library");
835        let (tx, rx) = std::sync::mpsc::channel();
836        let notif_id = ViewId::MessageNotif(0);
837        let shutdown = ShutdownSignal::never();
838
839        run(
840            &lib.db,
841            lib.library_id,
842            dir.path(),
843            &ImportSettings::default(),
844            false,
845            &tx,
846            notif_id,
847            &shutdown,
848        );
849        drop(tx);
850        let _: Vec<Event> = rx.try_iter().collect();
851
852        let handles = lib.db.list_book_handles(lib.library_id).expect("handles");
853        assert_eq!(handles.len(), 2, "both files should be imported initially");
854
855        let mut epub_only: FxHashSet<FileExtension> = FxHashSet::default();
856        epub_only.insert(FileExtension::Epub);
857
858        let settings = ImportSettings {
859            allowed_kinds: epub_only,
860            ..ImportSettings::default()
861        };
862
863        let (tx2, rx2) = std::sync::mpsc::channel();
864        run(
865            &lib.db,
866            lib.library_id,
867            dir.path(),
868            &settings,
869            false,
870            &tx2,
871            notif_id,
872            &shutdown,
873        );
874        drop(tx2);
875        let _: Vec<Event> = rx2.try_iter().collect();
876
877        let handles = lib
878            .db
879            .list_book_handles(lib.library_id)
880            .expect("handles after purge");
881        let paths: Vec<_> = handles.iter().map(|h| h.relat.clone()).collect();
882
883        assert_eq!(handles.len(), 1, "only epub should remain after purge");
884        assert!(
885            paths.iter().any(|p| p.ends_with("book.epub")),
886            "epub should still be present"
887        );
888    }
889}