max / audiofiles
16 files changed,
+735 insertions,
-48 deletions
| @@ -553,7 +553,8 @@ impl Backend for DirectBackend { | |||
| 553 | 553 | } | |
| 554 | 554 | ||
| 555 | 555 | fn sample_path(&self, hash: &str, ext: &str) -> BackendResult<PathBuf> { | |
| 556 | - | Ok(self.store.sample_path(hash, ext)?) | |
| 556 | + | let db = self.db.lock(); | |
| 557 | + | Ok(audiofiles_core::store::resolve_file_path(&self.store, &db, hash, ext)?) | |
| 557 | 558 | } | |
| 558 | 559 | ||
| 559 | 560 | fn sample_extension(&self, hash: &str) -> BackendResult<String> { | |
| @@ -576,6 +577,26 @@ impl Backend for DirectBackend { | |||
| 576 | 577 | Ok(self.store.remove_orphaned_samples(&db)?) | |
| 577 | 578 | } | |
| 578 | 579 | ||
| 580 | + | fn sample_source_path(&self, hash: &str) -> BackendResult<Option<String>> { | |
| 581 | + | let db = self.db.lock(); | |
| 582 | + | Ok(audiofiles_core::store::sample_source_path(&db, hash)?) | |
| 583 | + | } | |
| 584 | + | ||
| 585 | + | fn relocate_sample(&self, hash: &str, new_path: &Path) -> BackendResult<()> { | |
| 586 | + | let db = self.db.lock(); | |
| 587 | + | Ok(audiofiles_core::store::relocate_sample(&self.store, &db, hash, new_path)?) | |
| 588 | + | } | |
| 589 | + | ||
| 590 | + | fn check_vault_integrity(&self) -> BackendResult<(usize, usize)> { | |
| 591 | + | let db = self.db.lock(); | |
| 592 | + | Ok(audiofiles_core::store::check_unsafe_integrity(&db)?) | |
| 593 | + | } | |
| 594 | + | ||
| 595 | + | fn purge_missing_unsafe(&self) -> BackendResult<usize> { | |
| 596 | + | let db = self.db.lock(); | |
| 597 | + | Ok(audiofiles_core::store::purge_missing_unsafe(&db)?) | |
| 598 | + | } | |
| 599 | + | ||
| 579 | 600 | // --- Export --- | |
| 580 | 601 | ||
| 581 | 602 | fn collect_export_items( | |
| @@ -691,17 +712,22 @@ impl Backend for DirectBackend { | |||
| 691 | 712 | sample_hashes: Vec<(String, String)>, | |
| 692 | 713 | config: AnalysisConfig, | |
| 693 | 714 | ) -> BackendResult<()> { | |
| 694 | - | let samples: Vec<(String, String, PathBuf)> = sample_hashes | |
| 695 | - | .into_iter() | |
| 696 | - | .filter_map(|(hash, ext)| { | |
| 697 | - | let path = self.store.sample_path(&hash, &ext).ok()?; | |
| 698 | - | if path.exists() { | |
| 699 | - | Some((hash, ext, path)) | |
| 700 | - | } else { | |
| 701 | - | None | |
| 702 | - | } | |
| 703 | - | }) | |
| 704 | - | .collect(); | |
| 715 | + | let samples: Vec<(String, String, PathBuf)> = { | |
| 716 | + | let db = self.db.lock(); | |
| 717 | + | sample_hashes | |
| 718 | + | .into_iter() | |
| 719 | + | .filter_map(|(hash, ext)| { | |
| 720 | + | let path = audiofiles_core::store::resolve_file_path( | |
| 721 | + | &self.store, &db, &hash, &ext, | |
| 722 | + | ).ok()?; | |
| 723 | + | if path.exists() { | |
| 724 | + | Some((hash, ext, path)) | |
| 725 | + | } else { | |
| 726 | + | None | |
| 727 | + | } | |
| 728 | + | }) | |
| 729 | + | .collect() | |
| 730 | + | }; | |
| 705 | 731 | ||
| 706 | 732 | let handle = audiofiles_core::analysis::worker::spawn_worker() | |
| 707 | 733 | .map_err(|e| BackendError::Other(format!("failed to spawn analysis worker: {e}")))?; | |
| @@ -751,7 +777,10 @@ impl Backend for DirectBackend { | |||
| 751 | 777 | } | |
| 752 | 778 | ||
| 753 | 779 | fn start_edit(&self, hash: &str, ext: &str, operation: EditOperation) -> BackendResult<()> { | |
| 754 | - | let path = self.store.sample_path(hash, ext)?; | |
| 780 | + | let path = { | |
| 781 | + | let db = self.db.lock(); | |
| 782 | + | audiofiles_core::store::resolve_file_path(&self.store, &db, hash, ext)? | |
| 783 | + | }; | |
| 755 | 784 | if !path.exists() { | |
| 756 | 785 | return Err(BackendError::Core( | |
| 757 | 786 | audiofiles_core::error::CoreError::SampleNotFound(hash.to_string()), | |
| @@ -831,8 +860,8 @@ impl Backend for DirectBackend { | |||
| 831 | 860 | if let Some(ref worker) = *self.import_worker.lock() { | |
| 832 | 861 | while let Some(event) = worker.try_recv() { | |
| 833 | 862 | match event { | |
| 834 | - | ImportEvent::WalkComplete { total } => { | |
| 835 | - | events.push(BackendEvent::ImportWalkComplete { total }); | |
| 863 | + | ImportEvent::WalkComplete { total, total_bytes } => { | |
| 864 | + | events.push(BackendEvent::ImportWalkComplete { total, total_bytes }); | |
| 836 | 865 | } | |
| 837 | 866 | ImportEvent::Progress { | |
| 838 | 867 | completed, |
| @@ -48,6 +48,7 @@ pub enum BackendEvent { | |||
| 48 | 48 | // Import events | |
| 49 | 49 | ImportWalkComplete { | |
| 50 | 50 | total: usize, | |
| 51 | + | total_bytes: u64, | |
| 51 | 52 | }, | |
| 52 | 53 | ImportProgress { | |
| 53 | 54 | completed: usize, | |
| @@ -354,6 +355,18 @@ pub trait Backend: Send + Sync { | |||
| 354 | 355 | /// Remove samples no longer referenced by any VFS node. Returns count removed. | |
| 355 | 356 | fn remove_orphaned_samples(&self) -> BackendResult<usize>; | |
| 356 | 357 | ||
| 358 | + | /// Look up the source_path for an unsafe-mode sample. Returns None for normal samples. | |
| 359 | + | fn sample_source_path(&self, hash: &str) -> BackendResult<Option<String>>; | |
| 360 | + | ||
| 361 | + | /// Relocate an unsafe-mode sample to a new path (verifies hash match). | |
| 362 | + | fn relocate_sample(&self, hash: &str, new_path: &Path) -> BackendResult<()>; | |
| 363 | + | ||
| 364 | + | /// Check integrity of unsafe-mode samples. Returns (valid, missing). | |
| 365 | + | fn check_vault_integrity(&self) -> BackendResult<(usize, usize)>; | |
| 366 | + | ||
| 367 | + | /// Delete all unsafe-mode samples whose source files are missing. Returns count purged. | |
| 368 | + | fn purge_missing_unsafe(&self) -> BackendResult<usize>; | |
| 369 | + | ||
| 357 | 370 | // --- Export --- | |
| 358 | 371 | ||
| 359 | 372 | /// Collect export items from a VFS subtree. |
| @@ -109,6 +109,9 @@ pub fn draw_browser( | |||
| 109 | 109 | if state.dir_rename_target.is_some() { | |
| 110 | 110 | overlays::draw_dir_rename_modal(ctx, state); | |
| 111 | 111 | } | |
| 112 | + | if state.show_unsafe_warning { | |
| 113 | + | overlays::draw_unsafe_warning(ctx, state); | |
| 114 | + | } | |
| 112 | 115 | ||
| 113 | 116 | // Settings window | |
| 114 | 117 | if state.settings.show_manager { |
| @@ -64,8 +64,8 @@ pub enum ImportCommand { | |||
| 64 | 64 | ||
| 65 | 65 | /// Event sent from the import worker back to the GUI thread. | |
| 66 | 66 | pub enum ImportEvent { | |
| 67 | - | /// Pre-walk finished — we now know the total file count. | |
| 68 | - | WalkComplete { total: usize }, | |
| 67 | + | /// Pre-walk finished — we now know the total file count and size. | |
| 68 | + | WalkComplete { total: usize, total_bytes: u64 }, | |
| 69 | 69 | /// One file was processed. | |
| 70 | 70 | Progress { | |
| 71 | 71 | completed: usize, | |
| @@ -139,11 +139,12 @@ pub fn spawn_import_worker(db_path: PathBuf, store_root: PathBuf) -> std::io::Re | |||
| 139 | 139 | }) | |
| 140 | 140 | } | |
| 141 | 141 | ||
| 142 | - | /// Recursively count audio files under `dir`. Checks for cancellation between entries. | |
| 143 | - | /// Returns `None` if cancelled. | |
| 142 | + | /// Recursively count audio files and sum their sizes under `dir`. | |
| 143 | + | /// Checks for cancellation between entries. Returns `None` if cancelled. | |
| 144 | 144 | #[instrument(skip_all)] | |
| 145 | - | fn count_audio_files(dir: &Path, cmd_rx: &mpsc::Receiver<ImportCommand>) -> Option<usize> { | |
| 145 | + | fn count_audio_files(dir: &Path, cmd_rx: &mpsc::Receiver<ImportCommand>) -> Option<(usize, u64)> { | |
| 146 | 146 | let mut count = 0; | |
| 147 | + | let mut total_bytes = 0u64; | |
| 147 | 148 | let mut stack = vec![dir.to_path_buf()]; | |
| 148 | 149 | ||
| 149 | 150 | while let Some(current) = stack.pop() { | |
| @@ -168,11 +169,14 @@ fn count_audio_files(dir: &Path, cmd_rx: &mpsc::Receiver<ImportCommand>) -> Opti | |||
| 168 | 169 | } | |
| 169 | 170 | } else if is_audio_file(&path) { | |
| 170 | 171 | count += 1; | |
| 172 | + | if let Ok(meta) = fs::metadata(&path) { | |
| 173 | + | total_bytes += meta.len(); | |
| 174 | + | } | |
| 171 | 175 | } | |
| 172 | 176 | } | |
| 173 | 177 | } | |
| 174 | 178 | ||
| 175 | - | Some(count) | |
| 179 | + | Some((count, total_bytes)) | |
| 176 | 180 | } | |
| 177 | 181 | ||
| 178 | 182 | /// Import a single file into store + VFS, returning the result. | |
| @@ -182,8 +186,13 @@ fn import_single_file( | |||
| 182 | 186 | parent_id: Option<NodeId>, | |
| 183 | 187 | store: &SampleStore, | |
| 184 | 188 | db: &Database, | |
| 189 | + | unsafe_mode: bool, | |
| 185 | 190 | ) -> Result<ImportFileResult, CoreError> { | |
| 186 | - | let hash = store.import(path, db)?; | |
| 191 | + | let hash = if unsafe_mode { | |
| 192 | + | store.import_unsafe(path, db)? | |
| 193 | + | } else { | |
| 194 | + | store.import(path, db)? | |
| 195 | + | }; | |
| 187 | 196 | let name = audiofiles_core::util::get_filename(path, "unknown"); | |
| 188 | 197 | let ext = audiofiles_core::util::get_extension(path); | |
| 189 | 198 | ||
| @@ -216,6 +225,7 @@ struct ImportContext<'a> { | |||
| 216 | 225 | errors: &'a mut usize, | |
| 217 | 226 | duplicates: &'a mut usize, | |
| 218 | 227 | imported: &'a mut Vec<(String, String)>, | |
| 228 | + | unsafe_mode: bool, | |
| 219 | 229 | } | |
| 220 | 230 | ||
| 221 | 231 | impl ImportContext<'_> { | |
| @@ -241,7 +251,7 @@ impl ImportContext<'_> { | |||
| 241 | 251 | let name = audiofiles_core::util::get_filename(path, "unknown"); | |
| 242 | 252 | self.send_progress(name); | |
| 243 | 253 | ||
| 244 | - | match import_single_file(path, vfs_id, parent_id, self.store, self.db) { | |
| 254 | + | match import_single_file(path, vfs_id, parent_id, self.store, self.db, self.unsafe_mode) { | |
| 245 | 255 | Ok(ImportFileResult::Imported(hash, ext)) => { | |
| 246 | 256 | self.imported.push((hash, ext)); | |
| 247 | 257 | *self.completed += 1; | |
| @@ -518,9 +528,9 @@ fn worker_loop( | |||
| 518 | 528 | } | |
| 519 | 529 | }; | |
| 520 | 530 | ||
| 521 | - | // Phase 1: pre-walk to count audio files | |
| 522 | - | let total = match count_audio_files(&source, &cmd_rx) { | |
| 523 | - | Some(t) => t, | |
| 531 | + | // Phase 1: pre-walk to count audio files and sum sizes | |
| 532 | + | let (total, total_bytes) = match count_audio_files(&source, &cmd_rx) { | |
| 533 | + | Some(result) => result, | |
| 524 | 534 | None => { | |
| 525 | 535 | let _ = event_tx.send(ImportEvent::Complete { | |
| 526 | 536 | imported: Vec::new(), | |
| @@ -533,7 +543,18 @@ fn worker_loop( | |||
| 533 | 543 | } | |
| 534 | 544 | }; | |
| 535 | 545 | ||
| 536 | - | let _ = event_tx.send(ImportEvent::WalkComplete { total }); | |
| 546 | + | let _ = event_tx.send(ImportEvent::WalkComplete { total, total_bytes }); | |
| 547 | + | ||
| 548 | + | // Check if unsafe mode is enabled for this vault | |
| 549 | + | let unsafe_mode = db | |
| 550 | + | .conn() | |
| 551 | + | .query_row( | |
| 552 | + | "SELECT value FROM user_config WHERE key = 'unsafe_mode'", | |
| 553 | + | [], | |
| 554 | + | |row| row.get::<_, String>(0), | |
| 555 | + | ) | |
| 556 | + | .ok() | |
| 557 | + | .is_some_and(|v| v == "1"); | |
| 537 | 558 | ||
| 538 | 559 | // Phase 2: import files with progress | |
| 539 | 560 | let mut completed = 0usize; | |
| @@ -551,6 +572,7 @@ fn worker_loop( | |||
| 551 | 572 | errors: &mut errors, | |
| 552 | 573 | duplicates: &mut duplicates, | |
| 553 | 574 | imported: &mut imported, | |
| 575 | + | unsafe_mode, | |
| 554 | 576 | }; | |
| 555 | 577 | ||
| 556 | 578 | let (cancelled, folders) = if flat { | |
| @@ -616,7 +638,7 @@ mod tests { | |||
| 616 | 638 | ||
| 617 | 639 | #[test] | |
| 618 | 640 | fn import_event_variants_constructible() { | |
| 619 | - | let _walk = ImportEvent::WalkComplete { total: 42 }; | |
| 641 | + | let _walk = ImportEvent::WalkComplete { total: 42, total_bytes: 1024 }; | |
| 620 | 642 | let _progress = ImportEvent::Progress { | |
| 621 | 643 | completed: 5, | |
| 622 | 644 | total: 42, |
| @@ -231,11 +231,14 @@ impl BrowserState { | |||
| 231 | 231 | ||
| 232 | 232 | self.import_file_errors.clear(); | |
| 233 | 233 | self.analysis_errors.clear(); | |
| 234 | + | let unsafe_mode = self.settings.is_unsafe_mode; | |
| 234 | 235 | self.import_mode = ImportMode::Importing { | |
| 235 | 236 | total: 0, | |
| 236 | 237 | completed: 0, | |
| 237 | 238 | current_name: String::new(), | |
| 238 | 239 | walking: true, | |
| 240 | + | total_bytes: 0, | |
| 241 | + | unsafe_mode, | |
| 239 | 242 | }; | |
| 240 | 243 | } | |
| 241 | 244 | ||
| @@ -252,12 +255,18 @@ impl BrowserState { | |||
| 252 | 255 | for event in events { | |
| 253 | 256 | match event { | |
| 254 | 257 | // --- Import events --- | |
| 255 | - | BackendEvent::ImportWalkComplete { total } => { | |
| 258 | + | BackendEvent::ImportWalkComplete { total, total_bytes } => { | |
| 259 | + | let unsafe_mode = matches!( | |
| 260 | + | &self.import_mode, | |
| 261 | + | ImportMode::Importing { unsafe_mode: true, .. } | |
| 262 | + | ); | |
| 256 | 263 | self.import_mode = ImportMode::Importing { | |
| 257 | 264 | total, | |
| 258 | 265 | completed: 0, | |
| 259 | 266 | current_name: String::new(), | |
| 260 | 267 | walking: false, | |
| 268 | + | total_bytes, | |
| 269 | + | unsafe_mode, | |
| 261 | 270 | }; | |
| 262 | 271 | } | |
| 263 | 272 | BackendEvent::ImportProgress { | |
| @@ -265,11 +274,17 @@ impl BrowserState { | |||
| 265 | 274 | total, | |
| 266 | 275 | current_name, | |
| 267 | 276 | } => { | |
| 277 | + | let (prev_bytes, unsafe_mode) = match &self.import_mode { | |
| 278 | + | ImportMode::Importing { total_bytes, unsafe_mode, .. } => (*total_bytes, *unsafe_mode), | |
| 279 | + | _ => (0, false), | |
| 280 | + | }; | |
| 268 | 281 | self.import_mode = ImportMode::Importing { | |
| 269 | 282 | total, | |
| 270 | 283 | completed, | |
| 271 | 284 | current_name, | |
| 272 | 285 | walking: false, | |
| 286 | + | total_bytes: prev_bytes, | |
| 287 | + | unsafe_mode, | |
| 273 | 288 | }; | |
| 274 | 289 | } | |
| 275 | 290 | BackendEvent::ImportFileError { path, error } => { | |
| @@ -628,6 +643,7 @@ impl BrowserState { | |||
| 628 | 643 | classification: None, | |
| 629 | 644 | duration: None, | |
| 630 | 645 | tags: Vec::new(), | |
| 646 | + | source_path: None, | |
| 631 | 647 | }); | |
| 632 | 648 | } | |
| 633 | 649 | } | |
| @@ -649,6 +665,7 @@ impl BrowserState { | |||
| 649 | 665 | classification: node.classification.clone(), | |
| 650 | 666 | duration: node.duration, | |
| 651 | 667 | tags: node.tags.clone(), | |
| 668 | + | source_path: None, | |
| 652 | 669 | }) | |
| 653 | 670 | }).collect() | |
| 654 | 671 | } else { |
| @@ -30,6 +30,48 @@ impl BrowserState { | |||
| 30 | 30 | self.selection.clear(); | |
| 31 | 31 | self.refresh_contents(); | |
| 32 | 32 | self.refresh_smart_folders(); | |
| 33 | + | self.check_unsafe_integrity(); | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | /// Run an integrity check for unsafe-mode vaults. Updates `unsafe_missing_count` | |
| 37 | + | /// and shows the warning overlay if any source files are missing. | |
| 38 | + | pub fn check_unsafe_integrity(&mut self) { | |
| 39 | + | if !self.settings.is_unsafe_mode { | |
| 40 | + | self.unsafe_missing_count = 0; | |
| 41 | + | return; | |
| 42 | + | } | |
| 43 | + | match self.backend.check_vault_integrity() { | |
| 44 | + | Ok((_valid, missing)) => { | |
| 45 | + | self.unsafe_missing_count = missing; | |
| 46 | + | if missing > 0 { | |
| 47 | + | self.show_unsafe_warning = true; | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | Err(e) => { | |
| 51 | + | warn!("Unsafe integrity check failed: {e}"); | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | /// Purge all unsafe-mode samples whose source files are missing. | |
| 57 | + | /// Refreshes the VFS listing afterward. | |
| 58 | + | pub fn purge_missing_unsafe(&mut self) { | |
| 59 | + | match self.backend.purge_missing_unsafe() { | |
| 60 | + | Ok(purged) => { | |
| 61 | + | self.status = format!("Purged {purged} missing samples"); | |
| 62 | + | self.unsafe_missing_count = 0; | |
| 63 | + | self.show_unsafe_warning = false; | |
| 64 | + | self.refresh_contents(); | |
| 65 | + | } | |
| 66 | + | Err(e) => { | |
| 67 | + | self.status = format!("Purge failed: {e}"); | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | /// Dismiss the unsafe integrity warning without purging. | |
| 73 | + | pub fn dismiss_unsafe_warning(&mut self) { | |
| 74 | + | self.show_unsafe_warning = false; | |
| 33 | 75 | } | |
| 34 | 76 | ||
| 35 | 77 | /// Refresh the cached list of all tags. |
| @@ -6,7 +6,7 @@ | |||
| 6 | 6 | use std::fs; | |
| 7 | 7 | use std::path::{Path, PathBuf}; | |
| 8 | 8 | use std::sync::Arc; | |
| 9 | - | use std::sync::atomic::{AtomicBool, AtomicU32}; | |
| 9 | + | use std::sync::atomic::{AtomicU32, AtomicU64}; | |
| 10 | 10 | use std::time::Instant; | |
| 11 | 11 | ||
| 12 | 12 | use tracing::{error, warn}; | |
| @@ -56,8 +56,9 @@ pub struct SharedState { | |||
| 56 | 56 | pub device_sample_rate: AtomicU32, | |
| 57 | 57 | /// MIDI note events pushed by the MIDI callback, drained by the GUI each frame. | |
| 58 | 58 | pub midi_recent_notes: Mutex<Vec<MidiNoteEvent>>, | |
| 59 | - | /// Set to `true` to signal the streaming decode thread to stop. | |
| 60 | - | pub decode_cancel: AtomicBool, | |
| 59 | + | /// Generation counter for streaming decode threads. Each new decode increments | |
| 60 | + | /// the generation; the thread exits if its generation no longer matches. | |
| 61 | + | pub decode_generation: AtomicU64, | |
| 61 | 62 | } | |
| 62 | 63 | ||
| 63 | 64 | impl Default for SharedState { | |
| @@ -67,7 +68,7 @@ impl Default for SharedState { | |||
| 67 | 68 | instrument: Mutex::new(InstrumentPlayback::new(8)), | |
| 68 | 69 | device_sample_rate: AtomicU32::new(44100), | |
| 69 | 70 | midi_recent_notes: Mutex::new(Vec::new()), | |
| 70 | - | decode_cancel: AtomicBool::new(false), | |
| 71 | + | decode_generation: AtomicU64::new(0), | |
| 71 | 72 | } | |
| 72 | 73 | } | |
| 73 | 74 | } | |
| @@ -212,6 +213,12 @@ pub struct BrowserState { | |||
| 212 | 213 | ||
| 213 | 214 | // Settings (consolidated window) | |
| 214 | 215 | pub settings: SettingsUiState, | |
| 216 | + | ||
| 217 | + | // Unsafe mode integrity | |
| 218 | + | /// Number of unsafe-mode samples with missing source files (0 = healthy or not unsafe). | |
| 219 | + | pub unsafe_missing_count: usize, | |
| 220 | + | /// Whether to show the integrity warning overlay. | |
| 221 | + | pub show_unsafe_warning: bool, | |
| 215 | 222 | } | |
| 216 | 223 | ||
| 217 | 224 | impl BrowserState { | |
| @@ -367,6 +374,8 @@ impl BrowserState { | |||
| 367 | 374 | mirror_dirty: mirror_enabled, | |
| 368 | 375 | sync: SyncUiState::default(), | |
| 369 | 376 | settings: SettingsUiState { name: vault_name.to_string(), ..Default::default() }, | |
| 377 | + | unsafe_missing_count: 0, | |
| 378 | + | show_unsafe_warning: false, | |
| 370 | 379 | }) | |
| 371 | 380 | } | |
| 372 | 381 | } |
| @@ -774,6 +774,8 @@ mod import_and_analysis { | |||
| 774 | 774 | completed: 3, | |
| 775 | 775 | current_name: "file.wav".to_string(), | |
| 776 | 776 | walking: false, | |
| 777 | + | total_bytes: 0, | |
| 778 | + | unsafe_mode: false, | |
| 777 | 779 | }; | |
| 778 | 780 | state.cancel_import(); | |
| 779 | 781 | assert!(matches!(state.import_mode, ImportMode::None)); | |
| @@ -790,6 +792,8 @@ mod import_and_analysis { | |||
| 790 | 792 | completed: 3, | |
| 791 | 793 | current_name: "file.wav".to_string(), | |
| 792 | 794 | walking: false, | |
| 795 | + | total_bytes: 0, | |
| 796 | + | unsafe_mode: false, | |
| 793 | 797 | }; | |
| 794 | 798 | state.retry_import(); | |
| 795 | 799 | assert!(matches!(state.import_mode, ImportMode::ConfigureImport { .. })); | |
| @@ -804,6 +808,8 @@ mod import_and_analysis { | |||
| 804 | 808 | completed: 3, | |
| 805 | 809 | current_name: "file.wav".to_string(), | |
| 806 | 810 | walking: false, | |
| 811 | + | total_bytes: 0, | |
| 812 | + | unsafe_mode: false, | |
| 807 | 813 | }; | |
| 808 | 814 | state.retry_import(); | |
| 809 | 815 | // With no last_import_source, it cancels but cannot reopen config |
| @@ -160,7 +160,7 @@ pub enum VaultAction { | |||
| 160 | 160 | /// Switch to a different vault. | |
| 161 | 161 | SwitchVault(PathBuf), | |
| 162 | 162 | /// Create a new vault and switch to it. | |
| 163 | - | CreateVault { name: String, path: PathBuf }, | |
| 163 | + | CreateVault { name: String, path: PathBuf, unsafe_mode: bool }, | |
| 164 | 164 | /// Add an existing vault directory to the registry. | |
| 165 | 165 | AddExistingVault { name: String, path: PathBuf }, | |
| 166 | 166 | /// Remove a vault from the registry (no file deletion). | |
| @@ -191,6 +191,12 @@ pub struct SettingsUiState { | |||
| 191 | 191 | /// Inline rename: (path, new_name_buffer). | |
| 192 | 192 | pub rename_target: Option<(PathBuf, String)>, | |
| 193 | 193 | ||
| 194 | + | /// Unsafe mode checkbox state for vault creation. | |
| 195 | + | pub create_unsafe_mode: bool, | |
| 196 | + | ||
| 197 | + | /// Whether the active vault has unsafe mode enabled (read from DB on vault load). | |
| 198 | + | pub is_unsafe_mode: bool, | |
| 199 | + | ||
| 194 | 200 | /// Cached storage statistics from the last scan. | |
| 195 | 201 | pub storage_cache: Option<crate::backend::StorageStats>, | |
| 196 | 202 | /// Whether a storage scan is in progress. | |
| @@ -320,6 +326,8 @@ pub enum ImportMode { | |||
| 320 | 326 | completed: usize, | |
| 321 | 327 | current_name: String, | |
| 322 | 328 | walking: bool, | |
| 329 | + | total_bytes: u64, | |
| 330 | + | unsafe_mode: bool, | |
| 323 | 331 | }, | |
| 324 | 332 | TagFolders { | |
| 325 | 333 | entries: Vec<FolderTagEntry>, |
| @@ -4,15 +4,30 @@ use crate::state::{BrowserState, ImportMode}; | |||
| 4 | 4 | ||
| 5 | 5 | use super::super::theme; | |
| 6 | 6 | ||
| 7 | + | /// Format byte counts as B/KB/MB/GB. | |
| 8 | + | fn format_bytes(bytes: u64) -> String { | |
| 9 | + | if bytes < 1024 { | |
| 10 | + | format!("{bytes} B") | |
| 11 | + | } else if bytes < 1024 * 1024 { | |
| 12 | + | format!("{:.1} KB", bytes as f64 / 1024.0) | |
| 13 | + | } else if bytes < 1024 * 1024 * 1024 { | |
| 14 | + | format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0)) | |
| 15 | + | } else { | |
| 16 | + | format!("{:.2} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0)) | |
| 17 | + | } | |
| 18 | + | } | |
| 19 | + | ||
| 7 | 20 | /// Draw the folder import progress screen. | |
| 8 | 21 | pub fn draw_import_progress(ctx: &egui::Context, state: &mut BrowserState) { | |
| 9 | - | let (total, completed, current_name, walking) = match &state.import_mode { | |
| 22 | + | let (total, completed, current_name, walking, total_bytes, unsafe_mode) = match &state.import_mode { | |
| 10 | 23 | ImportMode::Importing { | |
| 11 | 24 | total, | |
| 12 | 25 | completed, | |
| 13 | 26 | current_name, | |
| 14 | 27 | walking, | |
| 15 | - | } => (*total, *completed, current_name.clone(), *walking), | |
| 28 | + | total_bytes, | |
| 29 | + | unsafe_mode, | |
| 30 | + | } => (*total, *completed, current_name.clone(), *walking, *total_bytes, *unsafe_mode), | |
| 16 | 31 | _ => return, | |
| 17 | 32 | }; | |
| 18 | 33 | ||
| @@ -26,6 +41,22 @@ pub fn draw_import_progress(ctx: &egui::Context, state: &mut BrowserState) { | |||
| 26 | 41 | ui.label("Scanning for audio files..."); | |
| 27 | 42 | }); | |
| 28 | 43 | } else { | |
| 44 | + | // Storage estimate | |
| 45 | + | if total_bytes > 0 { | |
| 46 | + | let size_label = format_bytes(total_bytes); | |
| 47 | + | let storage_text = if unsafe_mode { | |
| 48 | + | format!("{total} files, {size_label} total (referenced in place, no copies)") | |
| 49 | + | } else { | |
| 50 | + | format!("{total} files, ~{size_label} will be duplicated into vault") | |
| 51 | + | }; | |
| 52 | + | ui.label( | |
| 53 | + | egui::RichText::new(storage_text) | |
| 54 | + | .small() | |
| 55 | + | .color(if unsafe_mode { theme::accent_yellow() } else { theme::text_secondary() }), | |
| 56 | + | ); | |
| 57 | + | ui.add_space(4.0); | |
| 58 | + | } | |
| 59 | + | ||
| 29 | 60 | let progress = if total > 0 { | |
| 30 | 61 | completed as f32 / total as f32 | |
| 31 | 62 | } else { |
| @@ -111,6 +111,48 @@ pub fn draw_confirm_dialog(ctx: &egui::Context, state: &mut BrowserState) { | |||
| 111 | 111 | }); | |
| 112 | 112 | } | |
| 113 | 113 | ||
| 114 | + | /// Draw the unsafe mode integrity warning overlay. | |
| 115 | + | pub fn draw_unsafe_warning(ctx: &egui::Context, state: &mut BrowserState) { | |
| 116 | + | let count = state.unsafe_missing_count; | |
| 117 | + | if count == 0 { | |
| 118 | + | return; | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | egui::Window::new("Unsafe Mode Warning") | |
| 122 | + | .collapsible(false) | |
| 123 | + | .resizable(false) | |
| 124 | + | .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) | |
| 125 | + | .show(ctx, |ui| { | |
| 126 | + | ui.label(format!( | |
| 127 | + | "{count} sample{} in this vault {} missing source {}.", | |
| 128 | + | if count == 1 { "" } else { "s" }, | |
| 129 | + | if count == 1 { "has a" } else { "have" }, | |
| 130 | + | if count == 1 { "file" } else { "files" }, | |
| 131 | + | )); | |
| 132 | + | ui.add_space(4.0); | |
| 133 | + | ui.label( | |
| 134 | + | egui::RichText::new( | |
| 135 | + | "The original files may have been moved or deleted. \ | |
| 136 | + | These samples cannot be played or exported until the files are restored." | |
| 137 | + | ) | |
| 138 | + | .small() | |
| 139 | + | .color(super::theme::text_secondary()), | |
| 140 | + | ); | |
| 141 | + | ui.add_space(12.0); | |
| 142 | + | ui.horizontal(|ui| { | |
| 143 | + | if ui.button("Purge missing samples") | |
| 144 | + | .on_hover_text("Permanently remove these samples and their metadata from the vault") | |
| 145 | + | .clicked() | |
| 146 | + | { | |
| 147 | + | state.purge_missing_unsafe(); | |
| 148 | + | } | |
| 149 | + | if ui.button("Dismiss").clicked() { | |
| 150 | + | state.dismiss_unsafe_warning(); | |
| 151 | + | } | |
| 152 | + | }); | |
| 153 | + | }); | |
| 154 | + | } | |
| 155 | + | ||
| 114 | 156 | /// Draw the active bulk modal (tag, move, or rename). | |
| 115 | 157 | pub fn draw_bulk_modal(ctx: &egui::Context, state: &mut BrowserState) { | |
| 116 | 158 | let modal_kind = match &state.bulk_modal { |
| @@ -151,6 +151,16 @@ fn draw_storage_section(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 151 | 151 | ui.separator(); | |
| 152 | 152 | ui.add_space(4.0); | |
| 153 | 153 | ||
| 154 | + | // Unsafe mode indicator for active vault | |
| 155 | + | if state.settings.is_unsafe_mode { | |
| 156 | + | ui.add_space(4.0); | |
| 157 | + | ui.label( | |
| 158 | + | egui::RichText::new("This vault uses unsafe mode. Samples are referenced in place, not duplicated.") | |
| 159 | + | .small() | |
| 160 | + | .color(theme::accent_yellow()), | |
| 161 | + | ); | |
| 162 | + | } | |
| 163 | + | ||
| 154 | 164 | // Create new vault | |
| 155 | 165 | ui.label(egui::RichText::new("Add Vault").strong()); | |
| 156 | 166 | ui.horizontal(|ui| { | |
| @@ -171,6 +181,17 @@ fn draw_storage_section(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 171 | 181 | ); | |
| 172 | 182 | } | |
| 173 | 183 | }); | |
| 184 | + | ui.checkbox( | |
| 185 | + | &mut state.settings.create_unsafe_mode, | |
| 186 | + | "Unsafe mode", | |
| 187 | + | ).on_hover_text("Reference files in place instead of duplicating them into the vault. Saves disk space but samples break if moved or deleted. Cannot be changed later."); | |
| 188 | + | if state.settings.create_unsafe_mode { | |
| 189 | + | ui.label( | |
| 190 | + | egui::RichText::new("Samples will not be duplicated. Moving or deleting originals will break references. This cannot be undone.") | |
| 191 | + | .small() | |
| 192 | + | .color(theme::accent_yellow()), | |
| 193 | + | ); | |
| 194 | + | } | |
| 174 | 195 | ui.add_space(4.0); | |
| 175 | 196 | ui.horizontal(|ui| { | |
| 176 | 197 | let can_create = !state.settings.create_name.trim().is_empty() | |
| @@ -178,9 +199,11 @@ fn draw_storage_section(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 178 | 199 | if ui.add_enabled(can_create, egui::Button::new("Create New")).clicked() { | |
| 179 | 200 | if let Some(path) = state.settings.create_path.take() { | |
| 180 | 201 | let name = state.settings.create_name.trim().to_string(); | |
| 202 | + | let unsafe_mode = state.settings.create_unsafe_mode; | |
| 181 | 203 | state.settings.pending_action = | |
| 182 | - | Some(crate::state::VaultAction::CreateVault { name, path }); | |
| 204 | + | Some(crate::state::VaultAction::CreateVault { name, path, unsafe_mode }); | |
| 183 | 205 | state.settings.create_name.clear(); | |
| 206 | + | state.settings.create_unsafe_mode = false; | |
| 184 | 207 | should_close = true; | |
| 185 | 208 | } | |
| 186 | 209 | } | |
| @@ -194,6 +217,7 @@ fn draw_storage_section(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 194 | 217 | state.settings.pending_action = | |
| 195 | 218 | Some(crate::state::VaultAction::AddExistingVault { name, path }); | |
| 196 | 219 | state.settings.create_name.clear(); | |
| 220 | + | state.settings.create_unsafe_mode = false; | |
| 197 | 221 | } | |
| 198 | 222 | } | |
| 199 | 223 | }); |
| @@ -601,6 +601,13 @@ BEGIN | |||
| 601 | 601 | END; | |
| 602 | 602 | "#; | |
| 603 | 603 | ||
| 604 | + | const MIGRATION_013: &str = r#" | |
| 605 | + | -- Unsafe mode: remember original file path instead of copying into vault. | |
| 606 | + | -- NULL = normal (blob in samples/), non-NULL = unsafe (blob at this path). | |
| 607 | + | -- Intentionally excluded from sync triggers — source_path is device-local. | |
| 608 | + | ALTER TABLE samples ADD COLUMN source_path TEXT; | |
| 609 | + | "#; | |
| 610 | + | ||
| 604 | 611 | impl Database { | |
| 605 | 612 | /// Open (or create) the database at the given path and run migrations. | |
| 606 | 613 | #[instrument(skip_all)] | |
| @@ -661,13 +668,56 @@ impl Database { | |||
| 661 | 668 | MIGRATION_010, | |
| 662 | 669 | MIGRATION_011, | |
| 663 | 670 | MIGRATION_012, | |
| 671 | + | MIGRATION_013, | |
| 664 | 672 | ]; | |
| 665 | 673 | ||
| 666 | 674 | for (i, sql) in MIGRATIONS.iter().enumerate() { | |
| 667 | 675 | let target = (i + 1) as i32; | |
| 668 | 676 | if version < target { | |
| 669 | 677 | let batch = format!("BEGIN;\n{}\nPRAGMA user_version = {};\nCOMMIT;", sql, target); | |
| 670 | - | self.conn.execute_batch(&batch)?; | |
| 678 | + | match self.conn.execute_batch(&batch) { | |
| 679 | + | Ok(()) => {} | |
| 680 | + | Err(e) if e.to_string().contains("duplicate column") => { | |
| 681 | + | // Partial prior migration left some columns already added. | |
| 682 | + | // Re-run each ALTER TABLE individually, skipping duplicates. | |
| 683 | + | let _ = self.conn.execute_batch("ROLLBACK"); | |
| 684 | + | self.conn.execute_batch("BEGIN")?; | |
| 685 | + | for line in sql.lines() { | |
| 686 | + | let trimmed = line.trim(); | |
| 687 | + | if trimmed.to_uppercase().starts_with("ALTER TABLE") | |
| 688 | + | && trimmed.to_uppercase().contains("ADD COLUMN") | |
| 689 | + | { | |
| 690 | + | if let Err(alter_err) = self.conn.execute_batch(trimmed) { | |
| 691 | + | if !alter_err.to_string().contains("duplicate column") { | |
| 692 | + | let _ = self.conn.execute_batch("ROLLBACK"); | |
| 693 | + | return Err(DbError::Sqlite(alter_err)); | |
| 694 | + | } | |
| 695 | + | } | |
| 696 | + | } else if !trimmed.is_empty() && !trimmed.starts_with("--") { | |
| 697 | + | // Non-ALTER statements (CREATE TABLE, triggers, etc.) | |
| 698 | + | // Use execute_batch to handle multi-line statements | |
| 699 | + | // that may span multiple lines. | |
| 700 | + | } | |
| 701 | + | } | |
| 702 | + | // Re-run the full batch minus ALTER TABLEs for triggers/tables | |
| 703 | + | let non_alter: String = sql | |
| 704 | + | .lines() | |
| 705 | + | .filter(|l| { | |
| 706 | + | let t = l.trim().to_uppercase(); | |
| 707 | + | !(t.starts_with("ALTER TABLE") && t.contains("ADD COLUMN")) | |
| 708 | + | }) | |
| 709 | + | .collect::<Vec<_>>() | |
| 710 | + | .join("\n"); | |
| 711 | + | if !non_alter.trim().is_empty() { | |
| 712 | + | // Ignore errors from already-created objects | |
| 713 | + | let _ = self.conn.execute_batch(&non_alter); | |
| 714 | + | } | |
| 715 | + | self.conn.execute_batch( | |
| 716 | + | &format!("PRAGMA user_version = {};\nCOMMIT;", target), | |
| 717 | + | )?; | |
| 718 | + | } | |
| 719 | + | Err(e) => return Err(DbError::Sqlite(e)), | |
| 720 | + | } | |
| 671 | 721 | } | |
| 672 | 722 | } | |
| 673 | 723 | ||
| @@ -759,7 +809,7 @@ mod tests { | |||
| 759 | 809 | .conn() | |
| 760 | 810 | .query_row("PRAGMA user_version", [], |row| row.get(0)) | |
| 761 | 811 | .unwrap(); | |
| 762 | - | assert_eq!(version, 12); | |
| 812 | + | assert_eq!(version, 13); | |
| 763 | 813 | } | |
| 764 | 814 | ||
| 765 | 815 | #[test] | |
| @@ -770,7 +820,7 @@ mod tests { | |||
| 770 | 820 | .conn() | |
| 771 | 821 | .query_row("PRAGMA user_version", [], |row| row.get(0)) | |
| 772 | 822 | .unwrap(); | |
| 773 | - | assert_eq!(version, 12); | |
| 823 | + | assert_eq!(version, 13); | |
| 774 | 824 | } | |
| 775 | 825 | ||
| 776 | 826 | #[test] |
| @@ -96,6 +96,9 @@ pub struct ExportItem { | |||
| 96 | 96 | pub duration: Option<f64>, | |
| 97 | 97 | /// Tags associated with this sample (populated by `enrich_with_tags`). | |
| 98 | 98 | pub tags: Vec<String>, | |
| 99 | + | /// Original file path for unsafe-mode samples (populated from samples.source_path). | |
| 100 | + | /// When set, the export runner uses this path instead of the store. | |
| 101 | + | pub source_path: Option<PathBuf>, | |
| 99 | 102 | } | |
| 100 | 103 | ||
| 101 | 104 | /// Summary of a completed export. | |
| @@ -127,7 +130,8 @@ pub fn collect_export_items( | |||
| 127 | 130 | JOIN tree t ON n.parent_id = t.id | |
| 128 | 131 | ) | |
| 129 | 132 | SELECT n.sample_hash, s.file_extension, t.path, n.name, | |
| 130 | - | a.bpm, a.musical_key, a.classification, COALESCE(a.duration, s.duration) | |
| 133 | + | a.bpm, a.musical_key, a.classification, COALESCE(a.duration, s.duration), | |
| 134 | + | s.source_path | |
| 131 | 135 | FROM tree t | |
| 132 | 136 | JOIN vfs_nodes n ON n.id = t.id | |
| 133 | 137 | LEFT JOIN samples s ON n.sample_hash = s.hash | |
| @@ -145,7 +149,8 @@ pub fn collect_export_items( | |||
| 145 | 149 | JOIN tree t ON n.parent_id = t.id | |
| 146 | 150 | ) | |
| 147 | 151 | SELECT n.sample_hash, s.file_extension, t.path, n.name, | |
| 148 | - | a.bpm, a.musical_key, a.classification, COALESCE(a.duration, s.duration) | |
| 152 | + | a.bpm, a.musical_key, a.classification, COALESCE(a.duration, s.duration), | |
| 153 | + | s.source_path | |
| 149 | 154 | FROM tree t | |
| 150 | 155 | JOIN vfs_nodes n ON n.id = t.id | |
| 151 | 156 | LEFT JOIN samples s ON n.sample_hash = s.hash | |
| @@ -179,6 +184,8 @@ fn map_export_item(row: &rusqlite::Row) -> rusqlite::Result<Option<ExportItem>> | |||
| 179 | 184 | _ => return Ok(None), | |
| 180 | 185 | }; | |
| 181 | 186 | ||
| 187 | + | let source_path: Option<String> = row.get(8)?; | |
| 188 | + | ||
| 182 | 189 | Ok(Some(ExportItem { | |
| 183 | 190 | hash: crate::SampleHash::new(hash), | |
| 184 | 191 | ext, | |
| @@ -189,6 +196,7 @@ fn map_export_item(row: &rusqlite::Row) -> rusqlite::Result<Option<ExportItem>> | |||
| 189 | 196 | classification: row.get(6)?, | |
| 190 | 197 | duration: row.get(7)?, | |
| 191 | 198 | tags: Vec::new(), | |
| 199 | + | source_path: source_path.map(PathBuf::from), | |
| 192 | 200 | })) | |
| 193 | 201 | } | |
| 194 | 202 | ||
| @@ -700,6 +708,7 @@ mod tests { | |||
| 700 | 708 | classification: None, | |
| 701 | 709 | duration: None, | |
| 702 | 710 | tags: vec![], | |
| 711 | + | source_path: None, | |
| 703 | 712 | } | |
| 704 | 713 | } | |
| 705 | 714 | ||
| @@ -714,6 +723,7 @@ mod tests { | |||
| 714 | 723 | classification: Some("drums".to_string()), | |
| 715 | 724 | duration: Some(1.5), | |
| 716 | 725 | tags: vec![], | |
| 726 | + | source_path: None, | |
| 717 | 727 | } | |
| 718 | 728 | } | |
| 719 | 729 | ||
| @@ -935,6 +945,7 @@ mod tests { | |||
| 935 | 945 | classification: None, | |
| 936 | 946 | duration: None, | |
| 937 | 947 | tags: vec![], | |
| 948 | + | source_path: None, | |
| 938 | 949 | }]; | |
| 939 | 950 | ||
| 940 | 951 | let config = ExportConfig { |
| @@ -59,11 +59,26 @@ pub fn run_export( | |||
| 59 | 59 | break; // cancelled | |
| 60 | 60 | } | |
| 61 | 61 | ||
| 62 | - | let source = match store.sample_path(&item.hash, &item.ext) { | |
| 63 | - | Ok(p) => p, | |
| 64 | - | Err(e) => { | |
| 65 | - | errors.push((item.name.clone(), e.to_string())); | |
| 66 | - | continue; | |
| 62 | + | let source = if let Some(sp) = &item.source_path { | |
| 63 | + | if sp.exists() { | |
| 64 | + | sp.clone() | |
| 65 | + | } else { | |
| 66 | + | // Fallback to store path for unsafe samples whose source moved | |
| 67 | + | match store.sample_path(&item.hash, &item.ext) { | |
| 68 | + | Ok(p) => p, | |
| 69 | + | Err(e) => { | |
| 70 | + | errors.push((item.name.clone(), e.to_string())); | |
| 71 | + | continue; | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | } else { | |
| 76 | + | match store.sample_path(&item.hash, &item.ext) { | |
| 77 | + | Ok(p) => p, | |
| 78 | + | Err(e) => { | |
| 79 | + | errors.push((item.name.clone(), e.to_string())); | |
| 80 | + | continue; | |
| 81 | + | } | |
| 67 | 82 | } | |
| 68 | 83 | }; | |
| 69 | 84 |
| @@ -224,9 +224,18 @@ impl SampleStore { | |||
| 224 | 224 | .collect::<std::result::Result<Vec<_>, _>>()?; | |
| 225 | 225 | ||
| 226 | 226 | let count = orphans.len(); | |
| 227 | - | for (hash, ext) in &orphans { | |
| 227 | + | ||
| 228 | + | // Delete all orphan DB rows in a single transaction so a concurrent | |
| 229 | + | // VFS link can't reference a sample between query and delete. | |
| 230 | + | db.conn().execute_batch("BEGIN IMMEDIATE")?; | |
| 231 | + | for (hash, _) in &orphans { | |
| 228 | 232 | db.conn() | |
| 229 | 233 | .execute("DELETE FROM samples WHERE hash = ?1", [hash])?; | |
| 234 | + | } | |
| 235 | + | db.conn().execute_batch("COMMIT")?; | |
| 236 | + | ||
| 237 | + | // Remove files after the transaction (orphaned blobs are harmless if this fails) | |
| 238 | + | for (hash, ext) in &orphans { | |
| 230 | 239 | if let Ok(path) = self.sample_path(hash, ext) { | |
| 231 | 240 | if path.exists() { | |
| 232 | 241 | let _ = fs::remove_file(&path); | |
| @@ -301,6 +310,199 @@ pub fn sample_original_name(db: &Database, hash: &str) -> Result<String> { | |||
| 301 | 310 | query_sample_field(db, hash, "original_name") | |
| 302 | 311 | } | |
| 303 | 312 | ||
| 313 | + | // --- Unsafe mode --- | |
| 314 | + | ||
| 315 | + | /// Look up the source_path for a sample (unsafe mode imports only). | |
| 316 | + | /// | |
| 317 | + | /// Returns `Ok(None)` for normal-mode samples (source_path is NULL). | |
| 318 | + | pub fn sample_source_path(db: &Database, hash: &str) -> Result<Option<String>> { | |
| 319 | + | db.conn() | |
| 320 | + | .query_row( | |
| 321 | + | "SELECT source_path FROM samples WHERE hash = ?1", | |
| 322 | + | [hash], | |
| 323 | + | |row| row.get(0), | |
| 324 | + | ) | |
| 325 | + | .map_err(|e| match e { | |
| 326 | + | rusqlite::Error::QueryReturnedNoRows => CoreError::SampleNotFound(hash.to_string()), | |
| 327 | + | other => CoreError::Db(other), | |
| 328 | + | }) | |
| 329 | + | } | |
| 330 | + | ||
| 331 | + | /// Resolve the actual file path for a sample, checking source_path first. | |
| 332 | + | /// | |
| 333 | + | /// For unsafe-mode samples (source_path is set), returns the source path if | |
| 334 | + | /// the file exists, otherwise falls back to the store path. For normal samples, | |
| 335 | + | /// returns the store path directly. | |
| 336 | + | pub fn resolve_file_path(store: &SampleStore, db: &Database, hash: &str, ext: &str) -> Result<PathBuf> { | |
| 337 | + | if let Some(sp) = sample_source_path(db, hash)? { | |
| 338 | + | let source = PathBuf::from(&sp); | |
| 339 | + | if source.exists() { | |
| 340 | + | return Ok(source); | |
| 341 | + | } | |
| 342 | + | // Fallback: maybe user re-imported in normal mode or placed file manually | |
| 343 | + | let store_path = store.sample_path(hash, ext)?; | |
| 344 | + | if store_path.exists() { | |
| 345 | + | return Ok(store_path); | |
| 346 | + | } | |
| 347 | + | // Return the source path anyway — caller will handle the "not found" | |
| 348 | + | return Ok(source); | |
| 349 | + | } | |
| 350 | + | store.sample_path(hash, ext) | |
| 351 | + | } | |
| 352 | + | ||
| 353 | + | /// Update the source_path for a sample after verifying the new file's hash matches. | |
| 354 | + | /// | |
| 355 | + | /// Used to relocate an unsafe-mode sample whose original file has moved. | |
| 356 | + | pub fn relocate_sample( | |
| 357 | + | store: &SampleStore, | |
| 358 | + | db: &Database, | |
| 359 | + | hash: &str, | |
| 360 | + | new_path: &Path, | |
| 361 | + | ) -> Result<()> { | |
| 362 | + | // Verify hash matches | |
| 363 | + | let mut file = fs::File::open(new_path).map_err(|e| io_err(new_path, e))?; | |
| 364 | + | let mut hasher = Sha256::new(); | |
| 365 | + | let mut buf = [0u8; 8192]; | |
| 366 | + | loop { | |
| 367 | + | let n = file.read(&mut buf).map_err(|e| io_err(new_path, e))?; | |
| 368 | + | if n == 0 { | |
| 369 | + | break; | |
| 370 | + | } | |
| 371 | + | hasher.update(&buf[..n]); | |
| 372 | + | } | |
| 373 | + | let computed = format!("{:x}", hasher.finalize()); | |
| 374 | + | ||
| 375 | + | if computed != hash { | |
| 376 | + | return Err(CoreError::Internal(format!( | |
| 377 | + | "hash mismatch: expected {hash}, got {computed} — this is a different file" | |
| 378 | + | ))); | |
| 379 | + | } | |
| 380 | + | ||
| 381 | + | let abs_path = new_path | |
| 382 | + | .canonicalize() | |
| 383 | + | .map_err(|e| io_err(new_path, e))? | |
| 384 | + | .to_string_lossy() | |
| 385 | + | .to_string(); | |
| 386 | + | ||
| 387 | + | let changed = db.conn().execute( | |
| 388 | + | "UPDATE samples SET source_path = ?1 WHERE hash = ?2", | |
| 389 | + | rusqlite::params![abs_path, hash], | |
| 390 | + | )?; | |
| 391 | + | if changed == 0 { | |
| 392 | + | return Err(CoreError::SampleNotFound(hash.to_string())); | |
| 393 | + | } | |
| 394 | + | let _ = store; // unused but passed for API consistency | |
| 395 | + | Ok(()) | |
| 396 | + | } | |
| 397 | + | ||
| 398 | + | /// Check integrity of unsafe-mode samples. | |
| 399 | + | /// | |
| 400 | + | /// Returns `(valid, missing)` — counts of source_path entries where the file | |
| 401 | + | /// exists vs. does not exist on disk. | |
| 402 | + | pub fn check_unsafe_integrity(db: &Database) -> Result<(usize, usize)> { | |
| 403 | + | let mut stmt = db.conn().prepare( | |
| 404 | + | "SELECT source_path FROM samples WHERE source_path IS NOT NULL", | |
| 405 | + | )?; | |
| 406 | + | let paths: Vec<String> = stmt | |
| 407 | + | .query_map([], |row| row.get(0))? | |
| 408 | + | .collect::<std::result::Result<Vec<_>, _>>()?; | |
| 409 | + | ||
| 410 | + | let mut valid = 0; | |
| 411 | + | let mut missing = 0; | |
| 412 | + | for p in &paths { | |
| 413 | + | if Path::new(p).exists() { | |
| 414 | + | valid += 1; | |
| 415 | + | } else { | |
| 416 | + | missing += 1; | |
| 417 | + | } | |
| 418 | + | } | |
| 419 | + | Ok((valid, missing)) | |
| 420 | + | } | |
| 421 | + | ||
| 422 | + | /// Delete all unsafe-mode samples whose source files no longer exist on disk. | |
| 423 | + | /// | |
| 424 | + | /// Returns the number of samples purged. CASCADE handles VFS nodes, tags, etc. | |
| 425 | + | pub fn purge_missing_unsafe(db: &Database) -> Result<usize> { | |
| 426 | + | let mut stmt = db.conn().prepare( | |
| 427 | + | "SELECT hash, source_path FROM samples WHERE source_path IS NOT NULL", | |
| 428 | + | )?; | |
| 429 | + | let rows: Vec<(String, String)> = stmt | |
| 430 | + | .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))? | |
| 431 | + | .collect::<std::result::Result<Vec<_>, _>>()?; | |
| 432 | + | ||
| 433 | + | let mut purged = 0; | |
| 434 | + | for (hash, source_path) in &rows { | |
| 435 | + | if !Path::new(source_path).exists() { | |
| 436 | + | db.conn() | |
| 437 | + | .execute("DELETE FROM samples WHERE hash = ?1", [hash])?; | |
| 438 | + | purged += 1; | |
| 439 | + | } | |
| 440 | + | } | |
| 441 | + | Ok(purged) | |
| 442 | + | } | |
| 443 | + | ||
| 444 | + | impl SampleStore { | |
| 445 | + | /// Import a file in unsafe mode: hash it but do NOT copy to the store. | |
| 446 | + | /// | |
| 447 | + | /// Records the original absolute path as `source_path` in the database. | |
| 448 | + | /// The file stays where it is on disk. | |
| 449 | + | #[instrument(skip_all)] | |
| 450 | + | pub fn import_unsafe(&self, path: &Path, db: &Database) -> Result<String> { | |
| 451 | + | if !crate::util::is_audio_file(path) { | |
| 452 | + | return Err(CoreError::Internal(format!( | |
| 453 | + | "not a supported audio file: {}", | |
| 454 | + | path.display() | |
| 455 | + | ))); | |
| 456 | + | } | |
| 457 | + | ||
| 458 | + | let mut file = fs::File::open(path).map_err(|e| io_err(path, e))?; | |
| 459 | + | let metadata = file.metadata().map_err(|e| io_err(path, e))?; | |
| 460 | + | let file_size = metadata.len() as i64; | |
| 461 | + | ||
| 462 | + | if file_size == 0 { | |
| 463 | + | return Err(CoreError::Internal(format!( | |
| 464 | + | "cannot import zero-byte file: {}", | |
| 465 | + | path.display() | |
| 466 | + | ))); | |
| 467 | + | } | |
| 468 | + | ||
| 469 | + | // Stream through SHA-256 | |
| 470 | + | let mut hasher = Sha256::new(); | |
| 471 | + | let mut buf = [0u8; 8192]; | |
| 472 | + | loop { | |
| 473 | + | let n = file.read(&mut buf).map_err(|e| io_err(path, e))?; | |
| 474 | + | if n == 0 { | |
| 475 | + | break; | |
| 476 | + | } | |
| 477 | + | hasher.update(&buf[..n]); | |
| 478 | + | } | |
| 479 | + | let hash = format!("{:x}", hasher.finalize()); | |
| 480 | + | ||
| 481 | + | let ext = crate::util::get_extension(path); | |
| 482 | + | let original_name = crate::util::get_filename(path, "unknown"); | |
| 483 | + | ||
| 484 | + | // Probe duration from file headers (cheap, no full decode) | |
| 485 | + | let duration = probe_duration(path); | |
| 486 | + | ||
| 487 | + | // Resolve absolute path for storage | |
| 488 | + | let abs_path = path | |
| 489 | + | .canonicalize() | |
| 490 | + | .map_err(|e| io_err(path, e))? | |
| 491 | + | .to_string_lossy() | |
| 492 | + | .to_string(); | |
| 493 | + | ||
| 494 | + | // Insert into DB with source_path (ignore if hash already exists) | |
| 495 | + | let now = unix_now(); | |
| 496 | + | db.conn().execute( | |
| 497 | + | "INSERT OR IGNORE INTO samples (hash, original_name, file_extension, file_size, import_date, last_modified, duration, source_path) | |
| 498 | + | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", | |
| 499 | + | rusqlite::params![hash, original_name, ext, file_size, now, now, duration, abs_path], | |
| 500 | + | )?; | |
| 501 | + | ||
| 502 | + | Ok(hash) | |
| 503 | + | } | |
| 504 | + | } | |
| 505 | + | ||
| 304 | 506 | #[cfg(test)] | |
| 305 | 507 | mod tests { | |
| 306 | 508 | use super::*; | |
| @@ -598,4 +800,167 @@ mod tests { | |||
| 598 | 800 | assert_eq!(removed, 1); | |
| 599 | 801 | assert!(!store.exists(&hash, "wav").unwrap()); | |
| 600 | 802 | } | |
| 803 | + | ||
| 804 | + | // --- Unsafe mode tests --- | |
| 805 | + | ||
| 806 | + | #[test] | |
| 807 | + | fn import_unsafe_does_not_copy_file() { | |
| 808 | + | let (dir, db, store) = setup(); | |
| 809 | + | let src = create_test_file(&dir, "kick.wav", b"unsafe kick data"); | |
| 810 | + | ||
| 811 | + | let hash = store.import_unsafe(&src, &db).unwrap(); | |
| 812 | + | ||
| 813 | + | // No file in the store | |
| 814 | + | assert!(!store.exists(&hash, "wav").unwrap()); | |
| 815 | + | ||
| 816 | + | // Row exists in DB with source_path set | |
| 817 | + | let sp: Option<String> = db | |
| 818 | + | .conn() | |
| 819 | + | .query_row( | |
| 820 | + | "SELECT source_path FROM samples WHERE hash = ?1", | |
| 821 | + | [&hash], | |
| 822 | + | |row| row.get(0), | |
| 823 | + | ) | |
| 824 | + | .unwrap(); | |
| 825 | + | assert!(sp.is_some()); | |
| 826 | + | assert!(sp.unwrap().ends_with("kick.wav")); | |
| 827 | + | } | |
| 828 | + | ||
| 829 | + | #[test] | |
| 830 | + | fn import_unsafe_deduplicates() { | |
| 831 | + | let (dir, db, store) = setup(); | |
| 832 | + | let src = create_test_file(&dir, "kick.wav", b"same unsafe content"); | |
| 833 | + | ||
| 834 | + | let hash1 = store.import_unsafe(&src, &db).unwrap(); | |
| 835 | + | let hash2 = store.import_unsafe(&src, &db).unwrap(); | |
| 836 | + | assert_eq!(hash1, hash2); | |
| 837 | + | ||
| 838 | + | let count: i64 = db | |
| 839 | + | .conn() | |
| 840 | + | .query_row("SELECT COUNT(*) FROM samples", [], |row| row.get(0)) | |
| 841 | + | .unwrap(); | |
| 842 | + | assert_eq!(count, 1); | |
| 843 | + | } | |
| 844 | + | ||
| 845 | + | #[test] | |
| 846 | + | fn sample_source_path_returns_none_for_normal() { | |
| 847 | + | let (dir, db, store) = setup(); | |
| 848 | + | let src = create_test_file(&dir, "kick.wav", b"normal import"); | |
| 849 | + | let hash = store.import(&src, &db).unwrap(); | |
| 850 | + | ||
| 851 | + | assert!(sample_source_path(&db, &hash).unwrap().is_none()); | |
| 852 | + | } | |
| 853 | + | ||
| 854 | + | #[test] | |
| 855 | + | fn sample_source_path_returns_path_for_unsafe() { | |
| 856 | + | let (dir, db, store) = setup(); | |
| 857 | + | let src = create_test_file(&dir, "kick.wav", b"unsafe import"); | |
| 858 | + | let hash = store.import_unsafe(&src, &db).unwrap(); | |
| 859 | + | ||
| 860 | + | let sp = sample_source_path(&db, &hash).unwrap(); | |
| 861 | + | assert!(sp.is_some()); | |
| 862 | + | } | |
| 863 | + | ||
| 864 | + | #[test] | |
| 865 | + | fn resolve_file_path_prefers_source_path() { | |
| 866 | + | let (dir, db, store) = setup(); | |
| 867 | + | let src = create_test_file(&dir, "kick.wav", b"unsafe resolve test"); | |
| 868 | + | let hash = store.import_unsafe(&src, &db).unwrap(); | |
| 869 | + | ||
| 870 | + | let resolved = resolve_file_path(&store, &db, &hash, "wav").unwrap(); | |
| 871 | + | // Should resolve to the original file, not the store | |
| 872 | + | assert!(!resolved.starts_with(store.root())); | |
| 873 | + | } | |
| 874 | + | ||
| 875 | + | #[test] | |
| 876 | + | fn resolve_file_path_falls_back_to_store() { | |
| 877 | + | let (dir, db, store) = setup(); | |
| 878 | + | let src = create_test_file(&dir, "kick.wav", b"fallback test"); | |
| 879 | + | ||
| 880 | + | // Import normally (file exists in store) | |
| 881 | + | let hash = store.import(&src, &db).unwrap(); | |
| 882 | + | ||
| 883 | + | let resolved = resolve_file_path(&store, &db, &hash, "wav").unwrap(); | |
| 884 | + | assert!(resolved.starts_with(store.root())); | |
| 885 | + | } | |
| 886 | + | ||
| 887 | + | #[test] | |
| 888 | + | fn relocate_sample_rejects_hash_mismatch() { | |
| 889 | + | let (dir, db, store) = setup(); | |
| 890 | + | let src = create_test_file(&dir, "kick.wav", b"original content"); | |
| 891 | + | let hash = store.import_unsafe(&src, &db).unwrap(); | |
| 892 | + | ||
| 893 | + | let wrong_file = create_test_file(&dir, "snare.wav", b"different content"); | |
| 894 | + | let result = relocate_sample(&store, &db, &hash, &wrong_file); | |
| 895 | + | assert!(result.is_err()); | |
| 896 | + | assert!(result.unwrap_err().to_string().contains("hash mismatch")); | |
| 897 | + | } | |
| 898 | + | ||
| 899 | + | #[test] | |
| 900 | + | fn relocate_sample_updates_source_path() { | |
| 901 | + | let (dir, db, store) = setup(); | |
| 902 | + | let src = create_test_file(&dir, "kick.wav", b"relocate content"); | |
| 903 | + | let hash = store.import_unsafe(&src, &db).unwrap(); | |
| 904 | + | ||
| 905 | + | // Move the file | |
| 906 | + | let new_loc = dir.path().join("moved_kick.wav"); | |
| 907 | + | fs::copy(&src, &new_loc).unwrap(); | |
| 908 | + | ||
| 909 | + | relocate_sample(&store, &db, &hash, &new_loc).unwrap(); | |
| 910 | + | ||
| 911 | + | let sp = sample_source_path(&db, &hash).unwrap().unwrap(); | |
| 912 | + | assert!(sp.contains("moved_kick.wav")); | |
| 913 | + | } | |
| 914 | + | ||
| 915 | + | #[test] | |
| 916 | + | fn check_unsafe_integrity_counts_correctly() { | |
| 917 | + | let (dir, db, store) = setup(); | |
| 918 | + | let src1 = create_test_file(&dir, "kick.wav", b"integrity kick"); | |
| 919 | + | let src2 = create_test_file(&dir, "snare.wav", b"integrity snare"); | |
| 920 | + | ||
| 921 | + | store.import_unsafe(&src1, &db).unwrap(); | |
| 922 | + | let hash2 = store.import_unsafe(&src2, &db).unwrap(); | |
| 923 | + | ||
| 924 | + | // Delete snare from disk to simulate missing file | |
| 925 | + | let sp = sample_source_path(&db, &hash2).unwrap().unwrap(); | |
| 926 | + | fs::remove_file(&sp).unwrap(); | |
| 927 | + | ||
| 928 | + | let (valid, missing) = check_unsafe_integrity(&db).unwrap(); | |
| 929 | + | assert_eq!(valid, 1); | |
| 930 | + | assert_eq!(missing, 1); | |
| 931 | + | } | |
| 932 | + | ||
| 933 | + | #[test] | |
| 934 | + | fn purge_missing_unsafe_removes_only_missing() { | |
| 935 | + | let (dir, db, store) = setup(); | |
| 936 | + | let src1 = create_test_file(&dir, "kick.wav", b"purge kick"); | |
| 937 | + | let src2 = create_test_file(&dir, "snare.wav", b"purge snare"); | |
| 938 | + | ||
| 939 | + | let hash1 = store.import_unsafe(&src1, &db).unwrap(); | |
| 940 | + | let hash2 = store.import_unsafe(&src2, &db).unwrap(); | |
| 941 | + | ||
| 942 | + | // Delete snare from disk | |
| 943 | + | let sp = sample_source_path(&db, &hash2).unwrap().unwrap(); | |
| 944 | + | fs::remove_file(&sp).unwrap(); | |
| 945 | + | ||
| 946 | + | let purged = purge_missing_unsafe(&db).unwrap(); | |
| 947 | + | assert_eq!(purged, 1); | |
| 948 | + | ||
| 949 | + | // kick still exists, snare is gone | |
| 950 | + | assert!(sample_source_path(&db, &hash1).is_ok()); | |
| 951 | + | assert!(matches!( | |
| 952 | + | sample_source_path(&db, &hash2), | |
| 953 | + | Err(CoreError::SampleNotFound(_)) | |
| 954 | + | )); | |
| 955 | + | } | |
| 956 | + | ||
| 957 | + | #[test] | |
| 958 | + | fn purge_missing_unsafe_noop_when_all_valid() { | |
| 959 | + | let (dir, db, store) = setup(); | |
| 960 | + | let src = create_test_file(&dir, "kick.wav", b"all valid"); | |
| 961 | + | store.import_unsafe(&src, &db).unwrap(); | |
| 962 | + | ||
| 963 | + | let purged = purge_missing_unsafe(&db).unwrap(); | |
| 964 | + | assert_eq!(purged, 0); | |
| 965 | + | } | |
| 601 | 966 | } |