max / audiofiles
12 files changed,
+2324 insertions,
-797 deletions
| @@ -1,2202 +0,0 @@ | |||
| 1 | - | //! Direct backend: wraps `Mutex<Database>` + `SampleStore`, calls core functions directly. | |
| 2 | - | //! | |
| 3 | - | //! This is the "same as before" implementation — every Backend method delegates | |
| 4 | - | //! to the corresponding audiofiles-core function. Used in standalone mode, tests, | |
| 5 | - | //! and as a reference implementation. | |
| 6 | - | ||
| 7 | - | use std::path::{Path, PathBuf}; | |
| 8 | - | ||
| 9 | - | use tracing::instrument; | |
| 10 | - | ||
| 11 | - | use audiofiles_core::analysis::config::AnalysisConfig; | |
| 12 | - | use audiofiles_core::analysis::waveform::WaveformData; | |
| 13 | - | use audiofiles_core::analysis::AnalysisResult; | |
| 14 | - | use audiofiles_core::db::Database; | |
| 15 | - | use audiofiles_core::edit::EditOperation; | |
| 16 | - | use audiofiles_core::edit::worker::{EditCommand, EditEvent, EditWorkerHandle}; | |
| 17 | - | use audiofiles_core::export::profile::DeviceProfileSummary; | |
| 18 | - | use audiofiles_core::export::ExportItem; | |
| 19 | - | use audiofiles_core::forge::{ChopMethod, ConformResult, ConformTarget}; | |
| 20 | - | use audiofiles_core::search::SearchFilter; | |
| 21 | - | use audiofiles_core::collections::Collection; | |
| 22 | - | use audiofiles_core::store::SampleStore; | |
| 23 | - | use audiofiles_core::vfs::{self, Vfs, VfsNode, VfsNodeWithAnalysis}; | |
| 24 | - | use audiofiles_core::{collections, search, tags, CollectionId, NodeId, VfsId}; | |
| 25 | - | ||
| 26 | - | use super::search_worker::{self, SearchCommand, SearchEvent}; | |
| 27 | - | use parking_lot::Mutex; | |
| 28 | - | ||
| 29 | - | use super::{ | |
| 30 | - | Backend, VfsBackend, TagBackend, SearchBackend, CollectionBackend, AnalysisBackend, RulesBackend, ClassifierBackend, SimilarityBackend, StoreBackend, ExportBackend, ForgeBackend, ConfigBackend, OperationsBackend, | |
| 31 | - | BackendError, BackendEvent, BackendResult, ExportConfigDesc, ExportItemDesc, | |
| 32 | - | ImportStrategyDesc, ImportedFolderDesc, TrainedHeadInfo, | |
| 33 | - | }; | |
| 34 | - | ||
| 35 | - | use super::forge::{ForgePending, ForgeImportOutcome, forge_import_chop, forge_import_conform}; | |
| 36 | - | ||
| 37 | - | use crate::cleanup::{CleanupCommand, CleanupHandle}; | |
| 38 | - | use crate::export::{ExportCommand, ExportHandle}; | |
| 39 | - | use crate::import::{ImportCommand, ImportEvent, ImportHandle, ImportStrategy}; | |
| 40 | - | ||
| 41 | - | use audiofiles_core::analysis::worker::{WorkerCommand, WorkerEvent, WorkerHandle}; | |
| 42 | - | use audiofiles_core::forge::{ForgeCommand, ForgeEvent}; | |
| 43 | - | ||
| 44 | - | /// Convert a GUI-facing [`ImportStrategyDesc`] into the worker's [`ImportStrategy`]. | |
| 45 | - | /// Shared by the directory and explicit-file import entry points. | |
| 46 | - | fn resolve_import_strategy(strategy: ImportStrategyDesc) -> ImportStrategy { | |
| 47 | - | match strategy { | |
| 48 | - | ImportStrategyDesc::Flat { vfs_id, parent_id } => { | |
| 49 | - | ImportStrategy::Flat { vfs_id, parent_id } | |
| 50 | - | } | |
| 51 | - | ImportStrategyDesc::NewVfs { vfs_name } => ImportStrategy::NewVfs { vfs_name }, | |
| 52 | - | ImportStrategyDesc::MergeIntoVfs { vfs_id, parent_id } => { | |
| 53 | - | ImportStrategy::MergeIntoVfs { vfs_id, parent_id } | |
| 54 | - | } | |
| 55 | - | } | |
| 56 | - | } | |
| 57 | - | ||
| 58 | - | /// Direct backend: talks to SQLite and the sample store in-process. | |
| 59 | - | pub struct DirectBackend { | |
| 60 | - | db: Mutex<Database>, | |
| 61 | - | store: SampleStore, | |
| 62 | - | data_dir: PathBuf, | |
| 63 | - | // Worker handles for long-running operations | |
| 64 | - | import_worker: Mutex<Option<ImportHandle>>, | |
| 65 | - | analysis_worker: Mutex<Option<WorkerHandle>>, | |
| 66 | - | export_worker: Mutex<Option<ExportHandle>>, | |
| 67 | - | cleanup_worker: Mutex<Option<CleanupHandle>>, | |
| 68 | - | loose_files_worker: Mutex<Option<crate::loose_files_worker::LooseFilesHandle>>, | |
| 69 | - | edit_worker: Mutex<Option<EditWorkerHandle>>, | |
| 70 | - | forge_worker: Mutex<Option<audiofiles_core::forge::ForgeWorkerHandle>>, | |
| 71 | - | classifier_worker: Mutex<Option<crate::classifier_worker::ClassifierHandle>>, | |
| 72 | - | // Import context for the in-flight forge op, applied when its CPU stage | |
| 73 | - | // completes (the worker produces temp files; an import job imports them). | |
| 74 | - | forge_pending: Mutex<Option<ForgePending>>, | |
| 75 | - | // Off-thread forge import (blob copy + DB writes on its own WAL connection), | |
| 76 | - | // so the import never stalls the GUI frame under db.lock(). | |
| 77 | - | forge_import_job: Mutex<Option<audiofiles_core::worker_runtime::JobHandle<ForgeImportOutcome>>>, | |
| 78 | - | // Search worker: owns the similarity / near-duplicate VP-trees and builds + | |
| 79 | - | // queries them off the GUI thread. Spawned lazily on first search, | |
| 80 | - | // invalidated on new analysis. | |
| 81 | - | search_worker: Mutex<Option<search_worker::SearchWorkerHandle>>, | |
| 82 | - | // Device plugin registry (when device-profiles feature is enabled) | |
| 83 | - | #[cfg(feature = "device-profiles")] | |
| 84 | - | plugin_registry: audiofiles_rhai::registry::PluginRegistry, | |
| 85 | - | } | |
| 86 | - | ||
| 87 | - | impl DirectBackend { | |
| 88 | - | /// Create a new DirectBackend from a database and sample store. | |
| 89 | - | pub fn new(db: Database, store: SampleStore, data_dir: PathBuf) -> Self { | |
| 90 | - | // One-time maintenance: hard-delete tombstones past their retention | |
| 91 | - | // window (docs/design-sample-deletion.md Phase 4). Best-effort — a | |
| 92 | - | // failed sweep must never block app start, so errors are logged, not | |
| 93 | - | // propagated. Runs against the owned db before it moves into the Mutex. | |
| 94 | - | match store.sweep_expired_tombstones(&db) { | |
| 95 | - | Ok(0) => {} | |
| 96 | - | Ok(n) => tracing::info!("tombstone sweep: hard-deleted {n} expired sample(s)"), | |
| 97 | - | Err(e) => tracing::warn!("tombstone sweep failed at startup: {e}"), | |
| 98 | - | } | |
| 99 | - | Self { | |
| 100 | - | db: Mutex::new(db), | |
| 101 | - | store, | |
| 102 | - | data_dir, | |
| 103 | - | import_worker: Mutex::new(None), | |
| 104 | - | analysis_worker: Mutex::new(None), | |
| 105 | - | export_worker: Mutex::new(None), | |
| 106 | - | cleanup_worker: Mutex::new(None), | |
| 107 | - | loose_files_worker: Mutex::new(None), | |
| 108 | - | edit_worker: Mutex::new(None), | |
| 109 | - | forge_worker: Mutex::new(None), | |
| 110 | - | classifier_worker: Mutex::new(None), | |
| 111 | - | forge_pending: Mutex::new(None), | |
| 112 | - | forge_import_job: Mutex::new(None), | |
| 113 | - | search_worker: Mutex::new(None), | |
| 114 | - | #[cfg(feature = "device-profiles")] | |
| 115 | - | plugin_registry: audiofiles_rhai::create_registry().unwrap_or_else(|e| { | |
| 116 | - | // Embedded (include_str!) profiles are compile-checked, so this | |
| 117 | - | // should never trip — but if it does, the conform/M8/MPC wedge | |
| 118 | - | // would silently show "No device profiles". Log so the empty | |
| 119 | - | // registry is diagnosable instead of mysterious. | |
| 120 | - | tracing::error!("Failed to load device-profile registry, falling back to empty: {e}"); | |
| 121 | - | audiofiles_rhai::registry::PluginRegistry::new() | |
| 122 | - | }), | |
| 123 | - | } | |
| 124 | - | } | |
| 125 | - | ||
| 126 | - | /// Access the store (needed for preview decode path in BrowserState). | |
| 127 | - | pub fn store(&self) -> &SampleStore { | |
| 128 | - | &self.store | |
| 129 | - | } | |
| 130 | - | ||
| 131 | - | /// Access the data directory path. | |
| 132 | - | pub fn data_dir(&self) -> &Path { | |
| 133 | - | &self.data_dir | |
| 134 | - | } | |
| 135 | - | ||
| 136 | - | /// Lazily spawn the loose-files worker and send it a command. Keeps the | |
| 137 | - | /// integrity/relocate/purge filesystem work off the egui frame thread. | |
| 138 | - | fn loose_files_send( | |
| 139 | - | &self, | |
| 140 | - | cmd: crate::loose_files_worker::LooseFilesCommand, | |
| 141 | - | ) -> BackendResult<()> { | |
| 142 | - | let mut guard = self.loose_files_worker.lock(); | |
| 143 | - | if guard.is_none() { | |
| 144 | - | let db_path = self.data_dir.join("audiofiles.db"); | |
| 145 | - | let handle = crate::loose_files_worker::spawn_loose_files_worker( | |
| 146 | - | db_path, | |
| 147 | - | self.store.root().to_path_buf(), | |
| 148 | - | ) | |
| 149 | - | .map_err(|e| { | |
| 150 | - | BackendError::Other(format!("failed to spawn loose-files worker: {e}")) | |
| 151 | - | })?; | |
| 152 | - | *guard = Some(handle); | |
| 153 | - | } | |
| 154 | - | // If the cached worker died (e.g. its DB open failed inside the thread), | |
| 155 | - | // send returns false. Drop the dead handle so the next call respawns, and | |
| 156 | - | // report Err so the caller never sets a busy flag for a command that no | |
| 157 | - | // worker will ever answer (which would wedge the repaint-while-busy guard). | |
| 158 | - | if !guard.as_ref().expect("loose-files worker present").send(cmd) { | |
| 159 | - | *guard = None; | |
| 160 | - | return Err(BackendError::Other( | |
| 161 | - | "loose-files worker is not running".to_string(), | |
| 162 | - | )); | |
| 163 | - | } | |
| 164 | - | Ok(()) | |
| 165 | - | } | |
| 166 | - | ||
| 167 | - | /// Cancel any in-flight import worker and spawn a fresh one, returning its | |
| 168 | - | /// handle (not yet stored). Shared by [`start_import`](Self::start_import) and | |
| 169 | - | /// [`start_file_import`](Self::start_file_import). | |
| 170 | - | fn respawn_import_worker(&self) -> BackendResult<ImportHandle> { | |
| 171 | - | let db_path = self.data_dir.join("audiofiles.db"); | |
| 172 | - | let store_root = self.store.root().to_path_buf(); | |
| 173 | - | if let Some(old) = self.import_worker.lock().take() { | |
| 174 | - | old.send(ImportCommand::Cancel); | |
| 175 | - | drop(old); // joins the thread | |
| 176 | - | } | |
| 177 | - | crate::import::spawn_import_worker(db_path, store_root) | |
| 178 | - | .map_err(|e| BackendError::Other(format!("failed to spawn import worker: {e}"))) | |
| 179 | - | } | |
| 180 | - | ||
| 181 | - | /// Lazily spawn the search worker on first use. It opens its own read-only | |
| 182 | - | /// connection to the database file (WAL allows concurrent reads). | |
| 183 | - | fn ensure_search_worker(&self) -> BackendResult<()> { | |
| 184 | - | let mut guard = self.search_worker.lock(); | |
| 185 | - | if guard.is_none() { | |
| 186 | - | let db_path = self.data_dir.join("audiofiles.db"); | |
| 187 | - | let handle = search_worker::spawn_search_worker(db_path) | |
| 188 | - | .map_err(|e| BackendError::Other(format!("failed to spawn search worker: {e}")))?; | |
| 189 | - | *guard = Some(handle); | |
| 190 | - | } | |
| 191 | - | Ok(()) | |
| 192 | - | } | |
| 193 | - | ||
| 194 | - | /// Resolve a device profile's constraints into the export config and filter items. | |
| 195 | - | /// | |
| 196 | - | /// Called before spawning the export worker so profile resolution happens | |
| 197 | - | /// on the main thread (where PluginRegistry is accessible). | |
| 198 | - | #[cfg(feature = "device-profiles")] | |
| 199 | - | #[instrument(skip_all)] | |
| 200 | - | fn resolve_device_profile( | |
| 201 | - | &self, | |
| 202 | - | config: &mut audiofiles_core::export::ExportConfig, | |
| 203 | - | items: &mut Vec<ExportItem>, | |
| 204 | - | ) { | |
| 205 | - | use audiofiles_core::export::profile::ChannelConstraint; | |
| 206 | - | use audiofiles_core::export::{ExportChannels, ExportFormat}; | |
| 207 | - | ||
| 208 | - | let profile_name = match config.device_profile { | |
| 209 | - | Some(ref name) => name.clone(), | |
| 210 | - | None => return, | |
| 211 | - | }; | |
| 212 | - | ||
| 213 | - | let plugin = match self.plugin_registry.get(&profile_name) { | |
| 214 | - | Some(p) => p, | |
| 215 | - | None => return, | |
| 216 | - | }; | |
| 217 | - | ||
| 218 | - | let profile = &plugin.profile; | |
| 219 | - | ||
| 220 | - | // Format: if Original, set to profile's first supported format | |
| 221 | - | if config.format == ExportFormat::Original | |
| 222 | - | && let Some(fmt) = profile.audio.formats.first() { | |
| 223 | - | config.format = fmt.clone(); | |
| 224 | - | } | |
| 225 | - | ||
| 226 | - | // Sample rate: if not set, use profile's first rate | |
| 227 | - | if config.sample_rate.is_none() { | |
| 228 | - | config.sample_rate = profile.audio.sample_rates.first().copied(); | |
| 229 | - | } | |
| 230 | - | ||
| 231 | - | // Bit depth: if not set, use profile's first depth | |
| 232 | - | if config.bit_depth.is_none() { | |
| 233 | - | config.bit_depth = profile.audio.bit_depths.first().copied(); | |
| 234 | - | } | |
| 235 | - | ||
| 236 | - | // Channels | |
| 237 | - | match profile.audio.channels { | |
| 238 | - | ChannelConstraint::Mono => config.channels = ExportChannels::Mono, | |
| 239 | - | ChannelConstraint::Stereo => config.channels = ExportChannels::Stereo, | |
| 240 | - | ChannelConstraint::Both => {} // leave as-is | |
| 241 | - | } | |
| 242 | - | ||
| 243 | - | // Naming rules | |
| 244 | - | config.naming_rules = profile.naming.clone(); | |
| 245 | - | ||
| 246 | - | // File size limit | |
| 247 | - | config.max_file_size_bytes = profile.limits.as_ref().and_then(|l| l.max_file_size_bytes); | |
| 248 | - | ||
| 249 | - | // validate_sample hook: filter items through Rhai script | |
| 250 | - | if let Some(ref ast) = plugin.hooks.validate_sample { | |
| 251 | - | // Collect all sample info with the lock held, then drop it before running scripts | |
| 252 | - | // to avoid holding the DB lock during potentially slow Rhai execution. | |
| 253 | - | let mut infos: Vec<_> = { | |
| 254 | - | let db = self.db.lock(); | |
| 255 | - | items.iter().map(|item| super::sample_info::build_sample_info(&db, &self.store, item)).collect() | |
| 256 | - | }; | |
| 257 | - | // Probe bit depth off the DB lock and in parallel: N independent | |
| 258 | - | // file-header reads that previously ran serially *while holding the | |
| 259 | - | // lock* on the GUI thread. rayon bounds the concurrency to the pool. | |
| 260 | - | { | |
| 261 | - | use rayon::prelude::*; | |
| 262 | - | let paths: Vec<Option<std::path::PathBuf>> = items | |
| 263 | - | .iter() | |
| 264 | - | .map(|item| self.store.sample_path(&item.hash, &item.ext).ok()) | |
| 265 | - | .collect(); | |
| 266 | - | let depths: Vec<u16> = paths | |
| 267 | - | .into_par_iter() | |
| 268 | - | .map(|p| p.and_then(|p| super::sample_info::probe_bit_depth(&p)).unwrap_or(0)) | |
| 269 | - | .collect(); | |
| 270 | - | for (info, depth) in infos.iter_mut().zip(depths) { | |
| 271 | - | info.bit_depth = depth; | |
| 272 | - | } | |
| 273 | - | } | |
| 274 | - | let engine = self.plugin_registry.engine(); | |
| 275 | - | let mut keep = vec![true; items.len()]; | |
| 276 | - | for (i, info) in infos.into_iter().enumerate() { | |
| 277 | - | keep[i] = match audiofiles_rhai::hooks::run_validate_sample(engine, ast, info) { | |
| 278 | - | // A deliberate `false` from the script rejects the sample. | |
| 279 | - | Ok(decision) => decision, | |
| 280 | - | // A script *fault* (not a rejection) must not silently shrink | |
| 281 | - | // the export — previously `.unwrap_or(false)` dropped the file | |
| 282 | - | // and reported success. Fail open (keep the sample) and surface | |
| 283 | - | // the error loudly instead. | |
| 284 | - | Err(e) => { | |
| 285 | - | tracing::error!( | |
| 286 | - | profile = %profile_name, | |
| 287 | - | error = %e, | |
| 288 | - | "device-profile validate_sample script errored; keeping sample in export rather than dropping it silently" | |
| 289 | - | ); | |
| 290 | - | true | |
| 291 | - | } | |
| 292 | - | }; | |
| 293 | - | } | |
| 294 | - | let mut ki = 0; | |
| 295 | - | items.retain(|_| { let k = keep[ki]; ki += 1; k }); | |
| 296 | - | } | |
| 297 | - | ||
| 298 | - | // transform_filename hook: pre-compute output names with custom naming logic | |
| 299 | - | if let Some(ref ast) = plugin.hooks.transform_filename { | |
| 300 | - | let pattern = config | |
| 301 | - | .naming_pattern | |
| 302 | - | .as_ref() | |
| 303 | - | .and_then(|p| audiofiles_core::rename::RenamePattern::parse(p).ok()); | |
| 304 | - | ||
| 305 | - | let mut names = audiofiles_core::export::resolve_output_names( | |
| 306 | - | items, | |
| 307 | - | config, | |
| 308 | - | pattern.as_ref(), | |
| 309 | - | ); | |
| 310 | - | ||
| 311 | - | let device_name = profile.name.clone(); | |
| 312 | - | let destination = config.destination.display().to_string(); | |
| 313 | - | let total = names.len() as i64; | |
| 314 | - | ||
| 315 | - | for (i, name) in names.iter_mut().enumerate() { | |
| 316 | - | let (stem, ext) = split_name_ext(name); | |
| 317 | - | let ctx = audiofiles_rhai::types::RhaiExportContext { | |
| 318 | - | device_name: device_name.clone(), | |
| 319 | - | destination: destination.clone(), | |
| 320 | - | filename: stem.clone(), | |
| 321 | - | extension: ext.clone(), | |
| 322 | - | index: i as i64, | |
| 323 | - | total, | |
| 324 | - | }; | |
| 325 | - | if let Ok(new_stem) = audiofiles_rhai::hooks::run_transform_filename( | |
| 326 | - | self.plugin_registry.engine(), | |
| 327 | - | ast, | |
| 328 | - | stem, | |
| 329 | - | ctx, | |
| 330 | - | ) { | |
| 331 | - | // Sanitize: strip path separators, NUL bytes, and a bare `..` | |
| 332 | - | // to prevent traversal. resolve_output_names re-sanitizes | |
| 333 | - | // overrides too, so this is a first layer, not the only one. | |
| 334 | - | let safe_stem = new_stem.replace(['/', '\\', '\0'], "_"); | |
| 335 | - | let safe_stem = if safe_stem.is_empty() || safe_stem == ".." || safe_stem == "." { | |
| 336 | - | "untitled".to_string() | |
| 337 | - | } else { | |
| 338 | - | safe_stem | |
| 339 | - | }; | |
| 340 | - | *name = if ext.is_empty() { | |
| 341 | - | safe_stem | |
| 342 | - | } else { | |
| 343 | - | format!("{safe_stem}.{ext}") | |
| 344 | - | }; | |
| 345 | - | } | |
| 346 | - | } | |
| 347 | - | ||
| 348 | - | config.name_overrides = Some(names); | |
| 349 | - | } | |
| 350 | - | } | |
| 351 | - | } | |
| 352 | - | ||
| 353 | - | #[cfg(feature = "device-profiles")] | |
| 354 | - | use audiofiles_core::util::split_name_ext; | |
| 355 | - | ||
| 356 | - | impl VfsBackend for DirectBackend { | |
| 357 | - | // --- VFS --- | |
| 358 | - | ||
| 359 | - | fn list_vfs(&self) -> BackendResult<Vec<Vfs>> { | |
| 360 | - | let db = self.db.lock(); | |
| 361 | - | Ok(vfs::list_vfs(&db)?) | |
| 362 | - | } | |
| 363 | - | ||
| 364 | - | #[instrument(skip(self))] | |
| 365 | - | fn create_vfs(&self, name: &str) -> BackendResult<VfsId> { | |
| 366 | - | let db = self.db.lock(); | |
| 367 | - | Ok(vfs::create_vfs(&db, name)?) | |
| 368 | - | } | |
| 369 | - | ||
| 370 | - | #[instrument(skip(self))] | |
| 371 | - | fn rename_vfs(&self, id: VfsId, new_name: &str) -> BackendResult<()> { | |
| 372 | - | let db = self.db.lock(); | |
| 373 | - | Ok(vfs::rename_vfs(&db, id, new_name)?) | |
| 374 | - | } | |
| 375 | - | ||
| 376 | - | #[instrument(skip(self))] | |
| 377 | - | fn delete_vfs(&self, id: VfsId) -> BackendResult<()> { | |
| 378 | - | let db = self.db.lock(); | |
| 379 | - | Ok(vfs::delete_vfs(&db, id)?) | |
| 380 | - | } | |
| 381 | - | ||
| 382 | - | fn list_children_enriched( | |
| 383 | - | &self, | |
| 384 | - | vfs_id: VfsId, | |
| 385 | - | parent_id: Option<NodeId>, | |
| 386 | - | ) -> BackendResult<Vec<VfsNodeWithAnalysis>> { | |
| 387 | - | let db = self.db.lock(); | |
| 388 | - | Ok(vfs::list_children_enriched(&db, vfs_id, parent_id)?) | |
| 389 | - | } | |
| 390 | - | ||
| 391 | - | fn list_children( | |
| 392 | - | &self, | |
| 393 | - | vfs_id: VfsId, | |
| 394 | - | parent_id: Option<NodeId>, | |
| 395 | - | ) -> BackendResult<Vec<VfsNode>> { | |
| 396 | - | let db = self.db.lock(); | |
| 397 | - | Ok(vfs::list_children(&db, vfs_id, parent_id)?) | |
| 398 | - | } | |
| 399 | - | ||
| 400 | - | #[instrument(skip(self))] | |
| 401 | - | fn create_directory( | |
| 402 | - | &self, | |
| 403 | - | vfs_id: VfsId, | |
| 404 | - | parent_id: Option<NodeId>, | |
| 405 | - | name: &str, | |
| 406 | - | ) -> BackendResult<NodeId> { | |
| 407 | - | let db = self.db.lock(); | |
| 408 | - | Ok(vfs::create_directory(&db, vfs_id, parent_id, name)?) | |
| 409 | - | } | |
| 410 | - | ||
| 411 | - | #[instrument(skip(self))] | |
| 412 | - | fn create_sample_link( | |
| 413 | - | &self, | |
| 414 | - | vfs_id: VfsId, | |
| 415 | - | parent_id: Option<NodeId>, | |
| 416 | - | name: &str, | |
| 417 | - | sample_hash: &str, | |
| 418 | - | ) -> BackendResult<NodeId> { | |
| 419 | - | let db = self.db.lock(); | |
| 420 | - | Ok(vfs::create_sample_link(&db, vfs_id, parent_id, name, sample_hash)?) | |
| 421 | - | } | |
| 422 | - | ||
| 423 | - | fn get_node(&self, id: NodeId) -> BackendResult<VfsNode> { | |
| 424 | - | let db = self.db.lock(); | |
| 425 | - | Ok(vfs::get_node(&db, id)?) | |
| 426 | - | } | |
| 427 | - | ||
| 428 | - | fn get_breadcrumb(&self, node_id: NodeId) -> BackendResult<Vec<VfsNode>> { | |
| 429 | - | let db = self.db.lock(); | |
| 430 | - | Ok(vfs::get_breadcrumb(&db, node_id)?) | |
| 431 | - | } | |
| 432 | - | ||
| 433 | - | #[instrument(skip(self))] | |
| 434 | - | fn rename_node(&self, id: NodeId, new_name: &str) -> BackendResult<()> { | |
| 435 | - | let db = self.db.lock(); | |
| 436 | - | Ok(vfs::rename_node(&db, id, new_name)?) | |
| 437 | - | } | |
| 438 | - | ||
| 439 | - | #[instrument(skip(self))] | |
| 440 | - | fn move_node(&self, id: NodeId, new_parent_id: Option<NodeId>) -> BackendResult<()> { | |
| 441 | - | let db = self.db.lock(); | |
| 442 | - | Ok(vfs::move_node(&db, id, new_parent_id)?) | |
| 443 | - | } | |
| 444 | - | ||
| 445 | - | #[instrument(skip(self))] | |
| 446 | - | fn delete_node(&self, id: NodeId) -> BackendResult<()> { | |
| 447 | - | let db = self.db.lock(); | |
| 448 | - | Ok(vfs::delete_node(&db, id)?) | |
| 449 | - | } | |
| 450 | - | ||
| 451 | - | #[instrument(skip_all)] | |
| 452 | - | fn restore_node(&self, node: &VfsNode) -> BackendResult<()> { | |
| 453 | - | let db = self.db.lock(); | |
| 454 | - | Ok(vfs::restore_node(&db, node)?) | |
| 455 | - | } | |
| 456 | - | ||
| 457 | - | fn collect_subtree(&self, node_id: NodeId) -> BackendResult<Vec<VfsNode>> { | |
| 458 | - | let db = self.db.lock(); | |
| 459 | - | Ok(vfs::collect_subtree(&db, node_id)?) | |
| 460 | - | } | |
| 461 | - | ||
| 462 | - | fn list_all_directories(&self, vfs_id: VfsId) -> BackendResult<Vec<(NodeId, String)>> { | |
| 463 | - | let db = self.db.lock(); | |
| 464 | - | Ok(vfs::list_all_directories(&db, vfs_id)?) | |
| 465 | - | } | |
| 466 | - | ||
| 467 | - | fn find_nodes_by_hashes( | |
| 468 | - | &self, | |
| 469 | - | vfs_id: VfsId, | |
| 470 | - | hashes: &[&str], | |
| 471 | - | ) -> BackendResult<Vec<VfsNodeWithAnalysis>> { | |
| 472 | - | let db = self.db.lock(); | |
| 473 | - | Ok(vfs::find_nodes_by_hashes(&db, vfs_id, hashes)?) | |
| 474 | - | } | |
| 475 | - | } | |
| 476 | - | ||
| 477 | - | impl TagBackend for DirectBackend { | |
| 478 | - | ||
| 479 | - | // --- Tags --- | |
| 480 | - | ||
| 481 | - | #[instrument(skip(self))] | |
| 482 | - | fn add_tag(&self, hash: &str, tag: &str) -> BackendResult<()> { | |
| 483 | - | let db = self.db.lock(); | |
| 484 | - | Ok(tags::add_tag(&db, hash, tag)?) | |
| 485 | - | } | |
| 486 | - | ||
| 487 | - | #[instrument(skip(self))] | |
| 488 | - | fn remove_tag(&self, hash: &str, tag: &str) -> BackendResult<()> { | |
| 489 | - | let db = self.db.lock(); | |
| 490 | - | Ok(tags::remove_tag(&db, hash, tag)?) | |
| 491 | - | } | |
| 492 | - | ||
| 493 | - | fn get_sample_tags(&self, hash: &str) -> BackendResult<Vec<String>> { | |
| 494 | - | let db = self.db.lock(); | |
| 495 | - | Ok(tags::get_sample_tags(&db, hash)?) | |
| 496 | - | } | |
| 497 | - | ||
| 498 | - | fn list_all_tags(&self) -> BackendResult<Vec<String>> { | |
| 499 | - | let db = self.db.lock(); | |
| 500 | - | Ok(tags::list_all_tags(&db)?) |
Lines truncated
| @@ -0,0 +1,144 @@ | |||
| 1 | + | use super::*; | |
| 2 | + | ||
| 3 | + | impl AnalysisBackend for DirectBackend { | |
| 4 | + | ||
| 5 | + | // --- Analysis --- | |
| 6 | + | ||
| 7 | + | fn get_analysis(&self, hash: &str) -> BackendResult<Option<AnalysisResult>> { | |
| 8 | + | let db = self.db.lock(); | |
| 9 | + | Ok(audiofiles_core::analysis::load_analysis(&db, hash)) | |
| 10 | + | } | |
| 11 | + | ||
| 12 | + | fn save_analysis_batch(&self, results: &[AnalysisResult]) -> BackendResult<()> { | |
| 13 | + | if results.is_empty() { | |
| 14 | + | return Ok(()); | |
| 15 | + | } | |
| 16 | + | let db = self.db.lock(); | |
| 17 | + | audiofiles_core::analysis::save_analysis_batch(&db, results)?; | |
| 18 | + | // Invalidate the search worker's cached VP-trees once for the batch — new | |
| 19 | + | // analysis data changes normalization ranges and may add fingerprints, so | |
| 20 | + | // both indexes must rebuild on the next query. | |
| 21 | + | if let Some(w) = self.search_worker.lock().as_ref() { | |
| 22 | + | // Fire-and-forget cache drop; if the worker is gone it simply rebuilds | |
| 23 | + | // on next spawn, so a dropped Invalidate is harmless (no busy flag). | |
| 24 | + | let _ = w.send(SearchCommand::Invalidate); | |
| 25 | + | } | |
| 26 | + | Ok(()) | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | fn apply_rules_to_sample(&self, hash: &str) -> BackendResult<bool> { | |
| 30 | + | let db = self.db.lock(); | |
| 31 | + | Ok(audiofiles_core::rules::apply_rules_to_sample(&db, hash)?) | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | fn apply_rules_to_samples(&self, hashes: &[String]) -> BackendResult<usize> { | |
| 35 | + | let db = self.db.lock(); | |
| 36 | + | Ok(audiofiles_core::rules::apply_rules_to_samples(&db, hashes)?) | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | fn samples_needing_features(&self) -> BackendResult<Vec<(String, String)>> { | |
| 40 | + | let db = self.db.lock(); | |
| 41 | + | Ok(audiofiles_core::analysis::samples_needing_features(&db)?) | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | ||
| 46 | + | impl RulesBackend for DirectBackend { | |
| 47 | + | ||
| 48 | + | fn list_rules(&self) -> BackendResult<Vec<audiofiles_core::rules::Rule>> { | |
| 49 | + | let db = self.db.lock(); | |
| 50 | + | Ok(audiofiles_core::rules::list_rules(&db)?) | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | fn create_rule( | |
| 54 | + | &self, | |
| 55 | + | rule: audiofiles_core::rules::NewRule, | |
| 56 | + | ) -> BackendResult<audiofiles_core::rules::Rule> { | |
| 57 | + | let db = self.db.lock(); | |
| 58 | + | Ok(audiofiles_core::rules::create_rule(&db, rule)?) | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | fn update_rule(&self, rule: &audiofiles_core::rules::Rule) -> BackendResult<()> { | |
| 62 | + | let db = self.db.lock(); | |
| 63 | + | Ok(audiofiles_core::rules::update_rule(&db, rule)?) | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | fn delete_rule(&self, id: &str) -> BackendResult<()> { | |
| 67 | + | let db = self.db.lock(); | |
| 68 | + | Ok(audiofiles_core::rules::delete_rule(&db, id)?) | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | fn set_rule_enabled(&self, id: &str, enabled: bool) -> BackendResult<()> { | |
| 72 | + | let db = self.db.lock(); | |
| 73 | + | Ok(audiofiles_core::rules::set_rule_enabled(&db, id, enabled)?) | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | fn preview_rule_matches(&self, rule: &audiofiles_core::rules::Rule) -> BackendResult<usize> { | |
| 77 | + | let db = self.db.lock(); | |
| 78 | + | Ok(audiofiles_core::rules::preview_rule_matches(&db, rule)?) | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | fn apply_all_rules(&self) -> BackendResult<usize> { | |
| 82 | + | let db = self.db.lock(); | |
| 83 | + | Ok(audiofiles_core::rules::apply_all_rules(&db)?) | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | fn sample_tag_provenance( | |
| 87 | + | &self, | |
| 88 | + | hash: &str, | |
| 89 | + | ) -> BackendResult<Vec<(String, String, Option<String>)>> { | |
| 90 | + | let db = self.db.lock(); | |
| 91 | + | Ok(audiofiles_core::rules::sample_tag_provenance(&db, hash)?) | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | ||
| 96 | + | impl ConfigBackend for DirectBackend { | |
| 97 | + | ||
| 98 | + | // --- Config --- | |
| 99 | + | ||
| 100 | + | fn get_config(&self, key: &str) -> BackendResult<Option<String>> { | |
| 101 | + | let db = self.db.lock(); | |
| 102 | + | let result = db | |
| 103 | + | .conn() | |
| 104 | + | .query_row( | |
| 105 | + | "SELECT value FROM user_config WHERE key = ?1", | |
| 106 | + | [key], | |
| 107 | + | |row| row.get::<_, String>(0), | |
| 108 | + | ) | |
| 109 | + | .ok(); | |
| 110 | + | Ok(result) | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | #[instrument(skip(self, value))] | |
| 114 | + | fn set_config(&self, key: &str, value: &str) -> BackendResult<()> { | |
| 115 | + | let db = self.db.lock(); | |
| 116 | + | db.conn() | |
| 117 | + | .execute( | |
| 118 | + | "INSERT OR REPLACE INTO user_config (key, value) VALUES (?1, ?2)", | |
| 119 | + | rusqlite::params![key, value], | |
| 120 | + | ) | |
| 121 | + | .map_err(audiofiles_core::error::CoreError::Db)?; | |
| 122 | + | Ok(()) | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | fn delete_config(&self, key: &str) -> BackendResult<()> { | |
| 126 | + | let db = self.db.lock(); | |
| 127 | + | db.conn() | |
| 128 | + | .execute("DELETE FROM user_config WHERE key = ?1", [key]) | |
| 129 | + | .map_err(audiofiles_core::error::CoreError::Db)?; | |
| 130 | + | Ok(()) | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | fn set_vfs_sync_files(&self, id: VfsId, enabled: bool) -> BackendResult<()> { | |
| 134 | + | let db = self.db.lock(); | |
| 135 | + | vfs::set_vfs_sync_files(&db, id, enabled)?; | |
| 136 | + | Ok(()) | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | fn get_vfs_sync_files(&self, id: VfsId) -> BackendResult<bool> { | |
| 140 | + | let db = self.db.lock(); | |
| 141 | + | Ok(vfs::get_vfs_sync_files(&db, id)?) | |
| 142 | + | } | |
| 143 | + | } | |
| 144 | + |
| @@ -0,0 +1,232 @@ | |||
| 1 | + | use super::*; | |
| 2 | + | ||
| 3 | + | impl SearchBackend for DirectBackend { | |
| 4 | + | ||
| 5 | + | // --- Search --- | |
| 6 | + | ||
| 7 | + | fn search_in_folder( | |
| 8 | + | &self, | |
| 9 | + | filter: &SearchFilter, | |
| 10 | + | vfs_id: VfsId, | |
| 11 | + | parent_id: Option<NodeId>, | |
| 12 | + | ) -> BackendResult<Vec<VfsNodeWithAnalysis>> { | |
| 13 | + | let db = self.db.lock(); | |
| 14 | + | Ok(search::search_in_folder(&db, filter, vfs_id, parent_id)?) | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | fn search_global(&self, filter: &SearchFilter) -> BackendResult<Vec<VfsNodeWithAnalysis>> { | |
| 18 | + | let db = self.db.lock(); | |
| 19 | + | Ok(search::search_global(&db, filter)?) | |
| 20 | + | } | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | ||
| 24 | + | impl ClassifierBackend for DirectBackend { | |
| 25 | + | ||
| 26 | + | fn ml_auto_apply_all(&self, k: usize) -> BackendResult<usize> { | |
| 27 | + | use audiofiles_core::analysis::{exemplar, trained_head}; | |
| 28 | + | let db = self.db.lock(); | |
| 29 | + | // Route the heavy library-wide pass through the distilled head when one is | |
| 30 | + | // current (O(tags) per sample instead of O(library)); else brute-force k-NN. | |
| 31 | + | // Detail-panel suggestions stay on k-NN for neighbor explainability. | |
| 32 | + | if let Some(head) = trained_head::load(&db)? { | |
| 33 | + | Ok(trained_head::auto_apply_library(&db, &head)?) | |
| 34 | + | } else { | |
| 35 | + | Ok(exemplar::auto_apply_library(&db, k)?) | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | fn train_classifier_head(&self) -> BackendResult<Option<TrainedHeadInfo>> { | |
| 40 | + | let db = self.db.lock(); | |
| 41 | + | Ok(audiofiles_core::analysis::trained_head::train_and_save(&db)?.map(|h| TrainedHeadInfo { | |
| 42 | + | class_count: h.classes.len(), | |
| 43 | + | exemplar_count: h.exemplar_count, | |
| 44 | + | trained_at: h.trained_at, | |
| 45 | + | })) | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | fn classifier_head_info(&self) -> BackendResult<Option<TrainedHeadInfo>> { | |
| 49 | + | let db = self.db.lock(); | |
| 50 | + | Ok(audiofiles_core::analysis::trained_head::load(&db)?.map(|h| TrainedHeadInfo { | |
| 51 | + | class_count: h.classes.len(), | |
| 52 | + | exemplar_count: h.exemplar_count, | |
| 53 | + | trained_at: h.trained_at, | |
| 54 | + | })) | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | fn clear_classifier_head(&self) -> BackendResult<()> { | |
| 58 | + | let db = self.db.lock(); | |
| 59 | + | Ok(audiofiles_core::analysis::trained_head::clear(&db)?) | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | fn classifier_head_readiness(&self) -> BackendResult<(usize, bool)> { | |
| 63 | + | use audiofiles_core::analysis::trained_head; | |
| 64 | + | let db = self.db.lock(); | |
| 65 | + | let n = trained_head::labeled_exemplar_count(&db)?; | |
| 66 | + | Ok((n, n >= trained_head::RECOMMENDED_MIN_EXEMPLARS)) | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | fn start_classifier_job(&self, job: crate::backend::ClassifierJob) -> BackendResult<()> { | |
| 70 | + | let db_path = self.data_dir.join("audiofiles.db"); | |
| 71 | + | // Detach any prior job instead of dropping (joining) it here: the one-shot | |
| 72 | + | // classifier job can run for minutes, so joining on the GUI thread would | |
| 73 | + | // freeze the UI. Hand the old handle to a reaper thread that joins it off | |
| 74 | + | // the render thread. The UI gates to one active job, so at most one stale | |
| 75 | + | // job is reaping at a time. | |
| 76 | + | if let Some(old) = self.classifier_worker.lock().take() { | |
| 77 | + | let _ = audiofiles_core::worker_runtime::spawn_detached( | |
| 78 | + | "classifier-reaper", | |
| 79 | + | move || drop(old), | |
| 80 | + | ); | |
| 81 | + | } | |
| 82 | + | let handle = crate::classifier_worker::spawn_classifier_job(db_path, job) | |
| 83 | + | .map_err(|e| BackendError::Other(format!("failed to spawn classifier worker: {e}")))?; | |
| 84 | + | *self.classifier_worker.lock() = Some(handle); | |
| 85 | + | Ok(()) | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | fn export_afcl( | |
| 89 | + | &self, | |
| 90 | + | path: &Path, | |
| 91 | + | opts: &audiofiles_core::analysis::afcl::ExportOptions, | |
| 92 | + | ) -> BackendResult<()> { | |
| 93 | + | let db = self.db.lock(); | |
| 94 | + | Ok(audiofiles_core::analysis::afcl::export_to_path(&db, path, opts)?) | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | fn import_afcl( | |
| 98 | + | &self, | |
| 99 | + | path: &Path, | |
| 100 | + | ) -> BackendResult<audiofiles_core::analysis::afcl::ImportSummary> { | |
| 101 | + | let db = self.db.lock(); | |
| 102 | + | Ok(audiofiles_core::analysis::afcl::import_from_path(&db, path)?) | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | fn list_classifier_layers( | |
| 106 | + | &self, | |
| 107 | + | ) -> BackendResult<Vec<audiofiles_core::analysis::afcl::ClassifierLayer>> { | |
| 108 | + | let db = self.db.lock(); | |
| 109 | + | Ok(audiofiles_core::analysis::afcl::list_layers(&db)?) | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | fn remove_classifier_layer(&self, id: &str) -> BackendResult<()> { | |
| 113 | + | let db = self.db.lock(); | |
| 114 | + | Ok(audiofiles_core::analysis::afcl::remove_layer(&db, id)?) | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | fn set_classifier_layer_enabled(&self, id: &str, enabled: bool) -> BackendResult<()> { | |
| 118 | + | let db = self.db.lock(); | |
| 119 | + | Ok(audiofiles_core::analysis::afcl::set_layer_enabled(&db, id, enabled)?) | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | fn set_classifier_layer_weight(&self, id: &str, weight: f64) -> BackendResult<()> { | |
| 123 | + | let db = self.db.lock(); | |
| 124 | + | Ok(audiofiles_core::analysis::afcl::set_layer_weight(&db, id, weight)?) | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | fn accept_ml_tag(&self, hash: &str, tag: &str) -> BackendResult<()> { | |
| 128 | + | let db = self.db.lock(); | |
| 129 | + | db.transaction_core(|tx| { | |
| 130 | + | audiofiles_core::rules::apply_tag_sourced(&db, tx, hash, tag, "ml").map(|_| ()) | |
| 131 | + | })?; | |
| 132 | + | Ok(()) | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | fn get_tag_policy( | |
| 136 | + | &self, | |
| 137 | + | tag: &str, | |
| 138 | + | ) -> BackendResult<audiofiles_core::analysis::exemplar::TagPolicy> { | |
| 139 | + | let db = self.db.lock(); | |
| 140 | + | Ok(audiofiles_core::analysis::exemplar::get_policy(&db, tag)?) | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | #[instrument(skip(self))] | |
| 144 | + | fn set_tag_policy(&self, tag: &str, review: f64, auto: f64) -> BackendResult<()> { | |
| 145 | + | let db = self.db.lock(); | |
| 146 | + | Ok(audiofiles_core::analysis::exemplar::set_policy(&db, tag, review, auto)?) | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | fn list_tag_policies( | |
| 150 | + | &self, | |
| 151 | + | ) -> BackendResult<Vec<(String, audiofiles_core::analysis::exemplar::TagPolicy)>> { | |
| 152 | + | let db = self.db.lock(); | |
| 153 | + | Ok(audiofiles_core::analysis::exemplar::list_policies(&db)?) | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | fn cluster_library( | |
| 157 | + | &self, | |
| 158 | + | k: usize, | |
| 159 | + | ) -> BackendResult<Vec<audiofiles_core::analysis::cluster::Cluster>> { | |
| 160 | + | let db = self.db.lock(); | |
| 161 | + | Ok(audiofiles_core::analysis::cluster::cluster_library(&db, k, 50)?.clusters) | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | fn apply_cluster_tag(&self, members: &[String], tag: &str) -> BackendResult<usize> { | |
| 165 | + | let db = self.db.lock(); | |
| 166 | + | Ok(audiofiles_core::analysis::cluster::apply_cluster_tag(&db, members, tag)?) | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | fn harvest_folder_labels( | |
| 170 | + | &self, | |
| 171 | + | ) -> BackendResult<Vec<audiofiles_core::harvest::FolderLabel>> { | |
| 172 | + | let db = self.db.lock(); | |
| 173 | + | Ok(audiofiles_core::harvest::harvest_folder_labels(&db)?) | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | fn apply_harvested_tag(&self, hashes: &[String], tag: &str) -> BackendResult<usize> { | |
| 177 | + | let db = self.db.lock(); | |
| 178 | + | Ok(audiofiles_core::harvest::apply_harvested_tag(&db, hashes, tag)?) | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | fn remove_tags_by_source(&self, source: &str) -> BackendResult<usize> { | |
| 182 | + | let db = self.db.lock(); | |
| 183 | + | Ok(audiofiles_core::rules::remove_tags_by_source(&db, source)?) | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | fn get_waveform(&self, hash: &str) -> BackendResult<Option<WaveformData>> { | |
| 187 | + | let db = self.db.lock(); | |
| 188 | + | Ok(audiofiles_core::analysis::waveform::load_waveform(&db, hash)) | |
| 189 | + | } | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | ||
| 193 | + | impl SimilarityBackend for DirectBackend { | |
| 194 | + | ||
| 195 | + | // --- Similarity --- | |
| 196 | + | ||
| 197 | + | fn start_find_similar(&self, hash: &str, limit: usize) -> BackendResult<()> { | |
| 198 | + | self.ensure_search_worker()?; | |
| 199 | + | let mut guard = self.search_worker.lock(); | |
| 200 | + | let delivered = guard.as_ref().is_some_and(|w| { | |
| 201 | + | w.send(SearchCommand::FindSimilar { | |
| 202 | + | hash: hash.to_string(), | |
| 203 | + | limit, | |
| 204 | + | }) | |
| 205 | + | }); | |
| 206 | + | // A dead cached worker would never answer; drop it (next call respawns) | |
| 207 | + | // and report Err so the caller doesn't set latest_similarity_request and | |
| 208 | + | // spin the repaint-while-busy guard forever. | |
| 209 | + | if !delivered { | |
| 210 | + | *guard = None; | |
| 211 | + | return Err(BackendError::Other("search worker is not running".to_string())); | |
| 212 | + | } | |
| 213 | + | Ok(()) | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | fn start_find_near_duplicates(&self, hash: &str, limit: usize) -> BackendResult<()> { | |
| 217 | + | self.ensure_search_worker()?; | |
| 218 | + | let mut guard = self.search_worker.lock(); | |
| 219 | + | let delivered = guard.as_ref().is_some_and(|w| { | |
| 220 | + | w.send(SearchCommand::FindNearDuplicates { | |
| 221 | + | hash: hash.to_string(), | |
| 222 | + | limit, | |
| 223 | + | }) | |
| 224 | + | }); | |
| 225 | + | if !delivered { | |
| 226 | + | *guard = None; | |
| 227 | + | return Err(BackendError::Other("search worker is not running".to_string())); | |
| 228 | + | } | |
| 229 | + | Ok(()) | |
| 230 | + | } | |
| 231 | + | } | |
| 232 | + |
| @@ -0,0 +1,203 @@ | |||
| 1 | + | use super::*; | |
| 2 | + | ||
| 3 | + | impl ForgeBackend for DirectBackend { | |
| 4 | + | ||
| 5 | + | // --- Sample Forge --- | |
| 6 | + | ||
| 7 | + | #[instrument(skip_all)] | |
| 8 | + | fn start_chop_preview(&self, hash: &str, ext: &str, method: &ChopMethod) -> BackendResult<()> { | |
| 9 | + | // Resolve the source path under a brief lock; the decode + slice runs on | |
| 10 | + | // the forge worker and the marks arrive via ForgeEvent::PreviewComplete. | |
| 11 | + | let path = { | |
| 12 | + | let db = self.db.lock(); | |
| 13 | + | audiofiles_core::store::resolve_file_path(&self.store, &db, hash, ext)? | |
| 14 | + | }; | |
| 15 | + | // Reuse the forge worker if present (preview queues behind any running | |
| 16 | + | // chop/conform); spawn one lazily otherwise. Preview is read-only and | |
| 17 | + | // sets no forge_pending, so it never interferes with an import. | |
| 18 | + | let mut guard = self.forge_worker.lock(); | |
| 19 | + | if guard.is_none() { | |
| 20 | + | let handle = audiofiles_core::forge::spawn_forge_worker() | |
| 21 | + | .map_err(|e| BackendError::Other(format!("failed to spawn forge worker: {e}")))?; | |
| 22 | + | *guard = Some(handle); | |
| 23 | + | } | |
| 24 | + | let delivered = guard.as_ref().is_some_and(|w| { | |
| 25 | + | w.send(ForgeCommand::Preview { | |
| 26 | + | source_path: path, | |
| 27 | + | method: method.clone(), | |
| 28 | + | }) | |
| 29 | + | }); | |
| 30 | + | // Dead cached worker → drop it (next call respawns) and report Err so the | |
| 31 | + | // caller doesn't set forge.busy and wedge the repaint guard / busy-guard. | |
| 32 | + | if !delivered { | |
| 33 | + | *guard = None; | |
| 34 | + | return Err(BackendError::Other("forge worker is not running".to_string())); | |
| 35 | + | } | |
| 36 | + | Ok(()) | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | #[instrument(skip_all)] | |
| 40 | + | fn chop_sample( | |
| 41 | + | &self, | |
| 42 | + | vfs_id: VfsId, | |
| 43 | + | hash: &str, | |
| 44 | + | ext: &str, | |
| 45 | + | name: &str, | |
| 46 | + | parent_id: Option<NodeId>, | |
| 47 | + | method: &ChopMethod, | |
| 48 | + | ) -> BackendResult<usize> { | |
| 49 | + | let db = self.db.lock(); | |
| 50 | + | let path = audiofiles_core::store::resolve_file_path(&self.store, &db, hash, ext)?; | |
| 51 | + | let result = audiofiles_core::forge::chop_to_vfs( | |
| 52 | + | &self.store, | |
| 53 | + | &db, | |
| 54 | + | vfs_id, | |
| 55 | + | &path, | |
| 56 | + | name, | |
| 57 | + | parent_id, | |
| 58 | + | method, | |
| 59 | + | )?; | |
| 60 | + | Ok(result.slice_count) | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | #[instrument(skip_all)] | |
| 64 | + | fn conform_sample( | |
| 65 | + | &self, | |
| 66 | + | vfs_id: VfsId, | |
| 67 | + | hash: &str, | |
| 68 | + | ext: &str, | |
| 69 | + | name: &str, | |
| 70 | + | parent_id: Option<NodeId>, | |
| 71 | + | target: &ConformTarget, | |
| 72 | + | ) -> BackendResult<ConformResult> { | |
| 73 | + | let db = self.db.lock(); | |
| 74 | + | // Overshoot policy: trim to the ceiling only when the user has opted in; | |
| 75 | + | // the default leaves the signal untouched and merely reports. | |
| 76 | + | let auto_trim = db | |
| 77 | + | .conn() | |
| 78 | + | .query_row( | |
| 79 | + | "SELECT value FROM user_config WHERE key = ?1", | |
| 80 | + | [crate::backend::FORGE_AUTO_TRIM_OVERSHOOT_KEY], | |
| 81 | + | |row| row.get::<_, String>(0), | |
| 82 | + | ) | |
| 83 | + | .ok() | |
| 84 | + | .is_some_and(|v| v == "true"); | |
| 85 | + | let path = audiofiles_core::store::resolve_file_path(&self.store, &db, hash, ext)?; | |
| 86 | + | Ok(audiofiles_core::forge::conform_to_vfs( | |
| 87 | + | &self.store, | |
| 88 | + | &db, | |
| 89 | + | vfs_id, | |
| 90 | + | &path, | |
| 91 | + | name, | |
| 92 | + | parent_id, | |
| 93 | + | target, | |
| 94 | + | auto_trim, | |
| 95 | + | )?) | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | #[instrument(skip_all)] | |
| 99 | + | fn start_chop( | |
| 100 | + | &self, | |
| 101 | + | vfs_id: VfsId, | |
| 102 | + | hash: &str, | |
| 103 | + | ext: &str, | |
| 104 | + | name: &str, | |
| 105 | + | parent_id: Option<NodeId>, | |
| 106 | + | method: &ChopMethod, | |
| 107 | + | ) -> BackendResult<()> { | |
| 108 | + | // Resolve the source path under a brief lock; the heavy work runs on the | |
| 109 | + | // worker, and the import lands when ForgeEvent::ChopComplete is drained. | |
| 110 | + | let path = { | |
| 111 | + | let db = self.db.lock(); | |
| 112 | + | audiofiles_core::store::resolve_file_path(&self.store, &db, hash, ext)? | |
| 113 | + | }; | |
| 114 | + | ||
| 115 | + | // Reuse the persistent worker. Sending Cancel sets the cancel flag so any | |
| 116 | + | // in-flight job bails cooperatively; the queued Chop then runs. Dropping | |
| 117 | + | // the old handle here would join its thread ON the GUI thread — a visible | |
| 118 | + | // frame stall on a long in-flight job. The worker resets the flag when it | |
| 119 | + | // dequeues the new command. | |
| 120 | + | { | |
| 121 | + | let mut guard = self.forge_worker.lock(); | |
| 122 | + | if guard.is_none() { | |
| 123 | + | *guard = Some( | |
| 124 | + | audiofiles_core::forge::spawn_forge_worker().map_err(|e| { | |
| 125 | + | BackendError::Other(format!("failed to spawn forge worker: {e}")) | |
| 126 | + | })?, | |
| 127 | + | ); | |
| 128 | + | } | |
| 129 | + | let handle = guard.as_ref().expect("forge worker present"); | |
| 130 | + | let _ = handle.send(ForgeCommand::Cancel); | |
| 131 | + | let delivered = handle.send(ForgeCommand::Chop { | |
| 132 | + | source_path: path, | |
| 133 | + | base_name: name.to_string(), | |
| 134 | + | method: method.clone(), | |
| 135 | + | }); | |
| 136 | + | if !delivered { | |
| 137 | + | *guard = None; | |
| 138 | + | return Err(BackendError::Other("forge worker is not running".to_string())); | |
| 139 | + | } | |
| 140 | + | } | |
| 141 | + | *self.forge_pending.lock() = Some(ForgePending::Chop { vfs_id, parent_id }); | |
| 142 | + | Ok(()) | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | #[instrument(skip_all)] | |
| 146 | + | fn start_conform( | |
| 147 | + | &self, | |
| 148 | + | vfs_id: VfsId, | |
| 149 | + | hash: &str, | |
| 150 | + | ext: &str, | |
| 151 | + | name: &str, | |
| 152 | + | parent_id: Option<NodeId>, | |
| 153 | + | target: &ConformTarget, | |
| 154 | + | ) -> BackendResult<()> { | |
| 155 | + | let (path, auto_trim) = { | |
| 156 | + | let db = self.db.lock(); | |
| 157 | + | let auto_trim = db | |
| 158 | + | .conn() | |
| 159 | + | .query_row( | |
| 160 | + | "SELECT value FROM user_config WHERE key = ?1", | |
| 161 | + | [crate::backend::FORGE_AUTO_TRIM_OVERSHOOT_KEY], | |
| 162 | + | |row| row.get::<_, String>(0), | |
| 163 | + | ) | |
| 164 | + | .ok() | |
| 165 | + | .is_some_and(|v| v == "true"); | |
| 166 | + | let path = audiofiles_core::store::resolve_file_path(&self.store, &db, hash, ext)?; | |
| 167 | + | (path, auto_trim) | |
| 168 | + | }; | |
| 169 | + | ||
| 170 | + | // Reuse the persistent worker (see start_chop): Cancel + queue, never | |
| 171 | + | // drop+join on the GUI thread. | |
| 172 | + | { | |
| 173 | + | let mut guard = self.forge_worker.lock(); | |
| 174 | + | if guard.is_none() { | |
| 175 | + | *guard = Some( | |
| 176 | + | audiofiles_core::forge::spawn_forge_worker().map_err(|e| { | |
| 177 | + | BackendError::Other(format!("failed to spawn forge worker: {e}")) | |
| 178 | + | })?, | |
| 179 | + | ); | |
| 180 | + | } | |
| 181 | + | let handle = guard.as_ref().expect("forge worker present"); | |
| 182 | + | let _ = handle.send(ForgeCommand::Cancel); | |
| 183 | + | let delivered = handle.send(ForgeCommand::Conform { | |
| 184 | + | source_path: path, | |
| 185 | + | base_name: name.to_string(), | |
| 186 | + | target: target.clone(), | |
| 187 | + | auto_trim, | |
| 188 | + | }); | |
| 189 | + | if !delivered { | |
| 190 | + | *guard = None; | |
| 191 | + | return Err(BackendError::Other("forge worker is not running".to_string())); | |
| 192 | + | } | |
| 193 | + | } | |
| 194 | + | *self.forge_pending.lock() = Some(ForgePending::Conform { | |
| 195 | + | vfs_id, | |
| 196 | + | parent_id, | |
| 197 | + | sample_rate: target.sample_rate, | |
| 198 | + | bit_depth: target.bit_depth, | |
| 199 | + | }); | |
| 200 | + | Ok(()) | |
| 201 | + | } | |
| 202 | + | } | |
| 203 | + |
| @@ -0,0 +1,231 @@ | |||
| 1 | + | use super::*; | |
| 2 | + | ||
| 3 | + | impl VfsBackend for DirectBackend { | |
| 4 | + | // --- VFS --- | |
| 5 | + | ||
| 6 | + | fn list_vfs(&self) -> BackendResult<Vec<Vfs>> { | |
| 7 | + | let db = self.db.lock(); | |
| 8 | + | Ok(vfs::list_vfs(&db)?) | |
| 9 | + | } | |
| 10 | + | ||
| 11 | + | #[instrument(skip(self))] | |
| 12 | + | fn create_vfs(&self, name: &str) -> BackendResult<VfsId> { | |
| 13 | + | let db = self.db.lock(); | |
| 14 | + | Ok(vfs::create_vfs(&db, name)?) | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | #[instrument(skip(self))] | |
| 18 | + | fn rename_vfs(&self, id: VfsId, new_name: &str) -> BackendResult<()> { | |
| 19 | + | let db = self.db.lock(); | |
| 20 | + | Ok(vfs::rename_vfs(&db, id, new_name)?) | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | #[instrument(skip(self))] | |
| 24 | + | fn delete_vfs(&self, id: VfsId) -> BackendResult<()> { | |
| 25 | + | let db = self.db.lock(); | |
| 26 | + | Ok(vfs::delete_vfs(&db, id)?) | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | fn list_children_enriched( | |
| 30 | + | &self, | |
| 31 | + | vfs_id: VfsId, | |
| 32 | + | parent_id: Option<NodeId>, | |
| 33 | + | ) -> BackendResult<Vec<VfsNodeWithAnalysis>> { | |
| 34 | + | let db = self.db.lock(); | |
| 35 | + | Ok(vfs::list_children_enriched(&db, vfs_id, parent_id)?) | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | fn list_children( | |
| 39 | + | &self, | |
| 40 | + | vfs_id: VfsId, | |
| 41 | + | parent_id: Option<NodeId>, | |
| 42 | + | ) -> BackendResult<Vec<VfsNode>> { | |
| 43 | + | let db = self.db.lock(); | |
| 44 | + | Ok(vfs::list_children(&db, vfs_id, parent_id)?) | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | #[instrument(skip(self))] | |
| 48 | + | fn create_directory( | |
| 49 | + | &self, | |
| 50 | + | vfs_id: VfsId, | |
| 51 | + | parent_id: Option<NodeId>, | |
| 52 | + | name: &str, | |
| 53 | + | ) -> BackendResult<NodeId> { | |
| 54 | + | let db = self.db.lock(); | |
| 55 | + | Ok(vfs::create_directory(&db, vfs_id, parent_id, name)?) | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | #[instrument(skip(self))] | |
| 59 | + | fn create_sample_link( | |
| 60 | + | &self, | |
| 61 | + | vfs_id: VfsId, | |
| 62 | + | parent_id: Option<NodeId>, | |
| 63 | + | name: &str, | |
| 64 | + | sample_hash: &str, | |
| 65 | + | ) -> BackendResult<NodeId> { | |
| 66 | + | let db = self.db.lock(); | |
| 67 | + | Ok(vfs::create_sample_link(&db, vfs_id, parent_id, name, sample_hash)?) | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | fn get_node(&self, id: NodeId) -> BackendResult<VfsNode> { | |
| 71 | + | let db = self.db.lock(); | |
| 72 | + | Ok(vfs::get_node(&db, id)?) | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | fn get_breadcrumb(&self, node_id: NodeId) -> BackendResult<Vec<VfsNode>> { | |
| 76 | + | let db = self.db.lock(); | |
| 77 | + | Ok(vfs::get_breadcrumb(&db, node_id)?) | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | #[instrument(skip(self))] | |
| 81 | + | fn rename_node(&self, id: NodeId, new_name: &str) -> BackendResult<()> { | |
| 82 | + | let db = self.db.lock(); | |
| 83 | + | Ok(vfs::rename_node(&db, id, new_name)?) | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | #[instrument(skip(self))] | |
| 87 | + | fn move_node(&self, id: NodeId, new_parent_id: Option<NodeId>) -> BackendResult<()> { | |
| 88 | + | let db = self.db.lock(); | |
| 89 | + | Ok(vfs::move_node(&db, id, new_parent_id)?) | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | #[instrument(skip(self))] | |
| 93 | + | fn delete_node(&self, id: NodeId) -> BackendResult<()> { | |
| 94 | + | let db = self.db.lock(); | |
| 95 | + | Ok(vfs::delete_node(&db, id)?) | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | #[instrument(skip_all)] | |
| 99 | + | fn restore_node(&self, node: &VfsNode) -> BackendResult<()> { | |
| 100 | + | let db = self.db.lock(); | |
| 101 | + | Ok(vfs::restore_node(&db, node)?) | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | fn collect_subtree(&self, node_id: NodeId) -> BackendResult<Vec<VfsNode>> { | |
| 105 | + | let db = self.db.lock(); | |
| 106 | + | Ok(vfs::collect_subtree(&db, node_id)?) | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | fn list_all_directories(&self, vfs_id: VfsId) -> BackendResult<Vec<(NodeId, String)>> { | |
| 110 | + | let db = self.db.lock(); | |
| 111 | + | Ok(vfs::list_all_directories(&db, vfs_id)?) | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | fn find_nodes_by_hashes( | |
| 115 | + | &self, | |
| 116 | + | vfs_id: VfsId, | |
| 117 | + | hashes: &[&str], | |
| 118 | + | ) -> BackendResult<Vec<VfsNodeWithAnalysis>> { | |
| 119 | + | let db = self.db.lock(); | |
| 120 | + | Ok(vfs::find_nodes_by_hashes(&db, vfs_id, hashes)?) | |
| 121 | + | } | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | ||
| 125 | + | impl TagBackend for DirectBackend { | |
| 126 | + | ||
| 127 | + | // --- Tags --- | |
| 128 | + | ||
| 129 | + | #[instrument(skip(self))] | |
| 130 | + | fn add_tag(&self, hash: &str, tag: &str) -> BackendResult<()> { | |
| 131 | + | let db = self.db.lock(); | |
| 132 | + | Ok(tags::add_tag(&db, hash, tag)?) | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | #[instrument(skip(self))] | |
| 136 | + | fn remove_tag(&self, hash: &str, tag: &str) -> BackendResult<()> { | |
| 137 | + | let db = self.db.lock(); | |
| 138 | + | Ok(tags::remove_tag(&db, hash, tag)?) | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | fn get_sample_tags(&self, hash: &str) -> BackendResult<Vec<String>> { | |
| 142 | + | let db = self.db.lock(); | |
| 143 | + | Ok(tags::get_sample_tags(&db, hash)?) | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | fn list_all_tags(&self) -> BackendResult<Vec<String>> { | |
| 147 | + | let db = self.db.lock(); | |
| 148 | + | Ok(tags::list_all_tags(&db)?) | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | #[instrument(skip(self, hashes))] | |
| 152 | + | fn bulk_add_tag(&self, hashes: &[&str], tag: &str) -> BackendResult<usize> { | |
| 153 | + | let db = self.db.lock(); | |
| 154 | + | Ok(tags::bulk_add_tag(&db, hashes, tag)?) | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | #[instrument(skip(self, hashes))] | |
| 158 | + | fn bulk_remove_tag(&self, hashes: &[&str], tag: &str) -> BackendResult<usize> { | |
| 159 | + | let db = self.db.lock(); | |
| 160 | + | Ok(tags::bulk_remove_tag(&db, hashes, tag)?) | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | fn rename_tag_globally(&self, old_tag: &str, new_tag: &str) -> BackendResult<usize> { | |
| 164 | + | let db = self.db.lock(); | |
| 165 | + | Ok(tags::rename_tag_globally(&db, old_tag, new_tag)?) | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | fn count_samples_with_tag(&self, tag: &str) -> BackendResult<usize> { | |
| 169 | + | let db = self.db.lock(); | |
| 170 | + | Ok(tags::find_by_tag(&db, tag)?.len()) | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | fn remove_tag_globally(&self, tag: &str) -> BackendResult<usize> { | |
| 174 | + | let db = self.db.lock(); | |
| 175 | + | Ok(tags::remove_tag_globally(&db, tag)?) | |
| 176 | + | } | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | ||
| 180 | + | impl CollectionBackend for DirectBackend { | |
| 181 | + | ||
| 182 | + | // --- Collections --- | |
| 183 | + | ||
| 184 | + | fn list_collections(&self) -> BackendResult<Vec<Collection>> { | |
| 185 | + | let db = self.db.lock(); | |
| 186 | + | Ok(collections::list_collections(&db)?) | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | #[instrument(skip(self))] | |
| 190 | + | fn create_collection(&self, name: &str, description: Option<&str>) -> BackendResult<CollectionId> { | |
| 191 | + | let db = self.db.lock(); | |
| 192 | + | Ok(collections::create_collection(&db, name, description)?) | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | fn create_dynamic_collection(&self, name: &str, filter: &SearchFilter) -> BackendResult<CollectionId> { | |
| 196 | + | let db = self.db.lock(); | |
| 197 | + | Ok(collections::create_dynamic_collection(&db, name, filter)?) | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | fn rename_collection(&self, id: CollectionId, new_name: &str) -> BackendResult<()> { | |
| 201 | + | let db = self.db.lock(); | |
| 202 | + | Ok(collections::rename_collection(&db, id, new_name)?) | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | #[instrument(skip(self))] | |
| 206 | + | fn delete_collection(&self, id: CollectionId) -> BackendResult<()> { | |
| 207 | + | let db = self.db.lock(); | |
| 208 | + | Ok(collections::delete_collection(&db, id)?) | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | fn add_to_collection(&self, collection_id: CollectionId, sample_hash: &str) -> BackendResult<()> { | |
| 212 | + | let db = self.db.lock(); | |
| 213 | + | Ok(collections::add_to_collection(&db, collection_id, sample_hash)?) | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | fn remove_from_collection(&self, collection_id: CollectionId, sample_hash: &str) -> BackendResult<()> { | |
| 217 | + | let db = self.db.lock(); | |
| 218 | + | Ok(collections::remove_from_collection(&db, collection_id, sample_hash)?) | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | fn list_collection_members(&self, collection_id: CollectionId) -> BackendResult<Vec<String>> { | |
| 222 | + | let db = self.db.lock(); | |
| 223 | + | Ok(collections::list_collection_members(&db, collection_id)?) | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | fn get_sample_collections(&self, sample_hash: &str) -> BackendResult<Vec<Collection>> { | |
| 227 | + | let db = self.db.lock(); | |
| 228 | + | Ok(collections::get_sample_collections(&db, sample_hash)?) | |
| 229 | + | } | |
| 230 | + | } | |
| 231 | + |
| @@ -0,0 +1,366 @@ | |||
| 1 | + | //! Direct backend: wraps `Mutex<Database>` + `SampleStore`, calls core functions directly. | |
| 2 | + | //! | |
| 3 | + | //! This is the "same as before" implementation — every Backend method delegates | |
| 4 | + | //! to the corresponding audiofiles-core function. Used in standalone mode, tests, | |
| 5 | + | //! and as a reference implementation. | |
| 6 | + | ||
| 7 | + | use std::path::{Path, PathBuf}; | |
| 8 | + | ||
| 9 | + | use tracing::instrument; | |
| 10 | + | ||
| 11 | + | use audiofiles_core::analysis::config::AnalysisConfig; | |
| 12 | + | use audiofiles_core::analysis::waveform::WaveformData; | |
| 13 | + | use audiofiles_core::analysis::AnalysisResult; | |
| 14 | + | use audiofiles_core::db::Database; | |
| 15 | + | use audiofiles_core::edit::EditOperation; | |
| 16 | + | use audiofiles_core::edit::worker::{EditCommand, EditEvent, EditWorkerHandle}; | |
| 17 | + | use audiofiles_core::export::profile::DeviceProfileSummary; | |
| 18 | + | use audiofiles_core::export::ExportItem; | |
| 19 | + | use audiofiles_core::forge::{ChopMethod, ConformResult, ConformTarget}; | |
| 20 | + | use audiofiles_core::search::SearchFilter; | |
| 21 | + | use audiofiles_core::collections::Collection; | |
| 22 | + | use audiofiles_core::store::SampleStore; | |
| 23 | + | use audiofiles_core::vfs::{self, Vfs, VfsNode, VfsNodeWithAnalysis}; | |
| 24 | + | use audiofiles_core::{collections, search, tags, CollectionId, NodeId, VfsId}; | |
| 25 | + | ||
| 26 | + | use super::search_worker::{self, SearchCommand, SearchEvent}; | |
| 27 | + | use parking_lot::Mutex; | |
| 28 | + | ||
| 29 | + | use super::{ | |
| 30 | + | Backend, VfsBackend, TagBackend, SearchBackend, CollectionBackend, AnalysisBackend, RulesBackend, ClassifierBackend, SimilarityBackend, StoreBackend, ExportBackend, ForgeBackend, ConfigBackend, OperationsBackend, | |
| 31 | + | BackendError, BackendEvent, BackendResult, ExportConfigDesc, ExportItemDesc, | |
| 32 | + | ImportStrategyDesc, ImportedFolderDesc, TrainedHeadInfo, | |
| 33 | + | }; | |
| 34 | + | ||
| 35 | + | use super::forge::{ForgePending, ForgeImportOutcome, forge_import_chop, forge_import_conform}; | |
| 36 | + | ||
| 37 | + | use crate::cleanup::{CleanupCommand, CleanupHandle}; | |
| 38 | + | use crate::export::{ExportCommand, ExportHandle}; | |
| 39 | + | use crate::import::{ImportCommand, ImportEvent, ImportHandle, ImportStrategy}; | |
| 40 | + | ||
| 41 | + | use audiofiles_core::analysis::worker::{WorkerCommand, WorkerEvent, WorkerHandle}; | |
| 42 | + | use audiofiles_core::forge::{ForgeCommand, ForgeEvent}; | |
| 43 | + | ||
| 44 | + | /// Convert a GUI-facing [`ImportStrategyDesc`] into the worker's [`ImportStrategy`]. | |
| 45 | + | /// Shared by the directory and explicit-file import entry points. | |
| 46 | + | fn resolve_import_strategy(strategy: ImportStrategyDesc) -> ImportStrategy { | |
| 47 | + | match strategy { | |
| 48 | + | ImportStrategyDesc::Flat { vfs_id, parent_id } => { | |
| 49 | + | ImportStrategy::Flat { vfs_id, parent_id } | |
| 50 | + | } | |
| 51 | + | ImportStrategyDesc::NewVfs { vfs_name } => ImportStrategy::NewVfs { vfs_name }, | |
| 52 | + | ImportStrategyDesc::MergeIntoVfs { vfs_id, parent_id } => { | |
| 53 | + | ImportStrategy::MergeIntoVfs { vfs_id, parent_id } | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | /// Direct backend: talks to SQLite and the sample store in-process. | |
| 59 | + | pub struct DirectBackend { | |
| 60 | + | db: Mutex<Database>, | |
| 61 | + | store: SampleStore, | |
| 62 | + | data_dir: PathBuf, | |
| 63 | + | // Worker handles for long-running operations | |
| 64 | + | import_worker: Mutex<Option<ImportHandle>>, | |
| 65 | + | analysis_worker: Mutex<Option<WorkerHandle>>, | |
| 66 | + | export_worker: Mutex<Option<ExportHandle>>, | |
| 67 | + | cleanup_worker: Mutex<Option<CleanupHandle>>, | |
| 68 | + | loose_files_worker: Mutex<Option<crate::loose_files_worker::LooseFilesHandle>>, | |
| 69 | + | edit_worker: Mutex<Option<EditWorkerHandle>>, | |
| 70 | + | forge_worker: Mutex<Option<audiofiles_core::forge::ForgeWorkerHandle>>, | |
| 71 | + | classifier_worker: Mutex<Option<crate::classifier_worker::ClassifierHandle>>, | |
| 72 | + | // Import context for the in-flight forge op, applied when its CPU stage | |
| 73 | + | // completes (the worker produces temp files; an import job imports them). | |
| 74 | + | forge_pending: Mutex<Option<ForgePending>>, | |
| 75 | + | // Off-thread forge import (blob copy + DB writes on its own WAL connection), | |
| 76 | + | // so the import never stalls the GUI frame under db.lock(). | |
| 77 | + | forge_import_job: Mutex<Option<audiofiles_core::worker_runtime::JobHandle<ForgeImportOutcome>>>, | |
| 78 | + | // Search worker: owns the similarity / near-duplicate VP-trees and builds + | |
| 79 | + | // queries them off the GUI thread. Spawned lazily on first search, | |
| 80 | + | // invalidated on new analysis. | |
| 81 | + | search_worker: Mutex<Option<search_worker::SearchWorkerHandle>>, | |
| 82 | + | // Device plugin registry (when device-profiles feature is enabled) | |
| 83 | + | #[cfg(feature = "device-profiles")] | |
| 84 | + | plugin_registry: audiofiles_rhai::registry::PluginRegistry, | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | impl DirectBackend { | |
| 88 | + | /// Create a new DirectBackend from a database and sample store. | |
| 89 | + | pub fn new(db: Database, store: SampleStore, data_dir: PathBuf) -> Self { | |
| 90 | + | // One-time maintenance: hard-delete tombstones past their retention | |
| 91 | + | // window (docs/design-sample-deletion.md Phase 4). Best-effort — a | |
| 92 | + | // failed sweep must never block app start, so errors are logged, not | |
| 93 | + | // propagated. Runs against the owned db before it moves into the Mutex. | |
| 94 | + | match store.sweep_expired_tombstones(&db) { | |
| 95 | + | Ok(0) => {} | |
| 96 | + | Ok(n) => tracing::info!("tombstone sweep: hard-deleted {n} expired sample(s)"), | |
| 97 | + | Err(e) => tracing::warn!("tombstone sweep failed at startup: {e}"), | |
| 98 | + | } | |
| 99 | + | Self { | |
| 100 | + | db: Mutex::new(db), | |
| 101 | + | store, | |
| 102 | + | data_dir, | |
| 103 | + | import_worker: Mutex::new(None), | |
| 104 | + | analysis_worker: Mutex::new(None), | |
| 105 | + | export_worker: Mutex::new(None), | |
| 106 | + | cleanup_worker: Mutex::new(None), | |
| 107 | + | loose_files_worker: Mutex::new(None), | |
| 108 | + | edit_worker: Mutex::new(None), | |
| 109 | + | forge_worker: Mutex::new(None), | |
| 110 | + | classifier_worker: Mutex::new(None), | |
| 111 | + | forge_pending: Mutex::new(None), | |
| 112 | + | forge_import_job: Mutex::new(None), | |
| 113 | + | search_worker: Mutex::new(None), | |
| 114 | + | #[cfg(feature = "device-profiles")] | |
| 115 | + | plugin_registry: audiofiles_rhai::create_registry().unwrap_or_else(|e| { | |
| 116 | + | // Embedded (include_str!) profiles are compile-checked, so this | |
| 117 | + | // should never trip — but if it does, the conform/M8/MPC wedge | |
| 118 | + | // would silently show "No device profiles". Log so the empty | |
| 119 | + | // registry is diagnosable instead of mysterious. | |
| 120 | + | tracing::error!("Failed to load device-profile registry, falling back to empty: {e}"); | |
| 121 | + | audiofiles_rhai::registry::PluginRegistry::new() | |
| 122 | + | }), | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | /// Access the store (needed for preview decode path in BrowserState). | |
| 127 | + | pub fn store(&self) -> &SampleStore { | |
| 128 | + | &self.store | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | /// Access the data directory path. | |
| 132 | + | pub fn data_dir(&self) -> &Path { | |
| 133 | + | &self.data_dir | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | /// Lazily spawn the loose-files worker and send it a command. Keeps the | |
| 137 | + | /// integrity/relocate/purge filesystem work off the egui frame thread. | |
| 138 | + | fn loose_files_send( | |
| 139 | + | &self, | |
| 140 | + | cmd: crate::loose_files_worker::LooseFilesCommand, | |
| 141 | + | ) -> BackendResult<()> { | |
| 142 | + | let mut guard = self.loose_files_worker.lock(); | |
| 143 | + | if guard.is_none() { | |
| 144 | + | let db_path = self.data_dir.join("audiofiles.db"); | |
| 145 | + | let handle = crate::loose_files_worker::spawn_loose_files_worker( | |
| 146 | + | db_path, | |
| 147 | + | self.store.root().to_path_buf(), | |
| 148 | + | ) | |
| 149 | + | .map_err(|e| { | |
| 150 | + | BackendError::Other(format!("failed to spawn loose-files worker: {e}")) | |
| 151 | + | })?; | |
| 152 | + | *guard = Some(handle); | |
| 153 | + | } | |
| 154 | + | // If the cached worker died (e.g. its DB open failed inside the thread), | |
| 155 | + | // send returns false. Drop the dead handle so the next call respawns, and | |
| 156 | + | // report Err so the caller never sets a busy flag for a command that no | |
| 157 | + | // worker will ever answer (which would wedge the repaint-while-busy guard). | |
| 158 | + | if !guard.as_ref().expect("loose-files worker present").send(cmd) { | |
| 159 | + | *guard = None; | |
| 160 | + | return Err(BackendError::Other( | |
| 161 | + | "loose-files worker is not running".to_string(), | |
| 162 | + | )); | |
| 163 | + | } | |
| 164 | + | Ok(()) | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | /// Cancel any in-flight import worker and spawn a fresh one, returning its | |
| 168 | + | /// handle (not yet stored). Shared by [`start_import`](Self::start_import) and | |
| 169 | + | /// [`start_file_import`](Self::start_file_import). | |
| 170 | + | fn respawn_import_worker(&self) -> BackendResult<ImportHandle> { | |
| 171 | + | let db_path = self.data_dir.join("audiofiles.db"); | |
| 172 | + | let store_root = self.store.root().to_path_buf(); | |
| 173 | + | if let Some(old) = self.import_worker.lock().take() { | |
| 174 | + | old.send(ImportCommand::Cancel); | |
| 175 | + | drop(old); // joins the thread | |
| 176 | + | } | |
| 177 | + | crate::import::spawn_import_worker(db_path, store_root) | |
| 178 | + | .map_err(|e| BackendError::Other(format!("failed to spawn import worker: {e}"))) | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | /// Lazily spawn the search worker on first use. It opens its own read-only | |
| 182 | + | /// connection to the database file (WAL allows concurrent reads). | |
| 183 | + | fn ensure_search_worker(&self) -> BackendResult<()> { | |
| 184 | + | let mut guard = self.search_worker.lock(); | |
| 185 | + | if guard.is_none() { | |
| 186 | + | let db_path = self.data_dir.join("audiofiles.db"); | |
| 187 | + | let handle = search_worker::spawn_search_worker(db_path) | |
| 188 | + | .map_err(|e| BackendError::Other(format!("failed to spawn search worker: {e}")))?; | |
| 189 | + | *guard = Some(handle); | |
| 190 | + | } | |
| 191 | + | Ok(()) | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | /// Resolve a device profile's constraints into the export config and filter items. | |
| 195 | + | /// | |
| 196 | + | /// Called before spawning the export worker so profile resolution happens | |
| 197 | + | /// on the main thread (where PluginRegistry is accessible). | |
| 198 | + | #[cfg(feature = "device-profiles")] | |
| 199 | + | #[instrument(skip_all)] | |
| 200 | + | fn resolve_device_profile( | |
| 201 | + | &self, | |
| 202 | + | config: &mut audiofiles_core::export::ExportConfig, | |
| 203 | + | items: &mut Vec<ExportItem>, | |
| 204 | + | ) { | |
| 205 | + | use audiofiles_core::export::profile::ChannelConstraint; | |
| 206 | + | use audiofiles_core::export::{ExportChannels, ExportFormat}; | |
| 207 | + | ||
| 208 | + | let profile_name = match config.device_profile { | |
| 209 | + | Some(ref name) => name.clone(), | |
| 210 | + | None => return, | |
| 211 | + | }; | |
| 212 | + | ||
| 213 | + | let plugin = match self.plugin_registry.get(&profile_name) { | |
| 214 | + | Some(p) => p, | |
| 215 | + | None => return, | |
| 216 | + | }; | |
| 217 | + | ||
| 218 | + | let profile = &plugin.profile; | |
| 219 | + | ||
| 220 | + | // Format: if Original, set to profile's first supported format | |
| 221 | + | if config.format == ExportFormat::Original | |
| 222 | + | && let Some(fmt) = profile.audio.formats.first() { | |
| 223 | + | config.format = fmt.clone(); | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | // Sample rate: if not set, use profile's first rate | |
| 227 | + | if config.sample_rate.is_none() { | |
| 228 | + | config.sample_rate = profile.audio.sample_rates.first().copied(); | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | // Bit depth: if not set, use profile's first depth | |
| 232 | + | if config.bit_depth.is_none() { | |
| 233 | + | config.bit_depth = profile.audio.bit_depths.first().copied(); | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | // Channels | |
| 237 | + | match profile.audio.channels { | |
| 238 | + | ChannelConstraint::Mono => config.channels = ExportChannels::Mono, | |
| 239 | + | ChannelConstraint::Stereo => config.channels = ExportChannels::Stereo, | |
| 240 | + | ChannelConstraint::Both => {} // leave as-is | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | // Naming rules | |
| 244 | + | config.naming_rules = profile.naming.clone(); | |
| 245 | + | ||
| 246 | + | // File size limit | |
| 247 | + | config.max_file_size_bytes = profile.limits.as_ref().and_then(|l| l.max_file_size_bytes); | |
| 248 | + | ||
| 249 | + | // validate_sample hook: filter items through Rhai script | |
| 250 | + | if let Some(ref ast) = plugin.hooks.validate_sample { | |
| 251 | + | // Collect all sample info with the lock held, then drop it before running scripts | |
| 252 | + | // to avoid holding the DB lock during potentially slow Rhai execution. | |
| 253 | + | let mut infos: Vec<_> = { | |
| 254 | + | let db = self.db.lock(); | |
| 255 | + | items.iter().map(|item| super::sample_info::build_sample_info(&db, &self.store, item)).collect() | |
| 256 | + | }; | |
| 257 | + | // Probe bit depth off the DB lock and in parallel: N independent | |
| 258 | + | // file-header reads that previously ran serially *while holding the | |
| 259 | + | // lock* on the GUI thread. rayon bounds the concurrency to the pool. | |
| 260 | + | { | |
| 261 | + | use rayon::prelude::*; | |
| 262 | + | let paths: Vec<Option<std::path::PathBuf>> = items | |
| 263 | + | .iter() | |
| 264 | + | .map(|item| self.store.sample_path(&item.hash, &item.ext).ok()) | |
| 265 | + | .collect(); | |
| 266 | + | let depths: Vec<u16> = paths | |
| 267 | + | .into_par_iter() | |
| 268 | + | .map(|p| p.and_then(|p| super::sample_info::probe_bit_depth(&p)).unwrap_or(0)) | |
| 269 | + | .collect(); | |
| 270 | + | for (info, depth) in infos.iter_mut().zip(depths) { | |
| 271 | + | info.bit_depth = depth; | |
| 272 | + | } | |
| 273 | + | } | |
| 274 | + | let engine = self.plugin_registry.engine(); | |
| 275 | + | let mut keep = vec![true; items.len()]; | |
| 276 | + | for (i, info) in infos.into_iter().enumerate() { | |
| 277 | + | keep[i] = match audiofiles_rhai::hooks::run_validate_sample(engine, ast, info) { | |
| 278 | + | // A deliberate `false` from the script rejects the sample. | |
| 279 | + | Ok(decision) => decision, | |
| 280 | + | // A script *fault* (not a rejection) must not silently shrink | |
| 281 | + | // the export — previously `.unwrap_or(false)` dropped the file | |
| 282 | + | // and reported success. Fail open (keep the sample) and surface | |
| 283 | + | // the error loudly instead. | |
| 284 | + | Err(e) => { | |
| 285 | + | tracing::error!( | |
| 286 | + | profile = %profile_name, | |
| 287 | + | error = %e, | |
| 288 | + | "device-profile validate_sample script errored; keeping sample in export rather than dropping it silently" | |
| 289 | + | ); | |
| 290 | + | true | |
| 291 | + | } | |
| 292 | + | }; | |
| 293 | + | } | |
| 294 | + | let mut ki = 0; | |
| 295 | + | items.retain(|_| { let k = keep[ki]; ki += 1; k }); | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | // transform_filename hook: pre-compute output names with custom naming logic | |
| 299 | + | if let Some(ref ast) = plugin.hooks.transform_filename { | |
| 300 | + | let pattern = config | |
| 301 | + | .naming_pattern | |
| 302 | + | .as_ref() | |
| 303 | + | .and_then(|p| audiofiles_core::rename::RenamePattern::parse(p).ok()); | |
| 304 | + | ||
| 305 | + | let mut names = audiofiles_core::export::resolve_output_names( | |
| 306 | + | items, | |
| 307 | + | config, | |
| 308 | + | pattern.as_ref(), | |
| 309 | + | ); | |
| 310 | + | ||
| 311 | + | let device_name = profile.name.clone(); | |
| 312 | + | let destination = config.destination.display().to_string(); | |
| 313 | + | let total = names.len() as i64; | |
| 314 | + | ||
| 315 | + | for (i, name) in names.iter_mut().enumerate() { | |
| 316 | + | let (stem, ext) = split_name_ext(name); | |
| 317 | + | let ctx = audiofiles_rhai::types::RhaiExportContext { | |
| 318 | + | device_name: device_name.clone(), | |
| 319 | + | destination: destination.clone(), | |
| 320 | + | filename: stem.clone(), | |
| 321 | + | extension: ext.clone(), | |
| 322 | + | index: i as i64, | |
| 323 | + | total, | |
| 324 | + | }; | |
| 325 | + | if let Ok(new_stem) = audiofiles_rhai::hooks::run_transform_filename( | |
| 326 | + | self.plugin_registry.engine(), | |
| 327 | + | ast, | |
| 328 | + | stem, | |
| 329 | + | ctx, | |
| 330 | + | ) { | |
| 331 | + | // Sanitize: strip path separators, NUL bytes, and a bare `..` | |
| 332 | + | // to prevent traversal. resolve_output_names re-sanitizes | |
| 333 | + | // overrides too, so this is a first layer, not the only one. | |
| 334 | + | let safe_stem = new_stem.replace(['/', '\\', '\0'], "_"); | |
| 335 | + | let safe_stem = if safe_stem.is_empty() || safe_stem == ".." || safe_stem == "." { | |
| 336 | + | "untitled".to_string() | |
| 337 | + | } else { | |
| 338 | + | safe_stem | |
| 339 | + | }; | |
| 340 | + | *name = if ext.is_empty() { | |
| 341 | + | safe_stem | |
| 342 | + | } else { | |
| 343 | + | format!("{safe_stem}.{ext}") | |
| 344 | + | }; | |
| 345 | + | } | |
| 346 | + | } | |
| 347 | + | ||
| 348 | + | config.name_overrides = Some(names); | |
| 349 | + | } | |
| 350 | + | } | |
| 351 | + | } | |
| 352 | + | ||
| 353 | + | #[cfg(feature = "device-profiles")] | |
| 354 | + | use audiofiles_core::util::split_name_ext; | |
| 355 | + | ||
| 356 | + | impl Backend for DirectBackend {} | |
| 357 | + | ||
| 358 | + | mod library; | |
| 359 | + | mod analysis; | |
| 360 | + | mod store; | |
| 361 | + | mod classify; | |
| 362 | + | mod forge; | |
| 363 | + | mod operations; | |
| 364 | + | ||
| 365 | + | #[cfg(test)] | |
| 366 | + | mod tests; |
| @@ -0,0 +1,589 @@ | |||
| 1 | + | use super::*; | |
| 2 | + | ||
| 3 | + | impl OperationsBackend for DirectBackend { | |
| 4 | + | ||
| 5 | + | // --- VFS Mirror --- | |
| 6 | + | ||
| 7 | + | fn sync_vfs_mirror(&self, mirror_root: &Path) -> BackendResult<(usize, usize, usize)> { | |
| 8 | + | let db = self.db.lock(); | |
| 9 | + | let config = audiofiles_core::vfs_mirror::MirrorConfig { | |
| 10 | + | mirror_root: mirror_root.to_path_buf(), | |
| 11 | + | store_root: self.store.root().to_path_buf(), | |
| 12 | + | }; | |
| 13 | + | let stats = audiofiles_core::vfs_mirror::sync_mirror(&db, &config)?; | |
| 14 | + | Ok((stats.dirs_created, stats.links_created, stats.entries_removed)) | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | // --- Long-running operations --- | |
| 18 | + | ||
| 19 | + | fn start_import( | |
| 20 | + | &self, | |
| 21 | + | source: &Path, | |
| 22 | + | strategy: ImportStrategyDesc, | |
| 23 | + | ) -> BackendResult<()> { | |
| 24 | + | let import_strategy = resolve_import_strategy(strategy); | |
| 25 | + | let handle = self.respawn_import_worker()?; | |
| 26 | + | handle.send(ImportCommand::ImportDirectory { | |
| 27 | + | source: source.to_path_buf(), | |
| 28 | + | strategy: import_strategy, | |
| 29 | + | }); | |
| 30 | + | *self.import_worker.lock() = Some(handle); | |
| 31 | + | Ok(()) | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | fn start_file_import( | |
| 35 | + | &self, | |
| 36 | + | paths: &[PathBuf], | |
| 37 | + | strategy: ImportStrategyDesc, | |
| 38 | + | ) -> BackendResult<()> { | |
| 39 | + | let import_strategy = resolve_import_strategy(strategy); | |
| 40 | + | let handle = self.respawn_import_worker()?; | |
| 41 | + | handle.send(ImportCommand::ImportFiles { | |
| 42 | + | paths: paths.to_vec(), | |
| 43 | + | strategy: import_strategy, | |
| 44 | + | }); | |
| 45 | + | *self.import_worker.lock() = Some(handle); | |
| 46 | + | Ok(()) | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | fn start_analysis( | |
| 50 | + | &self, | |
| 51 | + | sample_hashes: Vec<(String, String)>, | |
| 52 | + | config: AnalysisConfig, | |
| 53 | + | ) -> BackendResult<()> { | |
| 54 | + | let samples: Vec<(String, String, PathBuf)> = { | |
| 55 | + | let db = self.db.lock(); | |
| 56 | + | sample_hashes | |
| 57 | + | .into_iter() | |
| 58 | + | .filter_map(|(hash, ext)| { | |
| 59 | + | let path = audiofiles_core::store::resolve_file_path( | |
| 60 | + | &self.store, &db, &hash, &ext, | |
| 61 | + | ).ok()?; | |
| 62 | + | if path.exists() { | |
| 63 | + | Some((hash, ext, path)) | |
| 64 | + | } else { | |
| 65 | + | None | |
| 66 | + | } | |
| 67 | + | }) | |
| 68 | + | .collect() | |
| 69 | + | }; | |
| 70 | + | ||
| 71 | + | // Cancel any existing analysis worker before starting a new one | |
| 72 | + | if let Some(old) = self.analysis_worker.lock().take() { | |
| 73 | + | old.send(WorkerCommand::Cancel); | |
| 74 | + | drop(old); | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | let handle = audiofiles_core::analysis::worker::spawn_worker() | |
| 78 | + | .map_err(|e| BackendError::Other(format!("failed to spawn analysis worker: {e}")))?; | |
| 79 | + | handle.send(WorkerCommand::AnalyzeBatch { samples, config }); | |
| 80 | + | *self.analysis_worker.lock() = Some(handle); | |
| 81 | + | Ok(()) | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | fn start_export( | |
| 85 | + | &self, | |
| 86 | + | items: Vec<ExportItemDesc>, | |
| 87 | + | config: ExportConfigDesc, | |
| 88 | + | ) -> BackendResult<()> { | |
| 89 | + | let mut config = config; | |
| 90 | + | let mut items = items; | |
| 91 | + | ||
| 92 | + | #[cfg(feature = "device-profiles")] | |
| 93 | + | self.resolve_device_profile(&mut config, &mut items); | |
| 94 | + | ||
| 95 | + | let store_root = self.store.root().to_path_buf(); | |
| 96 | + | // Cancel any existing export worker before starting a new one | |
| 97 | + | if let Some(old) = self.export_worker.lock().take() { | |
| 98 | + | old.send(ExportCommand::Cancel); | |
| 99 | + | drop(old); | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | let handle = crate::export::spawn_export_worker(store_root) | |
| 103 | + | .map_err(|e| BackendError::Other(format!("failed to spawn export worker: {e}")))?; | |
| 104 | + | handle.send(ExportCommand::Export { items, config }); | |
| 105 | + | *self.export_worker.lock() = Some(handle); | |
| 106 | + | Ok(()) | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | fn cancel_import(&self) -> BackendResult<()> { | |
| 110 | + | if let Some(worker) = self.import_worker.lock().take() { | |
| 111 | + | worker.send(ImportCommand::Cancel); | |
| 112 | + | } | |
| 113 | + | Ok(()) | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | fn cancel_analysis(&self) -> BackendResult<()> { | |
| 117 | + | if let Some(worker) = self.analysis_worker.lock().take() { | |
| 118 | + | worker.send(WorkerCommand::Cancel); | |
| 119 | + | } | |
| 120 | + | Ok(()) | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | fn cancel_export(&self) -> BackendResult<()> { | |
| 124 | + | if let Some(worker) = self.export_worker.lock().take() { | |
| 125 | + | worker.send(ExportCommand::Cancel); | |
| 126 | + | } | |
| 127 | + | Ok(()) | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | fn start_edit(&self, hash: &str, ext: &str, operation: EditOperation) -> BackendResult<()> { | |
| 131 | + | let path = { | |
| 132 | + | let db = self.db.lock(); | |
| 133 | + | audiofiles_core::store::resolve_file_path(&self.store, &db, hash, ext)? | |
| 134 | + | }; | |
| 135 | + | if !path.exists() { | |
| 136 | + | return Err(BackendError::Core( | |
| 137 | + | audiofiles_core::error::CoreError::SampleNotFound(hash.to_string()), | |
| 138 | + | )); | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | // Cancel any existing edit worker before starting a new one | |
| 142 | + | if let Some(old) = self.edit_worker.lock().take() { | |
| 143 | + | old.send(EditCommand::Cancel); | |
| 144 | + | drop(old); | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | let handle = audiofiles_core::edit::worker::spawn_edit_worker() | |
| 148 | + | .map_err(|e| BackendError::Other(format!("failed to spawn edit worker: {e}")))?; | |
| 149 | + | handle.send(EditCommand::Edit { | |
| 150 | + | hash: hash.to_string(), | |
| 151 | + | ext: ext.to_string(), | |
| 152 | + | path, | |
| 153 | + | operation, | |
| 154 | + | }); | |
| 155 | + | *self.edit_worker.lock() = Some(handle); | |
| 156 | + | Ok(()) | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | fn cancel_edit(&self) -> BackendResult<()> { | |
| 160 | + | if let Some(worker) = self.edit_worker.lock().take() { | |
| 161 | + | worker.send(EditCommand::Cancel); | |
| 162 | + | } | |
| 163 | + | Ok(()) | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | fn start_cleanup(&self) -> BackendResult<()> { | |
| 167 | + | // Cancel any existing cleanup worker to prevent concurrent cleanups | |
| 168 | + | if let Some(worker) = self.cleanup_worker.lock().take() { | |
| 169 | + | worker.send(CleanupCommand::Cancel); | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | let db_path = self.data_dir.join("audiofiles.db"); | |
| 173 | + | let store_root = self.store.root().to_path_buf(); | |
| 174 | + | ||
| 175 | + | let handle = crate::cleanup::spawn_cleanup_worker(db_path, store_root) | |
| 176 | + | .map_err(|e| BackendError::Other(format!("failed to spawn cleanup worker: {e}")))?; | |
| 177 | + | handle.send(CleanupCommand::RemoveOrphans); | |
| 178 | + | *self.cleanup_worker.lock() = Some(handle); | |
| 179 | + | Ok(()) | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | fn cancel_cleanup(&self) -> BackendResult<()> { | |
| 183 | + | if let Some(worker) = self.cleanup_worker.lock().take() { | |
| 184 | + | worker.send(CleanupCommand::Cancel); | |
| 185 | + | } | |
| 186 | + | Ok(()) | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | fn record_edit_history( | |
| 190 | + | &self, | |
| 191 | + | source_hash: &str, | |
| 192 | + | result_hash: &str, | |
| 193 | + | operation: &EditOperation, | |
| 194 | + | ) -> BackendResult<()> { | |
| 195 | + | let db = self.db.lock(); | |
| 196 | + | let op_name = operation.display_name(); | |
| 197 | + | let params_json = serde_json::to_string(operation) | |
| 198 | + | .map_err(|e| BackendError::Other(format!("serialize edit params: {e}")))?; | |
| 199 | + | db.conn() | |
| 200 | + | .execute( | |
| 201 | + | "INSERT INTO edit_history (source_hash, result_hash, operation, params_json) | |
| 202 | + | VALUES (?1, ?2, ?3, ?4)", | |
| 203 | + | rusqlite::params![source_hash, result_hash, op_name, params_json], | |
| 204 | + | ) | |
| 205 | + | .map_err(audiofiles_core::error::CoreError::Db)?; | |
| 206 | + | Ok(()) | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | fn delete_edit_history( | |
| 210 | + | &self, | |
| 211 | + | source_hash: &str, | |
| 212 | + | result_hash: &str, | |
| 213 | + | ) -> BackendResult<()> { | |
| 214 | + | let db = self.db.lock(); | |
| 215 | + | db.conn() | |
| 216 | + | .execute( | |
| 217 | + | "DELETE FROM edit_history | |
| 218 | + | WHERE id = ( | |
| 219 | + | SELECT id FROM edit_history | |
| 220 | + | WHERE source_hash = ?1 AND result_hash = ?2 | |
| 221 | + | ORDER BY id DESC | |
| 222 | + | LIMIT 1 | |
| 223 | + | )", | |
| 224 | + | rusqlite::params![source_hash, result_hash], | |
| 225 | + | ) | |
| 226 | + | .map_err(audiofiles_core::error::CoreError::Db)?; | |
| 227 | + | Ok(()) | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | fn storage_stats(&self) -> BackendResult<crate::backend::StorageStats> { | |
| 231 | + | let db = self.db.lock(); | |
| 232 | + | let (sample_count, total_bytes) = db.storage_stats() | |
| 233 | + | .map_err(|e| BackendError::Other(e.to_string()))?; | |
| 234 | + | let db_path = self.data_dir.join("audiofiles.db"); | |
| 235 | + | let db_bytes = std::fs::metadata(&db_path).map(|m| m.len()).unwrap_or(0); | |
| 236 | + | Ok(crate::backend::StorageStats { sample_count, total_bytes, db_bytes }) | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | fn vfs_storage_stats(&self, vfs_id: audiofiles_core::VfsId) -> BackendResult<(u64, u64)> { | |
| 240 | + | let db = self.db.lock(); | |
| 241 | + | db.vfs_storage_stats(vfs_id.as_i64()) | |
| 242 | + | .map_err(|e| BackendError::Other(e.to_string())) | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | fn poll_events(&self) -> Vec<BackendEvent> { | |
| 246 | + | let mut events = Vec::new(); | |
| 247 | + | ||
| 248 | + | // Poll import worker | |
| 249 | + | if let Some(ref worker) = *self.import_worker.lock() { | |
| 250 | + | while let Some(event) = worker.try_recv() { | |
| 251 | + | match event { | |
| 252 | + | ImportEvent::WalkProgress { count, total_bytes } => { | |
| 253 | + | events.push(BackendEvent::ImportWalkProgress { count, total_bytes }); | |
| 254 | + | } | |
| 255 | + | ImportEvent::WalkComplete { total, total_bytes } => { | |
| 256 | + | events.push(BackendEvent::ImportWalkComplete { total, total_bytes }); | |
| 257 | + | } | |
| 258 | + | ImportEvent::Progress { | |
| 259 | + | completed, | |
| 260 | + | total, | |
| 261 | + | current_name, | |
| 262 | + | } => { | |
| 263 | + | events.push(BackendEvent::ImportProgress { | |
| 264 | + | completed, | |
| 265 | + | total, | |
| 266 | + | current_name, | |
| 267 | + | }); | |
| 268 | + | } | |
| 269 | + | ImportEvent::FileError { path, error } => { | |
| 270 | + | events.push(BackendEvent::ImportFileError { path, error }); | |
| 271 | + | } | |
| 272 | + | ImportEvent::Complete { | |
| 273 | + | imported, | |
| 274 | + | total_files, | |
| 275 | + | errors, | |
| 276 | + | duplicates, | |
| 277 | + | folders, | |
| 278 | + | } => { | |
| 279 | + | events.push(BackendEvent::ImportComplete { | |
| 280 | + | imported, | |
| 281 | + | total_files, | |
| 282 | + | errors, | |
| 283 | + | duplicates, | |
| 284 | + | folders: folders | |
| 285 | + | .into_iter() | |
| 286 | + | .map(|f| ImportedFolderDesc { | |
| 287 | + | name: f.name, | |
| 288 | + | samples: f.samples, | |
| 289 | + | }) | |
| 290 | + | .collect(), | |
| 291 | + | }); | |
| 292 | + | } | |
| 293 | + | } | |
| 294 | + | } | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | // Poll analysis worker | |
| 298 | + | if let Some(ref worker) = *self.analysis_worker.lock() { | |
| 299 | + | while let Some(event) = worker.try_recv() { | |
| 300 | + | match event { | |
| 301 | + | WorkerEvent::Progress { | |
| 302 | + | completed, | |
| 303 | + | total, | |
| 304 | + | current_name, | |
| 305 | + | } => { | |
| 306 | + | events.push(BackendEvent::AnalysisProgress { | |
| 307 | + | completed, | |
| 308 | + | total, | |
| 309 | + | current_name, | |
| 310 | + | }); | |
| 311 | + | } | |
| 312 | + | WorkerEvent::SampleDone { | |
| 313 | + | result, | |
| 314 | + | suggestions, | |
| 315 | + | } => { | |
| 316 | + | events.push(BackendEvent::AnalysisSampleDone { | |
| 317 | + | result, | |
| 318 | + | suggestions, | |
| 319 | + | }); | |
| 320 | + | } | |
| 321 | + | WorkerEvent::SampleError { hash, error } => { | |
| 322 | + | events.push(BackendEvent::AnalysisSampleError { hash, error }); | |
| 323 | + | } | |
| 324 | + | WorkerEvent::BatchComplete => { | |
| 325 | + | events.push(BackendEvent::AnalysisBatchComplete); | |
| 326 | + | } | |
| 327 | + | } | |
| 328 | + | } | |
| 329 | + | } | |
| 330 | + | ||
| 331 | + | // Poll export worker | |
| 332 | + | if let Some(ref worker) = *self.export_worker.lock() { | |
| 333 | + | while let Some(event) = worker.try_recv() { | |
| 334 | + | match event { | |
| 335 | + | crate::export::ExportEvent::Progress { | |
| 336 | + | completed, | |
| 337 | + | total, | |
| 338 | + | current_name, | |
| 339 | + | } => { | |
| 340 | + | events.push(BackendEvent::ExportProgress { | |
| 341 | + | completed, | |
| 342 | + | total, | |
| 343 | + | current_name, | |
| 344 | + | }); | |
| 345 | + | } | |
| 346 | + | crate::export::ExportEvent::Complete { total, errors } => { | |
| 347 | + | events.push(BackendEvent::ExportComplete { total, errors }); | |
| 348 | + | } | |
| 349 | + | } | |
| 350 | + | } | |
| 351 | + | } | |
| 352 | + | ||
| 353 | + | // Poll cleanup worker | |
| 354 | + | if let Some(ref worker) = *self.cleanup_worker.lock() { | |
| 355 | + | while let Some(event) = worker.try_recv() { | |
| 356 | + | match event { | |
| 357 | + | crate::cleanup::CleanupEvent::Progress { | |
| 358 | + | completed, | |
| 359 | + | total, | |
| 360 | + | current_name, | |
| 361 | + | } => { | |
| 362 | + | events.push(BackendEvent::CleanupProgress { | |
| 363 | + | completed, | |
| 364 | + | total, | |
| 365 | + | current_name, | |
| 366 | + | }); | |
| 367 | + | } | |
| 368 | + | crate::cleanup::CleanupEvent::Complete { removed, errors } => { | |
| 369 | + | events.push(BackendEvent::CleanupComplete { removed, errors }); | |
| 370 | + | } | |
| 371 | + | } | |
| 372 | + | } | |
| 373 | + | } | |
| 374 | + | ||
| 375 | + | // Poll loose-files worker | |
| 376 | + | if let Some(ref worker) = *self.loose_files_worker.lock() { | |
| 377 | + | use crate::loose_files_worker::LooseFilesEvent as Lfe; | |
| 378 | + | while let Some(event) = worker.try_recv() { | |
| 379 | + | events.push(match event { | |
| 380 | + | Lfe::IntegrityResult { missing } => { | |
| 381 | + | BackendEvent::LooseFilesIntegrity { missing } | |
| 382 | + | } | |
| 383 | + | Lfe::RelocateResult { | |
| 384 | + | relocated, | |
| 385 | + | still_missing, | |
| 386 | + | } => BackendEvent::LooseFilesRelocated { | |
| 387 | + | relocated, | |
| 388 | + | still_missing, | |
| 389 | + | }, | |
| 390 | + | Lfe::PurgeResult { purged } => BackendEvent::LooseFilesPurged { purged }, | |
| 391 | + | Lfe::VerifyResult { checked, corrupt } => { | |
| 392 | + | BackendEvent::StoreIntegrity { checked, corrupt } | |
| 393 | + | } | |
| 394 | + | Lfe::Failed { message } => BackendEvent::LooseFilesFailed { message }, | |
| 395 | + | }); | |
| 396 | + | } | |
| 397 | + | } | |
| 398 | + | ||
| 399 | + | // Poll edit worker | |
| 400 | + | if let Some(ref worker) = *self.edit_worker.lock() { | |
| 401 | + | while let Some(event) = worker.try_recv() { | |
| 402 | + | match event { | |
| 403 | + | EditEvent::Started { hash } => { | |
| 404 | + | events.push(BackendEvent::EditStarted { hash }); | |
| 405 | + | } | |
| 406 | + | EditEvent::Complete { | |
| 407 | + | source_hash, | |
| 408 | + | result_path, | |
| 409 | + | operation, | |
| 410 | + | } => { | |
| 411 | + | events.push(BackendEvent::EditComplete { | |
| 412 | + | source_hash, | |
| 413 | + | result_path, | |
| 414 | + | operation, | |
| 415 | + | }); | |
| 416 | + | } | |
| 417 | + | EditEvent::Error { hash, error } => { | |
| 418 | + | events.push(BackendEvent::EditError { hash, error }); | |
| 419 | + | } | |
| 420 | + | } | |
| 421 | + | } | |
| 422 | + | } | |
| 423 | + | ||
| 424 | + | // Poll forge worker. Collect events first (releasing the worker lock), | |
| 425 | + | // then run the import stage here on the GUI thread — the worker only did | |
| 426 | + | // the CPU work (decode/slice/conform/encode → temp files), so the DB | |
| 427 | + | // write is fast and never blocked the render thread. | |
| 428 | + | let forge_events: Vec<ForgeEvent> = match *self.forge_worker.lock() { | |
| 429 | + | Some(ref worker) => { | |
| 430 | + | let mut evs = Vec::new(); | |
| 431 | + | while let Some(ev) = worker.try_recv() { | |
| 432 | + | evs.push(ev); | |
| 433 | + | } | |
| 434 | + | evs | |
| 435 | + | } | |
| 436 | + | None => Vec::new(), | |
| 437 | + | }; | |
| 438 | + | for ev in forge_events { | |
| 439 | + | match ev { | |
| 440 | + | ForgeEvent::Started => events.push(BackendEvent::ForgeStarted), | |
| 441 | + | ForgeEvent::Error { error } => { | |
| 442 | + | self.forge_pending.lock().take(); | |
| 443 | + | events.push(BackendEvent::ForgeError { error }); | |
| 444 | + | } | |
| 445 | + | ForgeEvent::ChopComplete { staging } => { | |
| 446 | + | let pending = self.forge_pending.lock().take(); | |
| 447 | + | if let Some(ForgePending::Chop { vfs_id, parent_id }) = pending { | |
| 448 | + | // Import off the GUI thread on its own WAL connection so the | |
| 449 | + | // blob copies + DB writes never hold the GUI's db.lock(). | |
| 450 | + | let data_dir = self.data_dir.clone(); | |
| 451 | + | let root = self.store.root().to_path_buf(); | |
| 452 | + | let job = audiofiles_core::worker_runtime::spawn_job( | |
| 453 | + | "forge-import-chop", | |
| 454 | + | || ForgeImportOutcome::Error { | |
| 455 | + | error: "forge chop import panicked".to_string(), | |
| 456 | + | }, | |
| 457 | + | move || forge_import_chop(&data_dir, &root, vfs_id, parent_id, staging), | |
| 458 | + | ); | |
| 459 | + | match job { | |
| 460 | + | Ok(handle) => *self.forge_import_job.lock() = Some(handle), | |
| 461 | + | Err(e) => events.push(BackendEvent::ForgeError { | |
| 462 | + | error: e.to_string(), | |
| 463 | + | }), | |
| 464 | + | } | |
| 465 | + | } else { | |
| 466 | + | // No matching pending context (cancelled/superseded) — | |
| 467 | + | // drop the staged temp files rather than leak them. | |
| 468 | + | for (_, p) in &staging.slices { | |
| 469 | + | let _ = std::fs::remove_file(p); | |
| 470 | + | } | |
| 471 | + | } | |
| 472 | + | } | |
| 473 | + | ForgeEvent::ConformComplete { staging } => { | |
| 474 | + | let pending = self.forge_pending.lock().take(); | |
| 475 | + | if let Some(ForgePending::Conform { | |
| 476 | + | vfs_id, | |
| 477 | + | parent_id, | |
| 478 | + | sample_rate, | |
| 479 | + | bit_depth, | |
| 480 | + | }) = pending | |
| 481 | + | { | |
| 482 | + | // Import off the GUI thread (see ChopComplete). | |
| 483 | + | let data_dir = self.data_dir.clone(); | |
| 484 | + | let root = self.store.root().to_path_buf(); | |
| 485 | + | let job = audiofiles_core::worker_runtime::spawn_job( | |
| 486 | + | "forge-import-conform", | |
| 487 | + | || ForgeImportOutcome::Error { | |
| 488 | + | error: "forge conform import panicked".to_string(), | |
| 489 | + | }, | |
| 490 | + | move || { | |
| 491 | + | forge_import_conform( | |
| 492 | + | &data_dir, &root, vfs_id, parent_id, sample_rate, bit_depth, | |
| 493 | + | staging, | |
| 494 | + | ) | |
| 495 | + | }, | |
| 496 | + | ); | |
| 497 | + | match job { | |
| 498 | + | Ok(handle) => *self.forge_import_job.lock() = Some(handle), | |
| 499 | + | Err(e) => events.push(BackendEvent::ForgeError { | |
| 500 | + | error: e.to_string(), |
Lines truncated
| @@ -0,0 +1,165 @@ | |||
| 1 | + | use super::*; | |
| 2 | + | ||
| 3 | + | impl StoreBackend for DirectBackend { | |
| 4 | + | ||
| 5 | + | // --- Store --- | |
| 6 | + | ||
| 7 | + | fn import_file(&self, path: &Path) -> BackendResult<String> { | |
| 8 | + | let db = self.db.lock(); | |
| 9 | + | Ok(self.store.import(path, &db)?) | |
| 10 | + | } | |
| 11 | + | ||
| 12 | + | fn sample_path(&self, hash: &str, ext: &str) -> BackendResult<PathBuf> { | |
| 13 | + | let db = self.db.lock(); | |
| 14 | + | Ok(audiofiles_core::store::resolve_file_path(&self.store, &db, hash, ext)?) | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | fn sample_extension(&self, hash: &str) -> BackendResult<String> { | |
| 18 | + | let db = self.db.lock(); | |
| 19 | + | Ok(audiofiles_core::store::sample_extension(&db, hash)?) | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | fn sample_original_name(&self, hash: &str) -> BackendResult<String> { | |
| 23 | + | let db = self.db.lock(); | |
| 24 | + | Ok(audiofiles_core::store::sample_original_name(&db, hash)?) | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | fn remove_sample(&self, hash: &str) -> BackendResult<()> { | |
| 28 | + | let db = self.db.lock(); | |
| 29 | + | Ok(self.store.remove(hash, &db)?) | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | fn remove_orphaned_samples(&self) -> BackendResult<usize> { | |
| 33 | + | let db = self.db.lock(); | |
| 34 | + | Ok(self.store.remove_orphaned_samples(&db)?) | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | fn cleanup_orphans_local(&self) -> BackendResult<usize> { | |
| 38 | + | // `remove_orphaned_samples` now suppresses sync triggers internally, so | |
| 39 | + | // local orphan GC never pushes a destructive `samples` DELETE across | |
| 40 | + | // devices. This wrapper exists only to name the user-facing gesture. | |
| 41 | + | let db = self.db.lock(); | |
| 42 | + | Ok(self.store.remove_orphaned_samples(&db)?) | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | fn list_tombstoned(&self) -> BackendResult<Vec<audiofiles_core::store::TombstonedSample>> { | |
| 46 | + | let db = self.db.lock(); | |
| 47 | + | Ok(audiofiles_core::store::tombstoned_samples(&db)?) | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | fn undelete_sample(&self, hash: &str) -> BackendResult<()> { | |
| 51 | + | let db = self.db.lock(); | |
| 52 | + | audiofiles_core::store::undelete_sample(&db, hash)?; | |
| 53 | + | Ok(()) | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | fn purge_sample(&self, hash: &str) -> BackendResult<()> { | |
| 57 | + | let db = self.db.lock(); | |
| 58 | + | Ok(self.store.remove(hash, &db)?) | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | fn sweep_expired_tombstones(&self) -> BackendResult<usize> { | |
| 62 | + | let db = self.db.lock(); | |
| 63 | + | Ok(self.store.sweep_expired_tombstones(&db)?) | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | fn sample_source_path(&self, hash: &str) -> BackendResult<Option<String>> { | |
| 67 | + | let db = self.db.lock(); | |
| 68 | + | Ok(audiofiles_core::store::sample_source_path(&db, hash)?) | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | fn relocate_sample(&self, hash: &str, new_path: &Path) -> BackendResult<()> { | |
| 72 | + | let db = self.db.lock(); | |
| 73 | + | Ok(audiofiles_core::store::relocate_sample(&self.store, &db, hash, new_path)?) | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | fn check_vault_integrity(&self) -> BackendResult<(usize, usize)> { | |
| 77 | + | let db = self.db.lock(); | |
| 78 | + | Ok(audiofiles_core::store::check_loose_files_integrity(&db)?) | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | fn purge_missing_loose_files(&self) -> BackendResult<usize> { | |
| 82 | + | let db = self.db.lock(); | |
| 83 | + | Ok(audiofiles_core::store::purge_missing_loose_files(&db)?) | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | fn relocate_missing_loose_files( | |
| 87 | + | &self, | |
| 88 | + | search_root: &std::path::Path, | |
| 89 | + | ) -> BackendResult<(usize, usize)> { | |
| 90 | + | let db = self.db.lock(); | |
| 91 | + | Ok(audiofiles_core::store::relocate_missing_loose_files(&db, search_root)?) | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | fn start_loose_files_check(&self) -> BackendResult<()> { | |
| 95 | + | self.loose_files_send(crate::loose_files_worker::LooseFilesCommand::CheckIntegrity) | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | fn start_loose_files_relocate(&self, search_root: &std::path::Path) -> BackendResult<()> { | |
| 99 | + | self.loose_files_send(crate::loose_files_worker::LooseFilesCommand::Relocate( | |
| 100 | + | search_root.to_path_buf(), | |
| 101 | + | )) | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | fn start_loose_files_purge(&self) -> BackendResult<()> { | |
| 105 | + | self.loose_files_send(crate::loose_files_worker::LooseFilesCommand::Purge) | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | fn start_store_verify(&self) -> BackendResult<()> { | |
| 109 | + | self.loose_files_send(crate::loose_files_worker::LooseFilesCommand::VerifyStore) | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | ||
| 114 | + | impl ExportBackend for DirectBackend { | |
| 115 | + | ||
| 116 | + | // --- Export --- | |
| 117 | + | ||
| 118 | + | fn collect_export_items( | |
| 119 | + | &self, | |
| 120 | + | vfs_id: VfsId, | |
| 121 | + | parent_id: Option<NodeId>, | |
| 122 | + | ) -> BackendResult<Vec<ExportItem>> { | |
| 123 | + | let db = self.db.lock(); | |
| 124 | + | Ok(audiofiles_core::export::collect_export_items(&db, vfs_id, parent_id)?) | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | fn enrich_export_with_tags(&self, items: &mut [ExportItem]) -> BackendResult<()> { | |
| 128 | + | let db = self.db.lock(); | |
| 129 | + | audiofiles_core::export::enrich_with_tags(&db, items); | |
| 130 | + | Ok(()) | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | // --- Device profiles --- | |
| 134 | + | ||
| 135 | + | fn list_device_profiles(&self) -> BackendResult<Vec<DeviceProfileSummary>> { | |
| 136 | + | #[cfg(feature = "device-profiles")] | |
| 137 | + | { | |
| 138 | + | Ok(self.plugin_registry.list()) | |
| 139 | + | } | |
| 140 | + | #[cfg(not(feature = "device-profiles"))] | |
| 141 | + | { | |
| 142 | + | Ok(Vec::new()) | |
| 143 | + | } | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | fn device_conform_target( | |
| 147 | + | &self, | |
| 148 | + | profile_name: &str, | |
| 149 | + | source_rate: u32, | |
| 150 | + | ) -> BackendResult<Option<ConformTarget>> { | |
| 151 | + | #[cfg(feature = "device-profiles")] | |
| 152 | + | { | |
| 153 | + | Ok(self | |
| 154 | + | .plugin_registry | |
| 155 | + | .get(profile_name) | |
| 156 | + | .map(|plugin| ConformTarget::for_device(&plugin.profile, source_rate))) | |
| 157 | + | } | |
| 158 | + | #[cfg(not(feature = "device-profiles"))] | |
| 159 | + | { | |
| 160 | + | let _ = (profile_name, source_rate); | |
| 161 | + | Ok(None) | |
| 162 | + | } | |
| 163 | + | } | |
| 164 | + | } | |
| 165 | + |
| @@ -0,0 +1,297 @@ | |||
| 1 | + | use super::*; | |
| 2 | + | ||
| 3 | + | fn setup() -> DirectBackend { | |
| 4 | + | let dir = tempfile::TempDir::new().unwrap(); | |
| 5 | + | let db = Database::open_in_memory().unwrap(); | |
| 6 | + | let store = SampleStore::new(dir.path().join("store")).unwrap(); | |
| 7 | + | DirectBackend::new(db, store, dir.path().to_path_buf()) | |
| 8 | + | } | |
| 9 | + | ||
| 10 | + | #[test] | |
| 11 | + | fn list_vfs_empty_then_create() { | |
| 12 | + | let backend = setup(); | |
| 13 | + | // Fresh DB has no VFS | |
| 14 | + | let vfs_list = backend.list_vfs().unwrap(); | |
| 15 | + | assert!(vfs_list.is_empty()); | |
| 16 | + | ||
| 17 | + | let id = backend.create_vfs("Library").unwrap(); | |
| 18 | + | assert!(id.as_i64() > 0); | |
| 19 | + | ||
| 20 | + | let vfs_list = backend.list_vfs().unwrap(); | |
| 21 | + | assert_eq!(vfs_list.len(), 1); | |
| 22 | + | assert_eq!(vfs_list[0].name, "Library"); | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | #[test] | |
| 26 | + | fn directory_crud() { | |
| 27 | + | let backend = setup(); | |
| 28 | + | let vfs_id = backend.create_vfs("Test").unwrap(); | |
| 29 | + | let dir_id = backend.create_directory(vfs_id, None, "Drums").unwrap(); | |
| 30 | + | ||
| 31 | + | let children = backend.list_children_enriched(vfs_id, None).unwrap(); | |
| 32 | + | assert_eq!(children.len(), 1); | |
| 33 | + | assert_eq!(children[0].node.name, "Drums"); | |
| 34 | + | ||
| 35 | + | backend.rename_node(dir_id, "Percussion").unwrap(); | |
| 36 | + | let node = backend.get_node(dir_id).unwrap(); | |
| 37 | + | assert_eq!(node.name, "Percussion"); | |
| 38 | + | ||
| 39 | + | backend.delete_node(dir_id).unwrap(); | |
| 40 | + | let children = backend.list_children_enriched(vfs_id, None).unwrap(); | |
| 41 | + | assert!(children.is_empty()); | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | /// Insert a fake sample row for testing (no actual file on disk). | |
| 45 | + | fn insert_fake_sample(db: &Database, hash: &str) { | |
| 46 | + | db.conn() | |
| 47 | + | .execute( | |
| 48 | + | "INSERT OR IGNORE INTO samples (hash, original_name, file_extension, file_size, import_date, last_modified) | |
| 49 | + | VALUES (?1, ?2, 'wav', 100, 0, 0)", | |
| 50 | + | rusqlite::params![hash, format!("{hash}.wav")], | |
| 51 | + | ) | |
| 52 | + | .unwrap(); | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | #[test] | |
| 56 | + | fn tags_crud() { | |
| 57 | + | let backend = setup(); | |
| 58 | + | // Insert a fake sample row for tag FK | |
| 59 | + | { | |
| 60 | + | let db = backend.db.lock(); | |
| 61 | + | insert_fake_sample(&db, "testhash"); | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | backend.add_tag("testhash", "drums.kick").unwrap(); | |
| 65 | + | let tags = backend.get_sample_tags("testhash").unwrap(); | |
| 66 | + | assert_eq!(tags, vec!["drums.kick"]); | |
| 67 | + | ||
| 68 | + | backend.remove_tag("testhash", "drums.kick").unwrap(); | |
| 69 | + | let tags = backend.get_sample_tags("testhash").unwrap(); | |
| 70 | + | assert!(tags.is_empty()); | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | #[test] | |
| 74 | + | fn config_crud() { | |
| 75 | + | let backend = setup(); | |
| 76 | + | assert!(backend.get_config("theme").unwrap().is_none()); | |
| 77 | + | ||
| 78 | + | backend.set_config("theme", "dark").unwrap(); | |
| 79 | + | assert_eq!(backend.get_config("theme").unwrap().unwrap(), "dark"); | |
| 80 | + | ||
| 81 | + | backend.set_config("theme", "light").unwrap(); | |
| 82 | + | assert_eq!(backend.get_config("theme").unwrap().unwrap(), "light"); | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | #[test] | |
| 86 | + | fn dynamic_collection_crud() { | |
| 87 | + | let backend = setup(); | |
| 88 | + | let filter = SearchFilter::default(); | |
| 89 | + | ||
| 90 | + | let id = backend.create_dynamic_collection("Kicks", &filter).unwrap(); | |
| 91 | + | assert!(id.as_i64() > 0); | |
| 92 | + | ||
| 93 | + | let colls = backend.list_collections().unwrap(); | |
| 94 | + | let dynamic = colls.iter().find(|c| c.name == "Kicks").unwrap(); | |
| 95 | + | assert!(dynamic.is_dynamic()); | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | #[test] | |
| 99 | + | fn search_empty_filter_returns_all() { | |
| 100 | + | let backend = setup(); | |
| 101 | + | let vfs_id = backend.create_vfs("Test").unwrap(); | |
| 102 | + | // Insert fake samples so search has something to find | |
| 103 | + | { | |
| 104 | + | let db = backend.db.lock(); | |
| 105 | + | insert_fake_sample(&db, "h1"); | |
| 106 | + | insert_fake_sample(&db, "h2"); | |
| 107 | + | vfs::create_sample_link(&db, vfs_id, None, "kick.wav", "h1").unwrap(); | |
| 108 | + | vfs::create_sample_link(&db, vfs_id, None, "snare.wav", "h2").unwrap(); | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | let filter = SearchFilter::default(); | |
| 112 | + | let results = backend.search_in_folder(&filter, vfs_id, None).unwrap(); | |
| 113 | + | assert_eq!(results.len(), 2); | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | #[test] | |
| 117 | + | fn poll_events_empty_when_no_workers() { | |
| 118 | + | let backend = setup(); | |
| 119 | + | let events = backend.poll_events(); | |
| 120 | + | assert!(events.is_empty()); | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | #[test] | |
| 124 | + | fn breadcrumb_and_subtree() { | |
| 125 | + | let backend = setup(); | |
| 126 | + | let vfs_id = backend.create_vfs("Test").unwrap(); | |
| 127 | + | let a = backend.create_directory(vfs_id, None, "A").unwrap(); | |
| 128 | + | let b = backend.create_directory(vfs_id, Some(a), "B").unwrap(); | |
| 129 | + | ||
| 130 | + | let crumbs = backend.get_breadcrumb(b).unwrap(); | |
| 131 | + | assert_eq!(crumbs.len(), 2); | |
| 132 | + | assert_eq!(crumbs[0].name, "A"); | |
| 133 | + | assert_eq!(crumbs[1].name, "B"); | |
| 134 | + | ||
| 135 | + | let subtree = backend.collect_subtree(a).unwrap(); | |
| 136 | + | assert_eq!(subtree.len(), 2); | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | #[test] | |
| 140 | + | fn list_all_directories_works() { | |
| 141 | + | let backend = setup(); | |
| 142 | + | let vfs_id = backend.create_vfs("Test").unwrap(); | |
| 143 | + | let drums = backend.create_directory(vfs_id, None, "Drums").unwrap(); | |
| 144 | + | backend.create_directory(vfs_id, Some(drums), "Kicks").unwrap(); | |
| 145 | + | ||
| 146 | + | let dirs = backend.list_all_directories(vfs_id).unwrap(); | |
| 147 | + | assert_eq!(dirs.len(), 2); | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | /// Write a minimal float-PCM WAV for forge integration tests. | |
| 151 | + | fn write_float_wav(path: &Path, channels: u16, sample_rate: u32, samples: &[f32]) { | |
| 152 | + | use std::io::Write; | |
| 153 | + | let bytes_per_sample = 4u16; | |
| 154 | + | let block_align = channels * bytes_per_sample; | |
| 155 | + | let data_size = (samples.len() as u32) * 4; | |
| 156 | + | let file_size = 36 + data_size; | |
| 157 | + | let mut buf = Vec::with_capacity(44 + data_size as usize); | |
| 158 | + | buf.extend_from_slice(b"RIFF"); | |
| 159 | + | buf.extend_from_slice(&file_size.to_le_bytes()); | |
| 160 | + | buf.extend_from_slice(b"WAVE"); | |
| 161 | + | buf.extend_from_slice(b"fmt "); | |
| 162 | + | buf.extend_from_slice(&16u32.to_le_bytes()); | |
| 163 | + | buf.extend_from_slice(&3u16.to_le_bytes()); | |
| 164 | + | buf.extend_from_slice(&channels.to_le_bytes()); | |
| 165 | + | buf.extend_from_slice(&sample_rate.to_le_bytes()); | |
| 166 | + | buf.extend_from_slice(&(sample_rate * block_align as u32).to_le_bytes()); | |
| 167 | + | buf.extend_from_slice(&block_align.to_le_bytes()); | |
| 168 | + | buf.extend_from_slice(&(bytes_per_sample * 8).to_le_bytes()); | |
| 169 | + | buf.extend_from_slice(b"data"); | |
| 170 | + | buf.extend_from_slice(&data_size.to_le_bytes()); | |
| 171 | + | for &s in samples { | |
| 172 | + | buf.extend_from_slice(&s.to_le_bytes()); | |
| 173 | + | } | |
| 174 | + | std::fs::File::create(path).unwrap().write_all(&buf).unwrap(); | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | #[test] | |
| 178 | + | fn chop_sample_creates_slice_folder() { | |
| 179 | + | use audiofiles_core::forge::ChopMethod; | |
| 180 | + | // Keep the temp dir alive for the whole test (store lives under it). | |
| 181 | + | let dir = tempfile::TempDir::new().unwrap(); | |
| 182 | + | let db = Database::open_in_memory().unwrap(); | |
| 183 | + | let store = SampleStore::new(dir.path().join("store")).unwrap(); | |
| 184 | + | let backend = DirectBackend::new(db, store, dir.path().to_path_buf()); | |
| 185 | + | ||
| 186 | + | let vfs_id = backend.create_vfs("Test").unwrap(); | |
| 187 | + | let samples: Vec<f32> = (0..2000).map(|i| ((i % 40) as f32 / 40.0) - 0.5).collect(); | |
| 188 | + | let src = dir.path().join("loop.wav"); | |
| 189 | + | write_float_wav(&src, 1, 44100, &samples); | |
| 190 | + | let hash = backend.import_file(&src).unwrap(); | |
| 191 | + | ||
| 192 | + | // Preview computes slice boundaries on the forge worker (N starts + | |
| 193 | + | // trailing 1.0), delivered via poll_events. | |
| 194 | + | backend | |
| 195 | + | .start_chop_preview(&hash, "wav", &ChopMethod::EqualDivisions(4)) | |
| 196 | + | .unwrap(); | |
| 197 | + | let marks = loop { | |
| 198 | + | let mut found = None; | |
| 199 | + | for ev in backend.poll_events() { | |
| 200 | + | if let BackendEvent::ChopPreviewComplete { marks } = ev { | |
| 201 | + | found = Some(marks); | |
| 202 | + | } | |
| 203 | + | } | |
| 204 | + | if let Some(m) = found { | |
| 205 | + | break m; | |
| 206 | + | } | |
| 207 | + | std::thread::yield_now(); | |
| 208 | + | }; | |
| 209 | + | assert_eq!(marks.len(), 5); | |
| 210 | + | assert_eq!(marks.last().copied(), Some(1.0)); | |
| 211 | + | ||
| 212 | + | let count = backend | |
| 213 | + | .chop_sample(vfs_id, &hash, "wav", "loop.wav", None, &ChopMethod::EqualDivisions(4)) | |
| 214 | + | .unwrap(); | |
| 215 | + | assert_eq!(count, 4); | |
| 216 | + | ||
| 217 | + | // A "loop_slices" directory now holds 4 samples. | |
| 218 | + | let roots = backend.list_children(vfs_id, None).unwrap(); | |
| 219 | + | let slice_dir = roots.iter().find(|n| n.name == "loop_slices").unwrap(); | |
| 220 | + | let slices = backend.list_children(vfs_id, Some(slice_dir.id)).unwrap(); | |
| 221 | + | assert_eq!(slices.len(), 4); | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | #[test] | |
| 225 | + | fn start_chop_runs_on_worker_and_imports_on_poll() { | |
| 226 | + | use audiofiles_core::forge::ChopMethod; | |
| 227 | + | let dir = tempfile::TempDir::new().unwrap(); | |
| 228 | + | // File-based db: the off-thread forge import opens its own connection from | |
| 229 | + | // data_dir/audiofiles.db (the same pattern every worker uses), so the test | |
| 230 | + | // backend must share that file rather than an in-memory db. | |
| 231 | + | let db = Database::open(dir.path().join("audiofiles.db")).unwrap(); | |
| 232 | + | let store = SampleStore::new(dir.path().join("store")).unwrap(); | |
| 233 | + | let backend = DirectBackend::new(db, store, dir.path().to_path_buf()); | |
| 234 | + | ||
| 235 | + | let vfs_id = backend.create_vfs("Test").unwrap(); | |
| 236 | + | let samples: Vec<f32> = (0..2000).map(|i| ((i % 40) as f32 / 40.0) - 0.5).collect(); | |
| 237 | + | let src = dir.path().join("loop.wav"); | |
| 238 | + | write_float_wav(&src, 1, 44100, &samples); | |
| 239 | + | let hash = backend.import_file(&src).unwrap(); | |
| 240 | + | ||
| 241 | + | // Dispatch to the worker; returns immediately (no UI block). | |
| 242 | + | backend | |
| 243 | + | .start_chop(vfs_id, &hash, "wav", "loop.wav", None, &ChopMethod::EqualDivisions(4)) | |
| 244 | + | .unwrap(); | |
| 245 | + | ||
| 246 | + | // Poll until the chop completes (worker does CPU; poll imports). | |
| 247 | + | let mut slice_count = None; | |
| 248 | + | for _ in 0..200 { | |
| 249 | + | for ev in backend.poll_events() { | |
| 250 | + | match ev { | |
| 251 | + | BackendEvent::ForgeChopComplete { slice_count: n } => slice_count = Some(n), | |
| 252 | + | BackendEvent::ForgeError { error } => panic!("forge error: {error}"), | |
| 253 | + | _ => {} | |
| 254 | + | } | |
| 255 | + | } | |
| 256 | + | if slice_count.is_some() { | |
| 257 | + | break; | |
| 258 | + | } | |
| 259 | + | std::thread::sleep(std::time::Duration::from_millis(10)); | |
| 260 | + | } | |
| 261 | + | assert_eq!(slice_count, Some(4), "chop should report 4 slices"); | |
| 262 | + | ||
| 263 | + | // The slices landed in the VFS under "loop_slices". | |
| 264 | + | let roots = backend.list_children(vfs_id, None).unwrap(); | |
| 265 | + | let slice_dir = roots.iter().find(|n| n.name == "loop_slices").unwrap(); | |
| 266 | + | let slices = backend.list_children(vfs_id, Some(slice_dir.id)).unwrap(); | |
| 267 | + | assert_eq!(slices.len(), 4); | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | #[test] | |
| 271 | + | #[cfg(feature = "device-profiles")] | |
| 272 | + | fn device_conform_target_resolves_bundled() { | |
| 273 | + | let backend = setup(); | |
| 274 | + | // A bundled mono device resolves to a mono target. | |
| 275 | + | let target = backend | |
| 276 | + | .device_conform_target("SP-404 MKII", 48000) | |
| 277 | + | .unwrap(); | |
| 278 | + | assert!(target.is_some(), "SP-404 MKII should resolve to a target"); | |
| 279 | + | } | |
| 280 | + | ||
| 281 | + | #[test] | |
| 282 | + | #[cfg(feature = "device-profiles")] | |
| 283 | + | fn list_device_profiles_returns_bundled() { | |
| 284 | + | let backend = setup(); | |
| 285 | + | let profiles = backend.list_device_profiles().unwrap(); | |
| 286 | + | // Bundled manifests should be loaded (14 devices) | |
| 287 | + | assert!( | |
| 288 | + | profiles.len() >= 14, | |
| 289 | + | "expected at least 14 bundled profiles, got {}", | |
| 290 | + | profiles.len() | |
| 291 | + | ); | |
| 292 | + | // Spot-check a known device | |
| 293 | + | assert!( | |
| 294 | + | profiles.iter().any(|p| p.name == "SP-404 MKII"), | |
| 295 | + | "SP-404 MKII should be in the bundled profiles" | |
| 296 | + | ); | |
| 297 | + | } |
| @@ -3,6 +3,7 @@ use std::path::{Path, PathBuf}; | |||
| 3 | 3 | use tracing::{error, warn}; | |
| 4 | 4 | ||
| 5 | 5 | use super::*; | |
| 6 | + | use crate::backend::BackendEvent; | |
| 6 | 7 | ||
| 7 | 8 | /// Count audio files in a directory (recursive). Used for the import dry-run preview. | |
| 8 | 9 | fn count_audio_files(dir: &Path) -> usize { | |
| @@ -54,152 +55,6 @@ pub struct ImportPreflight { | |||
| 54 | 55 | ||
| 55 | 56 | impl BrowserState { | |
| 56 | 57 | ||
| 57 | - | /// Quick-import a single file or directory via drag-and-drop into the current folder. | |
| 58 | - | /// After importing, starts the analysis flow so columns (BPM, Key, etc.) get populated. | |
| 59 | - | pub fn import_path(&mut self, path: &Path) { | |
| 60 | - | if !path.exists() { | |
| 61 | - | self.status = format!("Path not found: {}", path.display()); | |
| 62 | - | return; | |
| 63 | - | } | |
| 64 | - | ||
| 65 | - | let Some(vfs_id) = self.current_vfs_id() else { | |
| 66 | - | self.status = "No VFS available".to_string(); | |
| 67 | - | return; | |
| 68 | - | }; | |
| 69 | - | let parent_id = self.nav.current_dir; | |
| 70 | - | let mut hashes = Vec::new(); | |
| 71 | - | ||
| 72 | - | if path.is_file() { | |
| 73 | - | match self.import_single_file(path, vfs_id, parent_id) { | |
| 74 | - | Ok(Some(hash_ext)) => { | |
| 75 | - | self.status = format!("Imported: {}", path.display()); | |
| 76 | - | hashes.push(hash_ext); | |
| 77 | - | } | |
| 78 | - | Ok(None) => self.status = format!("Imported: {}", path.display()), | |
| 79 | - | Err(e) => self.status = format!("Error: {e}"), | |
| 80 | - | } | |
| 81 | - | } else if path.is_dir() { | |
| 82 | - | let mut count = 0; | |
| 83 | - | let mut errors = 0; | |
| 84 | - | self.import_directory_recursive(path, vfs_id, parent_id, &mut count, &mut errors, &mut hashes); | |
| 85 | - | self.status = format!("Imported {count} files ({errors} errors)"); | |
| 86 | - | } | |
| 87 | - | ||
| 88 | - | self.refresh_contents(); | |
| 89 | - | ||
| 90 | - | if !hashes.is_empty() { | |
| 91 | - | self.start_analysis_flow(hashes); | |
| 92 | - | } | |
| 93 | - | } | |
| 94 | - | ||
| 95 | - | /// Import one audio file: hash into the content-addressed store, then link into the VFS. | |
| 96 | - | /// Returns `Ok(Some((hash, ext)))` on successful import, `Ok(None)` if the file was a | |
| 97 | - | /// duplicate (NameConflict/UNIQUE), or `Err` on failure. | |
| 98 | - | fn import_single_file( | |
| 99 | - | &self, | |
| 100 | - | path: &Path, | |
| 101 | - | vfs_id: VfsId, | |
| 102 | - | parent_id: Option<NodeId>, | |
| 103 | - | ) -> Result<Option<(String, String)>, Box<dyn std::error::Error>> { | |
| 104 | - | let hash = self.backend.import_file(path)?; | |
| 105 | - | let name = path | |
| 106 | - | .file_name() | |
| 107 | - | .and_then(|n| n.to_str()) | |
| 108 | - | .unwrap_or("unknown") | |
| 109 | - | .to_string(); | |
| 110 | - | let ext = path | |
| 111 | - | .extension() | |
| 112 | - | .and_then(|e| e.to_str()) | |
| 113 | - | .unwrap_or("") | |
| 114 | - | .to_string(); | |
| 115 | - | ||
| 116 | - | match self.backend.create_sample_link(vfs_id, parent_id, &name, &hash) { | |
| 117 | - | Ok(_) => Ok(Some((hash, ext))), | |
| 118 | - | Err(crate::backend::BackendError::Core(CoreError::NameConflict(_))) => Ok(None), | |
| 119 | - | Err(crate::backend::BackendError::Core(CoreError::Db(ref sqlite_err))) | |
| 120 | - | if sqlite_err.to_string().contains("UNIQUE") => | |
| 121 | - | { | |
| 122 | - | Ok(None) | |
| 123 | - | } | |
| 124 | - | Err(e) => Err(Box::new(e)), | |
| 125 | - | } | |
| 126 | - | } | |
| 127 | - | ||
| 128 | - | /// Recursively import a directory tree, mirroring its folder structure in the VFS. | |
| 129 | - | /// On NameConflict for a directory, reuses the existing directory node rather than failing. | |
| 130 | - | /// | |
| 131 | - | /// NOTE: A parallel implementation exists in `import.rs` (`import_directory_recursive`) | |
| 132 | - | /// for the background import worker. That version adds progress events and cancellation. | |
| 133 | - | /// Both share the same traversal behavior (sorted, audio-only, skipped-dir filtering). | |
| 134 | - | /// Kept separate because the cancellation/progress channel differences make a shared | |
| 135 | - | /// abstraction more complex than the duplication. | |
| 136 | - | fn import_directory_recursive( | |
| 137 | - | &self, | |
| 138 | - | dir: &Path, | |
| 139 | - | vfs_id: VfsId, | |
| 140 | - | parent_id: Option<NodeId>, | |
| 141 | - | count: &mut usize, | |
| 142 | - | errors: &mut usize, | |
| 143 | - | hashes: &mut Vec<(String, String)>, | |
| 144 | - | ) { | |
| 145 | - | let entries = match fs::read_dir(dir) { | |
| 146 | - | Ok(e) => e, | |
| 147 | - | Err(_) => { | |
| 148 | - | *errors += 1; | |
| 149 | - | return; | |
| 150 | - | } | |
| 151 | - | }; | |
| 152 | - | ||
| 153 | - | let mut paths: Vec<PathBuf> = entries.flatten().map(|e| e.path()).collect(); | |
| 154 | - | paths.sort(); | |
| 155 | - | ||
| 156 | - | for path in paths { | |
| 157 | - | if path.is_dir() { | |
| 158 | - | if audiofiles_core::util::is_macos_metadata_dir(&path) { | |
| 159 | - | continue; | |
| 160 | - | } | |
| 161 | - | let dir_name = audiofiles_core::util::get_filename(&path, "folder"); | |
| 162 | - | ||
| 163 | - | // Create the directory node, or reuse an existing one if a | |
| 164 | - | // name conflict occurs (idempotent for re-imports). | |
| 165 | - | let dir_node_id = | |
| 166 | - | match self.backend.create_directory(vfs_id, parent_id, &dir_name) { | |
| 167 | - | Ok(id) => Some(id), | |
| 168 | - | Err(crate::backend::BackendError::Core(CoreError::NameConflict(_))) => { | |
| 169 | - | self.backend.list_children(vfs_id, parent_id) | |
| 170 | - | .unwrap_or_default() | |
| 171 | - | .iter() | |
| 172 | - | .find(|n| n.name == dir_name && n.node_type == NodeType::Directory) | |
| 173 | - | .map(|n| n.id) | |
| 174 | - | } | |
| 175 | - | Err(_) => { | |
| 176 | - | *errors += 1; | |
| 177 | - | continue; | |
| 178 | - | } | |
| 179 | - | }; | |
| 180 | - | ||
| 181 | - | self.import_directory_recursive( | |
| 182 | - | &path, | |
| 183 | - | vfs_id, | |
| 184 | - | dir_node_id.or(parent_id), | |
| 185 | - | count, | |
| 186 | - | errors, | |
| 187 | - | hashes, | |
| 188 | - | ); | |
| 189 | - | } else if path.is_file() && audiofiles_core::util::is_audio_file(&path) { | |
| 190 | - | match self.import_single_file(&path, vfs_id, parent_id) { | |
| 191 | - | Ok(Some(hash_ext)) => { | |
| 192 | - | *count += 1; | |
| 193 | - | hashes.push(hash_ext); | |
| 194 | - | } | |
| 195 | - | Ok(None) => *count += 1, | |
| 196 | - | Err(_) => *errors += 1, | |
| 197 | - | } | |
| 198 | - | } | |
| 199 | - | } | |
| 200 | - | } | |
| 201 | - | ||
| 202 | - | ||
| 203 | 58 | // --- Folder import --- | |
| 204 | 59 | ||
| 205 | 60 | /// Quick import: choose folder → import as new vault → analyze with defaults. | |
| @@ -401,312 +256,404 @@ impl BrowserState { | |||
| 401 | 256 | return decode_activity; | |
| 402 | 257 | } | |
| 403 | 258 | ||
| 404 | - | use crate::backend::BackendEvent; | |
| 405 | - | ||
| 259 | + | // Route each backend event to its category handler. A handler returns | |
| 260 | + | // `true` to end this poll batch early — used by the terminal completion | |
| 261 | + | // events (import/analysis/export/cleanup), which refresh library state | |
| 262 | + | // and want the next frame to re-poll cleanly. | |
| 406 | 263 | for event in events { | |
| 407 | - | match event { | |
| 408 | - | // --- Import events --- | |
| 409 | - | BackendEvent::ImportWalkProgress { count, total_bytes } => { | |
| 410 | - | if let ImportMode::Importing { | |
| 411 | - | walking: true, | |
| 412 | - | walking_count, | |
| 413 | - | total_bytes: bytes_slot, | |
| 414 | - | .. | |
| 415 | - | } = &mut self.import_wf.import_mode | |
| 416 | - | { | |
| 417 | - | *walking_count = count; | |
| 418 | - | *bytes_slot = total_bytes; | |
| 419 | - | } | |
| 420 | - | } | |
| 421 | - | BackendEvent::ImportWalkComplete { total, total_bytes } => { | |
| 422 | - | let loose_files = matches!( | |
| 423 | - | &self.import_wf.import_mode, | |
| 424 | - | ImportMode::Importing { loose_files: true, .. } | |
| 425 | - | ); | |
| 426 | - | self.import_wf.import_mode = ImportMode::Importing { | |
| 427 | - | total, | |
| 428 | - | completed: 0, | |
| 429 | - | current_name: String::new(), | |
| 430 | - | walking: false, | |
| 431 | - | walking_count: 0, | |
| 432 | - | total_bytes, | |
| 433 | - | loose_files, | |
| 434 | - | }; | |
| 264 | + | let stop = match event { | |
| 265 | + | BackendEvent::ImportWalkProgress { .. } | |
| 266 | + | | BackendEvent::ImportWalkComplete { .. } | |
| 267 | + | | BackendEvent::ImportProgress { .. } | |
| 268 | + | | BackendEvent::ImportFileError { .. } | |
| 269 | + | | BackendEvent::ImportComplete { .. } => self.handle_import_event(event), | |
| 270 | + | BackendEvent::AnalysisProgress { .. } | |
| 271 | + | | BackendEvent::AnalysisSampleDone { .. } | |
| 272 | + | | BackendEvent::AnalysisSampleError { .. } | |
| 273 | + | | BackendEvent::AnalysisBatchComplete => self.handle_analysis_event(event), | |
| 274 | + | BackendEvent::ExportProgress { .. } | |
| 275 | + | | BackendEvent::ExportComplete { .. } => self.handle_export_event(event), | |
| 276 | + | BackendEvent::CleanupProgress { .. } | |
| 277 | + | | BackendEvent::CleanupComplete { .. } => self.handle_cleanup_event(event), | |
| 278 | + | BackendEvent::LooseFilesIntegrity { .. } | |
| 279 | + | | BackendEvent::LooseFilesRelocated { .. } | |
| 280 | + | | BackendEvent::LooseFilesPurged { .. } | |
| 281 | + | | BackendEvent::StoreIntegrity { .. } | |
| 282 | + | | BackendEvent::LooseFilesFailed { .. } => self.handle_loose_files_event(event), | |
| 283 | + | BackendEvent::EditStarted { .. } | |
| 284 | + | | BackendEvent::EditComplete { .. } | |
| 285 | + | | BackendEvent::EditError { .. } => self.handle_edit_event(event), | |
| 286 | + | BackendEvent::ForgeStarted | |
| 287 | + | | BackendEvent::ForgeChopComplete { .. } | |
| 288 | + | | BackendEvent::ForgeConformComplete { .. } | |
| 289 | + | | BackendEvent::ChopPreviewComplete { .. } | |
| 290 | + | | BackendEvent::ForgeError { .. } => self.handle_forge_event(event), | |
| 291 | + | BackendEvent::SimilarResults { .. } | |
| 292 | + | | BackendEvent::NearDuplicateResults { .. } | |
| 293 | + | | BackendEvent::SearchError { .. } => self.handle_search_event(event), | |
| 294 | + | BackendEvent::ClassifierJobDone(_) | |
| 295 | + | | BackendEvent::ClassifierJobFailed(_) => self.handle_classifier_event(event), | |
| 296 | + | }; | |
| 297 | + | if stop { | |
| 298 | + | return true; | |
| 299 | + | } | |
| 300 | + | } | |
| 301 | + | ||
| 302 | + | true | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | /// Import worker events: walk/file progress, errors, and terminal ImportComplete. | |
| 306 | + | fn handle_import_event(&mut self, event: BackendEvent) -> bool { | |
| 307 | + | match event { | |
| 308 | + | BackendEvent::ImportWalkProgress { count, total_bytes } => { | |
| 309 | + | if let ImportMode::Importing { | |
| 310 | + | walking: true, | |
| 311 | + | walking_count, | |
| 312 | + | total_bytes: bytes_slot, | |
| 313 | + | .. | |
| 314 | + | } = &mut self.import_wf.import_mode | |
| 315 | + | { | |
| 316 | + | *walking_count = count; | |
| 317 | + | *bytes_slot = total_bytes; | |
| 435 | 318 | } | |
| 436 | - | BackendEvent::ImportProgress { | |
| 437 | - | completed, | |
| 319 | + | } | |
| 320 | + | BackendEvent::ImportWalkComplete { total, total_bytes } => { | |
| 321 | + | let loose_files = matches!( | |
| 322 | + | &self.import_wf.import_mode, | |
| 323 | + | ImportMode::Importing { loose_files: true, .. } | |
| 324 | + | ); | |
| 325 | + | self.import_wf.import_mode = ImportMode::Importing { | |
| 326 | + | total, | |
| 327 | + | completed: 0, | |
| 328 | + | current_name: String::new(), | |
| 329 | + | walking: false, | |
| 330 | + | walking_count: 0, | |
| 331 | + | total_bytes, | |
| 332 | + | loose_files, | |
| 333 | + | }; | |
| 334 | + | } | |
| 335 | + | BackendEvent::ImportProgress { | |
| 336 | + | completed, | |
| 337 | + | total, | |
| 338 | + | current_name, | |
| 339 | + | } => { | |
| 340 | + | let (prev_bytes, loose_files) = match &self.import_wf.import_mode { | |
| 341 | + | ImportMode::Importing { total_bytes, loose_files, .. } => (*total_bytes, *loose_files), | |
| 342 | + | _ => (0, false), | |
| 343 | + | }; | |
| 344 | + | self.import_wf.import_mode = ImportMode::Importing { | |
| 438 | 345 | total, | |
| 346 | + | completed, | |
| 439 | 347 | current_name, | |
| 440 | - | } => { | |
| 441 | - | let (prev_bytes, loose_files) = match &self.import_wf.import_mode { | |
| 442 | - | ImportMode::Importing { total_bytes, loose_files, .. } => (*total_bytes, *loose_files), | |
| 443 | - | _ => (0, false), | |
| 444 | - | }; | |
| 445 | - | self.import_wf.import_mode = ImportMode::Importing { | |
| 446 | - | total, | |
| 447 | - | completed, | |
| 448 | - | current_name, | |
| 449 | - | walking: false, | |
| 450 | - | walking_count: 0, | |
| 451 | - | total_bytes: prev_bytes, | |
| 452 | - | loose_files, | |
| 348 | + | walking: false, | |
| 349 | + | walking_count: 0, | |
| 350 | + | total_bytes: prev_bytes, | |
| 351 | + | loose_files, | |
| 352 | + | }; | |
| 353 | + | } | |
| 354 | + | BackendEvent::ImportFileError { path, error } => { | |
| 355 | + | self.import_wf.import_file_errors.push(super::ImportFileError { path, error }); | |
| 356 | + | } | |
| 357 | + | BackendEvent::ImportComplete { | |
| 358 | + | imported, | |
| 359 | + | total_files: _, | |
| 360 | + | errors, | |
| 361 | + | duplicates, | |
| 362 | + | folders, | |
| 363 | + | } => { | |
| 364 | + | self.nav.vfs_list = Arc::new(self.backend.list_vfs().unwrap_or_else(|e| { | |
| 365 | + | error!("Failed to refresh VFS list: {e}"); | |
| 366 | + | Vec::new() | |
| 367 | + | })); | |
| 368 | + | self.refresh_contents(); | |
| 369 | + | self.refresh_all_tags(); | |
| 370 | + | // First successful import auto-dismisses the welcome hint — | |
| 371 | + | // the user has moved past onboarding. | |
| 372 | + | if !imported.is_empty() && self.onboarding.show_first_launch_hint { | |
| 373 | + | self.dismiss_first_launch_hint(); | |
| 374 | + | } | |
| 375 | + | ||
| 376 | + | let mut parts = vec![format!("Imported {} files", imported.len())]; | |
| 377 | + | if duplicates > 0 { | |
| 378 | + | parts.push(format!("{duplicates} duplicates skipped")); | |
| 379 | + | } | |
| 380 | + | if errors > 0 { | |
| 381 | + | parts.push(format!("{errors} errors")); | |
| 382 | + | } | |
| 383 | + | self.status = parts.join(", "); | |
| 384 | + | ||
| 385 | + | if !folders.is_empty() && !self.import_wf.quick_import { | |
| 386 | + | let entries = folders | |
| 387 | + | .into_iter() | |
| 388 | + | .map(|f| FolderTagEntry { | |
| 389 | + | folder: ImportedFolder { | |
| 390 | + | name: f.name, | |
| 391 | + | samples: f.samples, | |
| 392 | + | }, | |
| 393 | + | tag_input: String::new(), | |
| 394 | + | }) | |
| 395 | + | .collect(); | |
| 396 | + | self.import_wf.import_mode = ImportMode::TagFolders { | |
| 397 | + | entries, | |
| 398 | + | sample_hashes: imported, | |
| 453 | 399 | }; | |
| 454 | - | } | |
| 455 | - | BackendEvent::ImportFileError { path, error } => { | |
| 456 | - | self.import_wf.import_file_errors.push(super::ImportFileError { path, error }); | |
| 457 | - | } | |
| 458 | - | BackendEvent::ImportComplete { | |
| 459 | - | imported, | |
| 460 | - | total_files: _, | |
| 461 | - | errors, | |
| 462 | - | duplicates, | |
| 463 | - | folders, | |
| 464 | - | } => { | |
| 465 | - | self.nav.vfs_list = Arc::new(self.backend.list_vfs().unwrap_or_else(|e| { | |
| 466 | - | error!("Failed to refresh VFS list: {e}"); | |
| 467 | - | Vec::new() | |
| 468 | - | })); | |
| 469 | - | self.refresh_contents(); | |
| 470 | - | self.refresh_all_tags(); | |
| 471 | - | // First successful import auto-dismisses the welcome hint — | |
| 472 | - | // the user has moved past onboarding. | |
| 473 | - | if !imported.is_empty() && self.onboarding.show_first_launch_hint { | |
| 474 | - | self.dismiss_first_launch_hint(); | |
| 475 | - | } | |
| 476 | - | ||
| 477 | - | let mut parts = vec![format!("Imported {} files", imported.len())]; | |
| 478 | - | if duplicates > 0 { | |
| 479 | - | parts.push(format!("{duplicates} duplicates skipped")); | |
| 480 | - | } | |
| 481 | - | if errors > 0 { | |
| 482 | - | parts.push(format!("{errors} errors")); | |
| 483 | - | } | |
| 484 | - | self.status = parts.join(", "); | |
| 485 | - | ||
| 486 | - | if !folders.is_empty() && !self.import_wf.quick_import { | |
| 487 | - | let entries = folders | |
| 488 | - | .into_iter() | |
| 489 | - | .map(|f| FolderTagEntry { | |
| 490 | - | folder: ImportedFolder { | |
| 491 | - | name: f.name, | |
| 492 | - | samples: f.samples, | |
| 493 | - | }, | |
| 494 | - | tag_input: String::new(), | |
| 495 | - | }) | |
| 496 | - | .collect(); | |
| 497 | - | self.import_wf.import_mode = ImportMode::TagFolders { | |
| 498 | - | entries, | |
| 499 | - | sample_hashes: imported, | |
| 500 | - | }; | |
| 501 | - | } else if !imported.is_empty() { | |
| 502 | - | if self.import_wf.quick_import { | |
| 503 | - | // Skip ConfigureAnalysis — run with defaults immediately | |
| 504 | - | self.run_analysis(imported, AnalysisConfig::default()); | |
| 505 | - | } else { | |
| 506 | - | self.start_analysis_flow(imported); | |
| 507 | - | } | |
| 508 | - | } else if self.has_import_errors() { | |
| 509 | - | self.import_wf.import_mode = ImportMode::ReviewErrors; | |
| 400 | + | } else if !imported.is_empty() { | |
| 401 | + | if self.import_wf.quick_import { | |
| 402 | + | // Skip ConfigureAnalysis — run with defaults immediately | |
| 403 | + | self.run_analysis(imported, AnalysisConfig::default()); | |
| 510 | 404 | } else { | |
| 511 | - | self.import_wf.quick_import = false; | |
| 512 | - | self.import_wf.import_mode = ImportMode::None; | |
| 513 | - | } | |
| 514 | - | return true; | |
| 515 | - | } | |
| 516 | - | ||
| 517 | - | // --- Analysis events --- | |
| 518 | - | BackendEvent::AnalysisProgress { | |
| 519 | - | completed, | |
| 520 | - | total, | |
| 521 | - | current_name, | |
| 522 | - | } => self.on_analysis_progress(completed, total, current_name), | |
| 523 | - | BackendEvent::AnalysisSampleDone { | |
| 524 | - | result, | |
| 525 | - | suggestions, | |
| 526 | - | } => self.on_analysis_sample_done(*result, suggestions), | |
| 527 | - | BackendEvent::AnalysisSampleError { hash, error } => { | |
| 528 | - | self.on_analysis_sample_error(hash, error) | |
| 529 | - | } | |
| 530 | - | BackendEvent::AnalysisBatchComplete => { | |
| 531 | - | if self.on_analysis_batch_complete() { | |
| 532 | - | return true; | |
| 405 | + | self.start_analysis_flow(imported); | |
| 533 | 406 | } | |
| 407 | + | } else if self.has_import_errors() { | |
| 408 | + | self.import_wf.import_mode = ImportMode::ReviewErrors; | |
| 409 | + | } else { | |
| 410 | + | self.import_wf.quick_import = false; | |
| 411 | + | self.import_wf.import_mode = ImportMode::None; | |
| 534 | 412 | } | |
| 413 | + | return true; | |
| 414 | + | } | |
| 415 | + | _ => unreachable!("handle_import_event: unrelated event"), | |
| 416 | + | } | |
| 417 | + | false | |
| 418 | + | } | |
| 535 | 419 | ||
| 536 | - | // --- Export events --- | |
| 537 | - | BackendEvent::ExportProgress { | |
| 538 | - | completed, | |
| 539 | - | total, | |
| 540 | - | current_name, | |
| 541 | - | } => self.on_export_progress(completed, total, current_name), | |
| 542 | - | BackendEvent::ExportComplete { total, errors } => { | |
| 543 | - | self.on_export_complete(total, errors); | |
| 420 | + | /// Analysis worker events: per-sample progress/results and batch completion. | |
| 421 | + | fn handle_analysis_event(&mut self, event: BackendEvent) -> bool { | |
| 422 | + | match event { | |
| 423 | + | BackendEvent::AnalysisProgress { | |
| 424 | + | completed, | |
| 425 | + | total, | |
| 426 | + | current_name, | |
| 427 | + | } => self.on_analysis_progress(completed, total, current_name), | |
| 428 | + | BackendEvent::AnalysisSampleDone { | |
| 429 | + | result, | |
| 430 | + | suggestions, | |
| 431 | + | } => self.on_analysis_sample_done(*result, suggestions), | |
| 432 | + | BackendEvent::AnalysisSampleError { hash, error } => { | |
| 433 | + | self.on_analysis_sample_error(hash, error) | |
| 434 | + | } | |
| 435 | + | BackendEvent::AnalysisBatchComplete => { | |
| 436 | + | if self.on_analysis_batch_complete() { | |
| 544 | 437 | return true; | |
| 545 | 438 | } | |
| 439 | + | } | |
| 440 | + | _ => unreachable!("handle_analysis_event: unrelated event"), | |
| 441 | + | } | |
| 442 | + | false | |
| 443 | + | } | |
| 546 | 444 | ||
| 547 | - | // --- Cleanup events --- | |
| 548 | - | BackendEvent::CleanupProgress { | |
| 549 | - | completed, | |
| 550 | - | total, | |
| 551 | - | current_name, | |
| 552 | - | } => self.on_cleanup_progress(completed, total, current_name), | |
| 553 | - | BackendEvent::CleanupComplete { removed, errors } => { | |
| 554 | - | self.on_cleanup_complete(removed, errors); | |
| 555 | - | return true; | |
| 556 | - | } | |
| 445 | + | /// Export worker events: progress and completion. | |
| 446 | + | fn handle_export_event(&mut self, event: BackendEvent) -> bool { | |
| 447 | + | match event { | |
| 448 | + | BackendEvent::ExportProgress { | |
| 449 | + | completed, | |
| 450 | + | total, | |
| 451 | + | current_name, | |
| 452 | + | } => self.on_export_progress(completed, total, current_name), | |
| 453 | + | BackendEvent::ExportComplete { total, errors } => { | |
| 454 | + | self.on_export_complete(total, errors); | |
| 455 | + | return true; |
Lines truncated
| @@ -3,7 +3,6 @@ | |||
| 3 | 3 | //! [`SharedState`] bridges the cpal audio output thread and the GUI thread via lock-free access. | |
| 4 | 4 | //! [`BrowserState`] holds the full GUI-side model: VFS navigation, preview, and analysis workflow. | |
| 5 | 5 | ||
| 6 | - | use std::fs; | |
| 7 | 6 | use std::path::{Path, PathBuf}; | |
| 8 | 7 | use std::sync::Arc; | |
| 9 | 8 | use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64}; | |
| @@ -15,7 +14,6 @@ use audiofiles_core::analysis::config::AnalysisConfig; | |||
| 15 | 14 | use audiofiles_core::analysis::waveform::WaveformData; | |
| 16 | 15 | use audiofiles_core::analysis::AnalysisResult; | |
| 17 | 16 | use audiofiles_core::db::Database; | |
| 18 | - | use audiofiles_core::error::CoreError; | |
| 19 | 17 | use audiofiles_core::search::SearchFilter; | |
| 20 | 18 | use audiofiles_core::store::SampleStore; | |
| 21 | 19 | use audiofiles_core::util::split_name_ext; |
| @@ -1129,14 +1129,6 @@ mod import_and_analysis { | |||
| 1129 | 1129 | // Should cancel and do nothing further | |
| 1130 | 1130 | assert!(matches!(state.import_wf.import_mode, ImportMode::None)); | |
| 1131 | 1131 | } | |
| 1132 | - | ||
| 1133 | - | #[test] | |
| 1134 | - | fn import_path_nonexistent_sets_status() { | |
| 1135 | - | let (mut state, _dir) = make_state(); | |
| 1136 | - | let fake_path = PathBuf::from("/nonexistent/path/to/audio.wav"); | |
| 1137 | - | state.import_path(&fake_path); | |
| 1138 | - | assert!(state.status.contains("Path not found")); | |
| 1139 | - | } | |
| 1140 | 1132 | } | |
| 1141 | 1133 | ||
| 1142 | 1134 | // ---- Navigation & filter ---- |