max / audiofiles
10 files changed,
+348 insertions,
-60 deletions
| @@ -670,7 +670,10 @@ impl AudioFilesApp { | |||
| 670 | 670 | } | |
| 671 | 671 | } | |
| 672 | 672 | VaultAction::ScanStorage => { | |
| 673 | - | browser.settings.storage_scanning = true; | |
| 673 | + | // storage_stats() is a single indexed COUNT/SUM — fast | |
| 674 | + | // enough to run inline; no in-progress flag needed (the | |
| 675 | + | // old one was set and cleared in the same frame, so its | |
| 676 | + | // spinner could never render). | |
| 674 | 677 | match browser.backend.storage_stats() { | |
| 675 | 678 | Ok(stats) => { | |
| 676 | 679 | browser.settings.storage_cache = Some(stats); | |
| @@ -683,7 +686,6 @@ impl AudioFilesApp { | |||
| 683 | 686 | } | |
| 684 | 687 | Err(e) => browser.status = format!("Storage scan failed: {e}"), | |
| 685 | 688 | } | |
| 686 | - | browser.settings.storage_scanning = false; | |
| 687 | 689 | } | |
| 688 | 690 | VaultAction::DeactivateLicense => { | |
| 689 | 691 | self.deactivate(); |
| @@ -65,6 +65,7 @@ pub struct DirectBackend { | |||
| 65 | 65 | analysis_worker: Mutex<Option<WorkerHandle>>, | |
| 66 | 66 | export_worker: Mutex<Option<ExportHandle>>, | |
| 67 | 67 | cleanup_worker: Mutex<Option<CleanupHandle>>, | |
| 68 | + | loose_files_worker: Mutex<Option<crate::loose_files_worker::LooseFilesHandle>>, | |
| 68 | 69 | edit_worker: Mutex<Option<EditWorkerHandle>>, | |
| 69 | 70 | forge_worker: Mutex<Option<audiofiles_core::forge::ForgeWorkerHandle>>, | |
| 70 | 71 | classifier_worker: Mutex<Option<crate::classifier_worker::ClassifierHandle>>, | |
| @@ -91,6 +92,7 @@ impl DirectBackend { | |||
| 91 | 92 | analysis_worker: Mutex::new(None), | |
| 92 | 93 | export_worker: Mutex::new(None), | |
| 93 | 94 | cleanup_worker: Mutex::new(None), | |
| 95 | + | loose_files_worker: Mutex::new(None), | |
| 94 | 96 | edit_worker: Mutex::new(None), | |
| 95 | 97 | forge_worker: Mutex::new(None), | |
| 96 | 98 | classifier_worker: Mutex::new(None), | |
| @@ -118,6 +120,24 @@ impl DirectBackend { | |||
| 118 | 120 | &self.data_dir | |
| 119 | 121 | } | |
| 120 | 122 | ||
| 123 | + | /// Lazily spawn the loose-files worker and send it a command. Keeps the | |
| 124 | + | /// integrity/relocate/purge filesystem work off the egui frame thread. | |
| 125 | + | fn loose_files_send( | |
| 126 | + | &self, | |
| 127 | + | cmd: crate::loose_files_worker::LooseFilesCommand, | |
| 128 | + | ) -> BackendResult<()> { | |
| 129 | + | let mut guard = self.loose_files_worker.lock(); | |
| 130 | + | if guard.is_none() { | |
| 131 | + | let db_path = self.data_dir.join("audiofiles.db"); | |
| 132 | + | let handle = crate::loose_files_worker::spawn_loose_files_worker(db_path).map_err( | |
| 133 | + | |e| BackendError::Other(format!("failed to spawn loose-files worker: {e}")), | |
| 134 | + | )?; | |
| 135 | + | *guard = Some(handle); | |
| 136 | + | } | |
| 137 | + | guard.as_ref().expect("loose-files worker present").send(cmd); | |
| 138 | + | Ok(()) | |
| 139 | + | } | |
| 140 | + | ||
| 121 | 141 | /// Lazily spawn the search worker on first use. It opens its own read-only | |
| 122 | 142 | /// connection to the database file (WAL allows concurrent reads). | |
| 123 | 143 | fn ensure_search_worker(&self) -> BackendResult<()> { | |
| @@ -906,6 +926,20 @@ impl Backend for DirectBackend { | |||
| 906 | 926 | Ok(audiofiles_core::store::relocate_missing_loose_files(&db, search_root)?) | |
| 907 | 927 | } | |
| 908 | 928 | ||
| 929 | + | fn start_loose_files_check(&self) -> BackendResult<()> { | |
| 930 | + | self.loose_files_send(crate::loose_files_worker::LooseFilesCommand::CheckIntegrity) | |
| 931 | + | } | |
| 932 | + | ||
| 933 | + | fn start_loose_files_relocate(&self, search_root: &std::path::Path) -> BackendResult<()> { | |
| 934 | + | self.loose_files_send(crate::loose_files_worker::LooseFilesCommand::Relocate( | |
| 935 | + | search_root.to_path_buf(), | |
| 936 | + | )) | |
| 937 | + | } | |
| 938 | + | ||
| 939 | + | fn start_loose_files_purge(&self) -> BackendResult<()> { | |
| 940 | + | self.loose_files_send(crate::loose_files_worker::LooseFilesCommand::Purge) | |
| 941 | + | } | |
| 942 | + | ||
| 909 | 943 | // --- Export --- | |
| 910 | 944 | ||
| 911 | 945 | fn collect_export_items( | |
| @@ -1559,6 +1593,27 @@ impl Backend for DirectBackend { | |||
| 1559 | 1593 | } | |
| 1560 | 1594 | } | |
| 1561 | 1595 | ||
| 1596 | + | // Poll loose-files worker | |
| 1597 | + | if let Some(ref worker) = *self.loose_files_worker.lock() { | |
| 1598 | + | use crate::loose_files_worker::LooseFilesEvent as Lfe; | |
| 1599 | + | while let Some(event) = worker.try_recv() { | |
| 1600 | + | events.push(match event { | |
| 1601 | + | Lfe::IntegrityResult { missing } => { | |
| 1602 | + | BackendEvent::LooseFilesIntegrity { missing } | |
| 1603 | + | } | |
| 1604 | + | Lfe::RelocateResult { | |
| 1605 | + | relocated, | |
| 1606 | + | still_missing, | |
| 1607 | + | } => BackendEvent::LooseFilesRelocated { | |
| 1608 | + | relocated, | |
| 1609 | + | still_missing, | |
| 1610 | + | }, | |
| 1611 | + | Lfe::PurgeResult { purged } => BackendEvent::LooseFilesPurged { purged }, | |
| 1612 | + | Lfe::Failed { message } => BackendEvent::LooseFilesFailed { message }, | |
| 1613 | + | }); | |
| 1614 | + | } | |
| 1615 | + | } | |
| 1616 | + | ||
| 1562 | 1617 | // Poll edit worker | |
| 1563 | 1618 | if let Some(ref worker) = *self.edit_worker.lock() { | |
| 1564 | 1619 | while let Some(event) = worker.try_recv() { |
| @@ -110,6 +110,21 @@ pub enum BackendEvent { | |||
| 110 | 110 | errors: usize, | |
| 111 | 111 | }, | |
| 112 | 112 | ||
| 113 | + | // Loose-files maintenance events | |
| 114 | + | LooseFilesIntegrity { | |
| 115 | + | missing: usize, | |
| 116 | + | }, | |
| 117 | + | LooseFilesRelocated { | |
| 118 | + | relocated: usize, | |
| 119 | + | still_missing: usize, | |
| 120 | + | }, | |
| 121 | + | LooseFilesPurged { | |
| 122 | + | purged: usize, | |
| 123 | + | }, | |
| 124 | + | LooseFilesFailed { | |
| 125 | + | message: String, | |
| 126 | + | }, | |
| 127 | + | ||
| 113 | 128 | // Edit events | |
| 114 | 129 | EditStarted { | |
| 115 | 130 | hash: String, | |
| @@ -609,6 +624,20 @@ pub trait Backend: Send + Sync { | |||
| 609 | 624 | search_root: &std::path::Path, | |
| 610 | 625 | ) -> BackendResult<(usize, usize)>; | |
| 611 | 626 | ||
| 627 | + | /// Start a background loose-files integrity check. The result arrives as a | |
| 628 | + | /// `BackendEvent::LooseFilesIntegrity` via [`Backend::poll_events`]. Off the | |
| 629 | + | /// GUI thread because it stats every loose source path. | |
| 630 | + | fn start_loose_files_check(&self) -> BackendResult<()>; | |
| 631 | + | ||
| 632 | + | /// Start a background relocate: walk `search_root`, hash-verify candidates, | |
| 633 | + | /// repoint missing samples. Result via `BackendEvent::LooseFilesRelocated`. | |
| 634 | + | /// Off the GUI thread because the walk + hashing is unbounded. | |
| 635 | + | fn start_loose_files_relocate(&self, search_root: &std::path::Path) -> BackendResult<()>; | |
| 636 | + | ||
| 637 | + | /// Start a background purge of loose-files samples whose source is missing. | |
| 638 | + | /// Result via `BackendEvent::LooseFilesPurged`. | |
| 639 | + | fn start_loose_files_purge(&self) -> BackendResult<()>; | |
| 640 | + | ||
| 612 | 641 | // --- Export --- | |
| 613 | 642 | ||
| 614 | 643 | /// Collect export items from a VFS subtree. |
| @@ -18,17 +18,21 @@ pub fn draw_browser( | |||
| 18 | 18 | let ctx = &ctx; | |
| 19 | 19 | theme::apply_theme(ctx); | |
| 20 | 20 | ||
| 21 | + | // Drain background worker events every frame, regardless of which screen is | |
| 22 | + | // showing. A completion event (loose-files check, classifier, etc.) can land | |
| 23 | + | // while any mode is active; polling only in some modes left those events | |
| 24 | + | // unread and the UI un-repainted until the next input. | |
| 25 | + | if state.poll_workers() { | |
| 26 | + | ctx.request_repaint(); | |
| 27 | + | } | |
| 28 | + | // Keep repainting while a background classifier job runs (no input would | |
| 29 | + | // otherwise repaint, so its completion event would sit unread). | |
| 30 | + | if state.classifier.busy.is_some() { | |
| 31 | + | ctx.request_repaint(); | |
| 32 | + | } | |
| 33 | + | ||
| 21 | 34 | match &state.import_mode { | |
| 22 | 35 | ImportMode::None => { | |
| 23 | - | // Poll edit worker events during normal browsing | |
| 24 | - | if state.poll_workers() { | |
| 25 | - | ctx.request_repaint(); | |
| 26 | - | } | |
| 27 | - | // Keep polling while a background classifier job runs (no input would | |
| 28 | - | // otherwise repaint, so its completion event would sit unread). | |
| 29 | - | if state.classifier.busy.is_some() { | |
| 30 | - | ctx.request_repaint(); | |
| 31 | - | } | |
| 32 | 36 | handle_keyboard(ctx, state); | |
| 33 | 37 | draw_normal_browser(ui, state, sync_manager); | |
| 34 | 38 | } | |
| @@ -39,9 +43,6 @@ pub fn draw_browser( | |||
| 39 | 43 | | ImportMode::Analyzing { .. } | |
| 40 | 44 | | ImportMode::Exporting { .. } | |
| 41 | 45 | | ImportMode::Cleaning { .. } => { | |
| 42 | - | if state.poll_workers() { | |
| 43 | - | ctx.request_repaint(); | |
| 44 | - | } | |
| 45 | 46 | match &state.import_mode { | |
| 46 | 47 | ImportMode::Importing { .. } => { | |
| 47 | 48 | import_screens::draw_import_progress(ui, state); |
| @@ -8,6 +8,7 @@ pub mod error; | |||
| 8 | 8 | pub mod export; | |
| 9 | 9 | pub mod import; | |
| 10 | 10 | pub mod instrument; | |
| 11 | + | pub mod loose_files_worker; | |
| 11 | 12 | pub mod preview; | |
| 12 | 13 | pub mod state; | |
| 13 | 14 | pub mod ui; |
| @@ -0,0 +1,174 @@ | |||
| 1 | + | //! Background worker for loose-files maintenance: integrity check, relocate, and | |
| 2 | + | //! purge. These walk the filesystem and hash candidate files, so running them on | |
| 3 | + | //! the egui thread froze the UI (ultra-fuzz UX finding). Mirrors `cleanup.rs`: | |
| 4 | + | //! a dedicated thread with its own Database, polled each frame via channels. | |
| 5 | + | ||
| 6 | + | use std::path::PathBuf; | |
| 7 | + | use std::sync::{mpsc, Mutex}; | |
| 8 | + | use std::thread; | |
| 9 | + | ||
| 10 | + | use tracing::{error, instrument}; | |
| 11 | + | ||
| 12 | + | use audiofiles_core::db::Database; | |
| 13 | + | ||
| 14 | + | /// Command from the GUI thread to the loose-files worker. | |
| 15 | + | pub enum LooseFilesCommand { | |
| 16 | + | /// Count loose-files samples whose source file is missing. | |
| 17 | + | CheckIntegrity, | |
| 18 | + | /// Walk `search_root` and repoint missing samples to matching files. | |
| 19 | + | Relocate(PathBuf), | |
| 20 | + | /// Remove loose-files samples whose source file is missing. | |
| 21 | + | Purge, | |
| 22 | + | /// Shut the worker down. | |
| 23 | + | Shutdown, | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | /// Event from the loose-files worker back to the GUI thread. | |
| 27 | + | pub enum LooseFilesEvent { | |
| 28 | + | /// Integrity check finished: `missing` source files absent. | |
| 29 | + | IntegrityResult { missing: usize }, | |
| 30 | + | /// Relocate finished. | |
| 31 | + | RelocateResult { relocated: usize, still_missing: usize }, | |
| 32 | + | /// Purge finished. | |
| 33 | + | PurgeResult { purged: usize }, | |
| 34 | + | /// An operation failed. | |
| 35 | + | Failed { message: String }, | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | /// Handle for communicating with the loose-files worker. | |
| 39 | + | /// | |
| 40 | + | /// The receiver is wrapped in a `Mutex` so `BrowserState` stays `Sync` (nih-plug); | |
| 41 | + | /// only the GUI thread calls `try_recv`, so contention is zero. | |
| 42 | + | pub struct LooseFilesHandle { | |
| 43 | + | cmd_tx: mpsc::Sender<LooseFilesCommand>, | |
| 44 | + | event_rx: Mutex<mpsc::Receiver<LooseFilesEvent>>, | |
| 45 | + | _thread: Option<thread::JoinHandle<()>>, | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | impl LooseFilesHandle { | |
| 49 | + | /// Poll for the next event without blocking. | |
| 50 | + | pub fn try_recv(&self) -> Option<LooseFilesEvent> { | |
| 51 | + | self.event_rx.lock().ok()?.try_recv().ok() | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | /// Send a command to the worker. | |
| 55 | + | pub fn send(&self, cmd: LooseFilesCommand) { | |
| 56 | + | let _ = self.cmd_tx.send(cmd); | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | impl Drop for LooseFilesHandle { | |
| 61 | + | fn drop(&mut self) { | |
| 62 | + | let _ = self.cmd_tx.send(LooseFilesCommand::Shutdown); | |
| 63 | + | if let Some(handle) = self._thread.take() { | |
| 64 | + | let _ = handle.join(); | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | /// Spawn the loose-files worker. It opens its own `Database` to avoid contending | |
| 70 | + | /// with the GUI connection. | |
| 71 | + | #[instrument(skip_all)] | |
| 72 | + | pub fn spawn_loose_files_worker(db_path: PathBuf) -> std::io::Result<LooseFilesHandle> { | |
| 73 | + | let (cmd_tx, cmd_rx) = mpsc::channel::<LooseFilesCommand>(); | |
| 74 | + | let (event_tx, event_rx) = mpsc::channel::<LooseFilesEvent>(); | |
| 75 | + | ||
| 76 | + | let thread = thread::Builder::new() | |
| 77 | + | .name("loose-files-worker".to_string()) | |
| 78 | + | .spawn(move || worker_loop(&cmd_rx, &event_tx, &db_path))?; | |
| 79 | + | ||
| 80 | + | Ok(LooseFilesHandle { | |
| 81 | + | cmd_tx, | |
| 82 | + | event_rx: Mutex::new(event_rx), | |
| 83 | + | _thread: Some(thread), | |
| 84 | + | }) | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | #[instrument(skip_all)] | |
| 88 | + | fn worker_loop( | |
| 89 | + | cmd_rx: &mpsc::Receiver<LooseFilesCommand>, | |
| 90 | + | event_tx: &mpsc::Sender<LooseFilesEvent>, | |
| 91 | + | db_path: &std::path::Path, | |
| 92 | + | ) { | |
| 93 | + | let db = match Database::open(db_path) { | |
| 94 | + | Ok(d) => d, | |
| 95 | + | Err(e) => { | |
| 96 | + | error!("Loose-files worker failed to open database: {e}"); | |
| 97 | + | let _ = event_tx.send(LooseFilesEvent::Failed { | |
| 98 | + | message: "could not open the library database".to_string(), | |
| 99 | + | }); | |
| 100 | + | return; | |
| 101 | + | } | |
| 102 | + | }; | |
| 103 | + | ||
| 104 | + | while let Ok(cmd) = cmd_rx.recv() { | |
| 105 | + | let event = match cmd { | |
| 106 | + | LooseFilesCommand::Shutdown => break, | |
| 107 | + | LooseFilesCommand::CheckIntegrity => { | |
| 108 | + | match audiofiles_core::store::check_loose_files_integrity(&db) { | |
| 109 | + | Ok((_valid, missing)) => LooseFilesEvent::IntegrityResult { missing }, | |
| 110 | + | Err(e) => LooseFilesEvent::Failed { | |
| 111 | + | message: e.to_string(), | |
| 112 | + | }, | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | LooseFilesCommand::Relocate(root) => { | |
| 116 | + | match audiofiles_core::store::relocate_missing_loose_files(&db, &root) { | |
| 117 | + | Ok((relocated, still_missing)) => LooseFilesEvent::RelocateResult { | |
| 118 | + | relocated, | |
| 119 | + | still_missing, | |
| 120 | + | }, | |
| 121 | + | Err(e) => LooseFilesEvent::Failed { | |
| 122 | + | message: e.to_string(), | |
| 123 | + | }, | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | LooseFilesCommand::Purge => { | |
| 127 | + | match audiofiles_core::store::purge_missing_loose_files(&db) { | |
| 128 | + | Ok(purged) => LooseFilesEvent::PurgeResult { purged }, | |
| 129 | + | Err(e) => LooseFilesEvent::Failed { | |
| 130 | + | message: e.to_string(), | |
| 131 | + | }, | |
| 132 | + | } | |
| 133 | + | } | |
| 134 | + | }; | |
| 135 | + | let _ = event_tx.send(event); | |
| 136 | + | } | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | #[cfg(test)] | |
| 140 | + | mod tests { | |
| 141 | + | use super::*; | |
| 142 | + | ||
| 143 | + | #[test] | |
| 144 | + | fn spawn_and_drop_does_not_hang() { | |
| 145 | + | let dir = tempfile::TempDir::new().unwrap(); | |
| 146 | + | let db_path = dir.path().join("audiofiles.db"); | |
| 147 | + | let _db = Database::open(&db_path).unwrap(); | |
| 148 | + | ||
| 149 | + | let handle = spawn_loose_files_worker(db_path).unwrap(); | |
| 150 | + | assert!(handle.try_recv().is_none()); | |
| 151 | + | drop(handle); | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | #[test] | |
| 155 | + | fn integrity_check_reports_result() { | |
| 156 | + | let dir = tempfile::TempDir::new().unwrap(); | |
| 157 | + | let db_path = dir.path().join("audiofiles.db"); | |
| 158 | + | let _db = Database::open(&db_path).unwrap(); | |
| 159 | + | ||
| 160 | + | let handle = spawn_loose_files_worker(db_path).unwrap(); | |
| 161 | + | handle.send(LooseFilesCommand::CheckIntegrity); | |
| 162 | + | ||
| 163 | + | let mut got = false; | |
| 164 | + | for _ in 0..50 { | |
| 165 | + | if let Some(LooseFilesEvent::IntegrityResult { missing }) = handle.try_recv() { | |
| 166 | + | assert_eq!(missing, 0); | |
| 167 | + | got = true; | |
| 168 | + | break; | |
| 169 | + | } | |
| 170 | + | std::thread::sleep(std::time::Duration::from_millis(20)); | |
| 171 | + | } | |
| 172 | + | assert!(got, "expected an IntegrityResult event"); | |
| 173 | + | } | |
| 174 | + | } |
| @@ -702,6 +702,40 @@ impl BrowserState { | |||
| 702 | 702 | return true; | |
| 703 | 703 | } | |
| 704 | 704 | ||
| 705 | + | // --- Loose-files maintenance events --- | |
| 706 | + | BackendEvent::LooseFilesIntegrity { missing } => { | |
| 707 | + | self.loose_files_missing_count = missing; | |
| 708 | + | if missing > 0 { | |
| 709 | + | self.show_loose_files_warning = true; | |
| 710 | + | } | |
| 711 | + | } | |
| 712 | + | BackendEvent::LooseFilesRelocated { | |
| 713 | + | relocated, | |
| 714 | + | still_missing, | |
| 715 | + | } => { | |
| 716 | + | self.loose_files_missing_count = still_missing; | |
| 717 | + | self.status = if relocated == 0 { | |
| 718 | + | "No matching files found in that folder.".to_string() | |
| 719 | + | } else if still_missing == 0 { | |
| 720 | + | format!("Relocated {relocated} samples. All missing files found.") | |
| 721 | + | } else { | |
| 722 | + | format!("Relocated {relocated} samples. {still_missing} still missing.") | |
| 723 | + | }; | |
| 724 | + | if still_missing == 0 { | |
| 725 | + | self.show_loose_files_warning = false; | |
| 726 | + | } | |
| 727 | + | self.refresh_contents(); | |
| 728 | + | } | |
| 729 | + | BackendEvent::LooseFilesPurged { purged } => { | |
| 730 | + | self.status = format!("Purged {purged} missing samples"); | |
| 731 | + | self.loose_files_missing_count = 0; | |
| 732 | + | self.show_loose_files_warning = false; | |
| 733 | + | self.refresh_contents(); | |
| 734 | + | } | |
| 735 | + | BackendEvent::LooseFilesFailed { message } => { | |
| 736 | + | self.status = format!("Loose-files operation failed: {message}"); | |
| 737 | + | } | |
| 738 | + | ||
| 705 | 739 | // --- Edit events --- | |
| 706 | 740 | BackendEvent::EditStarted { hash: _ } => { | |
| 707 | 741 | self.edit.in_progress = true; | |
| @@ -774,11 +808,11 @@ impl BrowserState { | |||
| 774 | 808 | // --- Search events (similarity / near-duplicate, off-thread) --- | |
| 775 | 809 | BackendEvent::SimilarResults { source, hashes } => { | |
| 776 | 810 | let refs: Vec<&str> = hashes.iter().map(String::as_str).collect(); | |
| 777 | - | self.apply_similarity_results(&source, &refs, "similar samples"); | |
| 811 | + | self.apply_similarity_results(&source, &refs, "similar samples", false); | |
| 778 | 812 | } | |
| 779 | 813 | BackendEvent::NearDuplicateResults { source, hashes } => { | |
| 780 | 814 | let refs: Vec<&str> = hashes.iter().map(String::as_str).collect(); | |
| 781 | - | self.apply_similarity_results(&source, &refs, "near-duplicates"); | |
| 815 | + | self.apply_similarity_results(&source, &refs, "near-duplicates", true); | |
| 782 | 816 | } | |
| 783 | 817 | BackendEvent::SearchError { error } => { | |
| 784 | 818 | self.status = format!("Search failed: {error}"); |
| @@ -40,32 +40,19 @@ impl BrowserState { | |||
| 40 | 40 | self.loose_files_missing_count = 0; | |
| 41 | 41 | return; | |
| 42 | 42 | } | |
| 43 | - | match self.backend.check_vault_integrity() { | |
| 44 | - | Ok((_valid, missing)) => { | |
| 45 | - | self.loose_files_missing_count = missing; | |
| 46 | - | if missing > 0 { | |
| 47 | - | self.show_loose_files_warning = true; | |
| 48 | - | } | |
| 49 | - | } | |
| 50 | - | Err(e) => { | |
| 51 | - | warn!("Loose-files integrity check failed: {e}"); | |
| 52 | - | } | |
| 43 | + | // Runs off the GUI thread: it stats every loose source path. The result | |
| 44 | + | // arrives as BackendEvent::LooseFilesIntegrity (see poll_workers). | |
| 45 | + | if let Err(e) = self.backend.start_loose_files_check() { | |
| 46 | + | warn!("Loose-files integrity check failed to start: {e}"); | |
| 53 | 47 | } | |
| 54 | 48 | } | |
| 55 | 49 | ||
| 56 | 50 | /// Purge all loose-files mode samples whose source files are missing. | |
| 57 | - | /// Refreshes the VFS listing afterward. | |
| 51 | + | /// Runs in the background; the VFS listing refreshes when the result lands. | |
| 58 | 52 | pub fn purge_missing_loose_files(&mut self) { | |
| 59 | - | match self.backend.purge_missing_loose_files() { | |
| 60 | - | Ok(purged) => { | |
| 61 | - | self.status = format!("Purged {purged} missing samples"); | |
| 62 | - | self.loose_files_missing_count = 0; | |
| 63 | - | self.show_loose_files_warning = false; | |
| 64 | - | self.refresh_contents(); | |
| 65 | - | } | |
| 66 | - | Err(e) => { | |
| 67 | - | self.status = format!("Purge failed: {e}"); | |
| 68 | - | } | |
| 53 | + | match self.backend.start_loose_files_purge() { | |
| 54 | + | Ok(()) => self.status = "Purging missing samples\u{2026}".to_string(), | |
| 55 | + | Err(e) => self.status = format!("Purge failed: {e}"), | |
| 69 | 56 | } | |
| 70 | 57 | } | |
| 71 | 58 | ||
| @@ -79,26 +66,12 @@ impl BrowserState { | |||
| 79 | 66 | /// `loose_files_missing_count` so the user can run Locate again against a | |
| 80 | 67 | /// different directory if some samples are still missing. | |
| 81 | 68 | pub fn locate_missing_loose_files(&mut self, search_root: std::path::PathBuf) { | |
| 82 | - | match self.backend.relocate_missing_loose_files(&search_root) { | |
| 83 | - | Ok((relocated, still_missing)) => { | |
| 84 | - | self.loose_files_missing_count = still_missing; | |
| 85 | - | self.status = if relocated == 0 { | |
| 86 | - | "No matching files found in that folder.".to_string() | |
| 87 | - | } else if still_missing == 0 { | |
| 88 | - | format!("Relocated {relocated} samples. All missing files found.") | |
| 89 | - | } else { | |
| 90 | - | format!( | |
| 91 | - | "Relocated {relocated} samples. {still_missing} still missing.", | |
| 92 | - | ) | |
| 93 | - | }; | |
| 94 | - | if still_missing == 0 { | |
| 95 | - | self.show_loose_files_warning = false; | |
| 96 | - | } | |
| 97 | - | self.refresh_contents(); | |
| 98 | - | } | |
| 99 | - | Err(e) => { | |
| 100 | - | self.status = format!("Could not locate sample on disk \u{2014} {e}"); | |
| 101 | - | } | |
| 69 | + | // Runs off the GUI thread: it walks `search_root` and SHA-256-verifies | |
| 70 | + | // candidates, which is unbounded. The result arrives as | |
| 71 | + | // BackendEvent::LooseFilesRelocated (see poll_workers). | |
| 72 | + | match self.backend.start_loose_files_relocate(&search_root) { | |
| 73 | + | Ok(()) => self.status = "Searching that folder for missing samples\u{2026}".to_string(), | |
| 74 | + | Err(e) => self.status = format!("Could not start locate \u{2014} {e}"), | |
| 102 | 75 | } | |
| 103 | 76 | } | |
| 104 | 77 | ||
| @@ -254,6 +227,7 @@ impl BrowserState { | |||
| 254 | 227 | pub fn find_similar(&mut self, hash: &str) { | |
| 255 | 228 | match self.backend.start_find_similar(hash, 50) { | |
| 256 | 229 | Ok(()) => { | |
| 230 | + | self.latest_similarity_request = Some((hash.to_string(), false)); | |
| 257 | 231 | self.status = "Searching for similar samples...".to_string(); | |
| 258 | 232 | } | |
| 259 | 233 | Err(e) => { | |
| @@ -267,6 +241,7 @@ impl BrowserState { | |||
| 267 | 241 | pub fn find_near_duplicates(&mut self, hash: &str) { | |
| 268 | 242 | match self.backend.start_find_near_duplicates(hash, 50) { | |
| 269 | 243 | Ok(()) => { | |
| 244 | + | self.latest_similarity_request = Some((hash.to_string(), true)); | |
| 270 | 245 | self.status = "Searching for near-duplicates...".to_string(); | |
| 271 | 246 | } | |
| 272 | 247 | Err(e) => { | |
| @@ -278,7 +253,20 @@ impl BrowserState { | |||
| 278 | 253 | /// Apply search results to the file list (called from the poll loop). | |
| 279 | 254 | /// `noun` is the human label for the status line ("similar samples" / | |
| 280 | 255 | /// "near-duplicates"). `hashes` are the result hashes in rank order. | |
| 281 | - | pub fn apply_similarity_results(&mut self, source_hash: &str, hashes: &[&str], noun: &str) { | |
| 256 | + | pub fn apply_similarity_results( | |
| 257 | + | &mut self, | |
| 258 | + | source_hash: &str, | |
| 259 | + | hashes: &[&str], | |
| 260 | + | noun: &str, | |
| 261 | + | is_near_dup: bool, | |
| 262 | + | ) { | |
| 263 | + | // Drop stale results: if the user fired another search after this one, | |
| 264 | + | // the request won't match and a slow query can't clobber the view. | |
| 265 | + | if self.latest_similarity_request.as_ref() | |
| 266 | + | != Some(&(source_hash.to_string(), is_near_dup)) | |
| 267 | + | { | |
| 268 | + | return; | |
| 269 | + | } | |
| 282 | 270 | let Some(vfs_id) = self.current_vfs_id() else { return }; | |
| 283 | 271 | let nodes = self | |
| 284 | 272 | .backend | |
| @@ -296,6 +284,7 @@ impl BrowserState { | |||
| 296 | 284 | pub fn clear_similarity_search(&mut self) { | |
| 297 | 285 | self.similarity_search_hash = None; | |
| 298 | 286 | self.similarity_source_name = None; | |
| 287 | + | self.latest_similarity_request = None; | |
| 299 | 288 | self.refresh_contents(); | |
| 300 | 289 | } | |
| 301 | 290 |
| @@ -139,6 +139,10 @@ pub struct BrowserState { | |||
| 139 | 139 | ||
| 140 | 140 | // Similarity search | |
| 141 | 141 | pub similarity_search_hash: Option<String>, | |
| 142 | + | /// The most recent similarity request as `(source_hash, is_near_dup)`. Async | |
| 143 | + | /// results that don't match it are stale (the user fired another search | |
| 144 | + | /// first) and are dropped, so a slow query can't replace the current view. | |
| 145 | + | pub latest_similarity_request: Option<(String, bool)>, | |
| 142 | 146 | /// Display name of the source sample for the active similarity / duplicate | |
| 143 | 147 | /// search. Cached so the breadcrumb can render "Similar to: <name>" without | |
| 144 | 148 | /// a backend lookup on every frame. | |
| @@ -489,6 +493,7 @@ impl BrowserState { | |||
| 489 | 493 | collection_filter_name_input: String::new(), | |
| 490 | 494 | filter_tag_input: String::new(), | |
| 491 | 495 | similarity_search_hash: None, | |
| 496 | + | latest_similarity_request: None, | |
| 492 | 497 | similarity_source_name: None, | |
| 493 | 498 | all_tags: Arc::new(all_tags), | |
| 494 | 499 | tag_search: String::new(), |
| @@ -269,8 +269,6 @@ pub struct SettingsUiState { | |||
| 269 | 269 | /// Unix timestamp (seconds) of the last successful storage scan, used to | |
| 270 | 270 | /// render a "last scanned N minutes ago" label so stale numbers are visible. | |
| 271 | 271 | pub storage_cache_at: Option<i64>, | |
| 272 | - | /// Whether a storage scan is in progress. | |
| 273 | - | pub storage_scanning: bool, | |
| 274 | 272 | /// Masked license key for display. | |
| 275 | 273 | pub license_key_masked: Option<String>, | |
| 276 | 274 | /// Machine ID for display. |