Skip to main content

max / audiofiles

Decompose direct.rs and poll_workers god-modules Split backend/direct.rs (2202 lines, one struct impl'ing 15 traits) into a backend/direct/ module: operations, forge, classify, store, library, analysis, and tests, with the struct and inherent impl in mod.rs. Impl blocks move verbatim; cross-module refs rewritten to crate::backend::. Split the poll_workers event-dispatch megafunction into a thin router plus per-category handle_*_event methods, preserving the batch short-circuit. Remove the now-dead synchronous drag-drop import path (import_path, import_single_file, import_directory_recursive): OS drag-drop already routes through the async import/file workers in audiofiles-app, and the worker preserves folder structure. Drops its orphan test and two unused imports. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-17 17:20 UTC
Commit: e032cd4f2f388a3f585e6fd96f24db543d15c349
Parent: 0368ec1
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 ----