Skip to main content

max / audiofiles

Decompose Backend god-trait into 13 capability traits backend/mod.rs held one 125-method Backend trait and backend/direct.rs one 125-method impl Backend for DirectBackend — the backend god-object. Split Backend into 13 capability supertraits (VfsBackend, TagBackend, SearchBackend, CollectionBackend, AnalysisBackend, RulesBackend, ClassifierBackend, SimilarityBackend, StoreBackend, ExportBackend, ForgeBackend, ConfigBackend, OperationsBackend) with `Backend: <all 13> + Send + Sync {}` as an empty roll-up. DirectBackend's impl splits into 13 matching impl blocks + `impl Backend for DirectBackend {}`. Every method keeps its exact signature and body, so all `Box<dyn Backend>` call sites are unchanged (supertrait methods are callable through the trait object without upcasting). Extract the non-delegating helpers so the impl is delegation-only: backend/forge.rs takes the off-thread forge import plumbing (ForgePending, ForgeImportOutcome, forge_import_chop/conform) as pub(super); backend/sample_info.rs takes build_sample_info and probe_bit_depth. direct.rs 2356 -> 2202. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-13 14:06 UTC
Commit: 9127e2face7e21cd026c115f002e064fa288225d
Parent: 2fd0d04
4 files changed, +328 insertions, -200 deletions
@@ -27,10 +27,13 @@ use super::search_worker::{self, SearchCommand, SearchEvent};
27 27 use parking_lot::Mutex;
28 28
29 29 use super::{
30 - Backend, BackendError, BackendEvent, BackendResult, ExportConfigDesc, ExportItemDesc,
30 + Backend, VfsBackend, TagBackend, SearchBackend, CollectionBackend, AnalysisBackend, RulesBackend, ClassifierBackend, SimilarityBackend, StoreBackend, ExportBackend, ForgeBackend, ConfigBackend, OperationsBackend,
31 + BackendError, BackendEvent, BackendResult, ExportConfigDesc, ExportItemDesc,
31 32 ImportStrategyDesc, ImportedFolderDesc, TrainedHeadInfo,
32 33 };
33 34
35 + use super::forge::{ForgePending, ForgeImportOutcome, forge_import_chop, forge_import_conform};
36 +
34 37 use crate::cleanup::{CleanupCommand, CleanupHandle};
35 38 use crate::export::{ExportCommand, ExportHandle};
36 39 use crate::import::{ImportCommand, ImportEvent, ImportHandle, ImportStrategy};
@@ -38,60 +41,6 @@ use crate::import::{ImportCommand, ImportEvent, ImportHandle, ImportStrategy};
38 41 use audiofiles_core::analysis::worker::{WorkerCommand, WorkerEvent, WorkerHandle};
39 42 use audiofiles_core::forge::{ForgeCommand, ForgeEvent};
40 43
41 - /// Import context for the in-flight forge operation. The worker does the CPU
42 - /// work (decode/slice/conform/encode → temp files); this carries the VFS
43 - /// destination so the GUI thread can import the staged files when the worker
44 - /// signals completion.
45 - enum ForgePending {
46 - Chop {
47 - vfs_id: VfsId,
48 - parent_id: Option<NodeId>,
49 - },
50 - Conform {
51 - vfs_id: VfsId,
52 - parent_id: Option<NodeId>,
53 - sample_rate: u32,
54 - bit_depth: u16,
55 - },
56 - }
57 -
58 - /// GUI-ready result of an off-thread forge import job. The import (blob copy + DB
59 - /// writes) runs on its own WAL connection so it never holds the GUI's `db.lock()`;
60 - /// `poll_workers` drains this and emits the matching `BackendEvent` on a later frame.
61 - enum ForgeImportOutcome {
62 - Chop { slice_count: usize },
63 - Conform {
64 - sample_rate: u32,
65 - bit_depth: u16,
66 - overshoot: Option<super::ForgeOvershoot>,
67 - },
68 - Error { error: String },
69 - }
70 -
71 - /// Open a fresh WAL connection + store and import chop slices. Runs on a job
72 - /// thread (the established per-worker connection pattern) so the GUI's db.lock()
73 - /// is never held during the blob copies + row writes.
74 - fn forge_import_chop(
75 - data_dir: &Path,
76 - root: &Path,
77 - vfs_id: VfsId,
78 - parent_id: Option<NodeId>,
79 - staging: audiofiles_core::forge::ChopStaging,
80 - ) -> ForgeImportOutcome {
81 - let db = match Database::open(data_dir.join("audiofiles.db")) {
82 - Ok(d) => d,
83 - Err(e) => return ForgeImportOutcome::Error { error: e.to_string() },
84 - };
85 - let store = match SampleStore::new(root) {
86 - Ok(s) => s,
87 - Err(e) => return ForgeImportOutcome::Error { error: e.to_string() },
88 - };
89 - match audiofiles_core::forge::import_chop(&store, &db, vfs_id, parent_id, staging) {
90 - Ok(r) => ForgeImportOutcome::Chop { slice_count: r.slice_count },
91 - Err(e) => ForgeImportOutcome::Error { error: e.to_string() },
92 - }
93 - }
94 -
95 44 /// Convert a GUI-facing [`ImportStrategyDesc`] into the worker's [`ImportStrategy`].
96 45 /// Shared by the directory and explicit-file import entry points.
97 46 fn resolve_import_strategy(strategy: ImportStrategyDesc) -> ImportStrategy {
@@ -106,43 +55,6 @@ fn resolve_import_strategy(strategy: ImportStrategyDesc) -> ImportStrategy {
106 55 }
107 56 }
108 57
109 - /// Off-thread counterpart for conform import (see [`forge_import_chop`]). Maps the
110 - /// core overshoot result into the GUI-facing `ForgeOvershoot` here so the outcome
111 - /// is ready to emit.
112 - #[allow(clippy::too_many_arguments)]
113 - fn forge_import_conform(
114 - data_dir: &Path,
115 - root: &Path,
116 - vfs_id: VfsId,
117 - parent_id: Option<NodeId>,
118 - sample_rate: u32,
119 - bit_depth: u16,
120 - staging: audiofiles_core::forge::ConformStaging,
121 - ) -> ForgeImportOutcome {
122 - let db = match Database::open(data_dir.join("audiofiles.db")) {
123 - Ok(d) => d,
124 - Err(e) => return ForgeImportOutcome::Error { error: e.to_string() },
125 - };
126 - let store = match SampleStore::new(root) {
127 - Ok(s) => s,
128 - Err(e) => return ForgeImportOutcome::Error { error: e.to_string() },
129 - };
130 - match audiofiles_core::forge::import_conform(&store, &db, vfs_id, parent_id, staging) {
131 - Ok(r) => {
132 - let overshoot = r.overshoot.map(|o| match o.action {
133 - audiofiles_core::forge::OvershootAction::Trimmed { gain_db } => {
134 - super::ForgeOvershoot::Trimmed { peak_dbfs: o.peak_dbfs, gain_db }
135 - }
136 - audiofiles_core::forge::OvershootAction::Flagged => {
137 - super::ForgeOvershoot::Flagged { peak_dbfs: o.peak_dbfs }
138 - }
139 - });
140 - ForgeImportOutcome::Conform { sample_rate, bit_depth, overshoot }
141 - }
142 - Err(e) => ForgeImportOutcome::Error { error: e.to_string() },
143 - }
144 - }
145 -
146 58 /// Direct backend: talks to SQLite and the sample store in-process.
147 59 pub struct DirectBackend {
148 60 db: Mutex<Database>,
@@ -340,7 +252,7 @@ impl DirectBackend {
340 252 // to avoid holding the DB lock during potentially slow Rhai execution.
341 253 let mut infos: Vec<_> = {
342 254 let db = self.db.lock();
343 - items.iter().map(|item| build_sample_info(&db, &self.store, item)).collect()
255 + items.iter().map(|item| super::sample_info::build_sample_info(&db, &self.store, item)).collect()
344 256 };
345 257 // Probe bit depth off the DB lock and in parallel: N independent
346 258 // file-header reads that previously ran serially *while holding the
@@ -353,7 +265,7 @@ impl DirectBackend {
353 265 .collect();
354 266 let depths: Vec<u16> = paths
355 267 .into_par_iter()
356 - .map(|p| p.and_then(|p| probe_bit_depth(&p)).unwrap_or(0))
268 + .map(|p| p.and_then(|p| super::sample_info::probe_bit_depth(&p)).unwrap_or(0))
357 269 .collect();
358 270 for (info, depth) in infos.iter_mut().zip(depths) {
359 271 info.bit_depth = depth;
@@ -441,111 +353,7 @@ impl DirectBackend {
441 353 #[cfg(feature = "device-profiles")]
442 354 use audiofiles_core::util::split_name_ext;
443 355
444 - /// Build a RhaiSampleInfo from an ExportItem and database lookups.
445 - #[cfg(feature = "device-profiles")]
446 - fn build_sample_info(
447 - db: &audiofiles_core::db::Database,
448 - store: &audiofiles_core::store::SampleStore,
449 - item: &ExportItem,
450 - ) -> audiofiles_rhai::types::RhaiSampleInfo {
451 - // Query audio_analysis for sample_rate and channels. Read the integers as
452 - // i64 so a valid-but-out-of-range stored value isn't reported as a query
453 - // error and lumped in with the legitimate "no analysis row yet" case; range
454 - // problems are surfaced explicitly below instead. Only QueryReturnedNoRows
455 - // is a silent (0,0) fallback — a genuine read error or a corrupt value
456 - // (negative / oversized rate or channels) is logged so a device profile
457 - // can't silently drop the sample on mystery zeros.
458 - let (sample_rate, channels, duration) = db
459 - .conn()
460 - .query_row(
461 - "SELECT sample_rate, channels, duration FROM audio_analysis WHERE hash = ?1",
462 - [&item.hash],
463 - |row| {
464 - Ok((
465 - row.get::<_, i64>(0)?,
466 - row.get::<_, i64>(1)?,
467 - row.get::<_, f64>(2)?,
468 - ))
469 - },
470 - )
471 - .map(|(sr, ch, dur)| {
472 - let sample_rate = u32::try_from(sr).unwrap_or_else(|_| {
473 - tracing::warn!("Corrupt sample_rate {sr} for {}", &item.hash[..8]);
474 - 0
475 - });
476 - let channels = u16::try_from(ch).unwrap_or_else(|_| {
477 - tracing::warn!("Corrupt channels {ch} for {}", &item.hash[..8]);
478 - 0
479 - });
480 - (sample_rate, channels, dur)
481 - })
482 - .unwrap_or_else(|e| {
483 - if !matches!(e, rusqlite::Error::QueryReturnedNoRows) {
484 - tracing::warn!("Failed to query audio_analysis for {}: {e}", &item.hash[..8]);
485 - }
486 - (0, 0, item.duration.unwrap_or(0.0))
487 - });
488 -
489 - // Query samples for file_size
490 - let file_size = db
491 - .conn()
492 - .query_row(
493 - "SELECT file_size FROM samples WHERE hash = ?1 AND deleted_at IS NULL",
494 - [&item.hash],
495 - |row| row.get::<_, u64>(0),
496 - )
497 - .unwrap_or_else(|_| {
498 - // Fallback: read from store
499 - store
500 - .sample_path(&item.hash, &item.ext)
501 - .ok()
502 - .and_then(|p| std::fs::metadata(p).ok())
503 - .map(|m| m.len())
504 - .unwrap_or(0)
505 - });
506 -
507 - // bit_depth is filled by the caller in a parallel pass off the DB lock — see
508 - // resolve_device_profile. Probing it here would open a file per item while
509 - // holding the DB lock on the GUI thread.
510 - audiofiles_rhai::types::RhaiSampleInfo {
511 - hash: item.hash.to_string(),
512 - name: item.name.clone(),
513 - extension: item.ext.clone(),
514 - sample_rate,
515 - bit_depth: 0,
516 - channels,
517 - duration,
518 - file_size,
519 - }
520 - }
521 -
522 - /// Probe bit depth from a WAV/AIFF file header without full decode.
523 - #[cfg(feature = "device-profiles")]
524 - fn probe_bit_depth(path: &std::path::Path) -> Option<u16> {
525 - // Try hound first (WAV)
526 - if let Ok(reader) = hound::WavReader::open(path) {
527 - return Some(reader.spec().bits_per_sample);
528 - }
529 - // Try symphonia for AIFF/other formats
530 - let file = std::fs::File::open(path).ok()?;
531 - let mss = symphonia::core::io::MediaSourceStream::new(Box::new(file), Default::default());
532 - let mut hint = symphonia::core::probe::Hint::new();
533 - if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
534 - hint.with_extension(ext);
535 - }
536 - let probed = symphonia::default::get_probe()
537 - .format(
538 - &hint,
539 - mss,
540 - &symphonia::core::formats::FormatOptions::default(),
541 - &symphonia::core::meta::MetadataOptions::default(),
542 - )
543 - .ok()?;
544 - let track = probed.format.default_track()?;
545 - track.codec_params.bits_per_sample.map(|b| b as u16)
546 - }
547 -
548 - impl Backend for DirectBackend {
356 + impl VfsBackend for DirectBackend {
549 357 // --- VFS ---
550 358
551 359 fn list_vfs(&self) -> BackendResult<Vec<Vfs>> {
@@ -664,6 +472,9 @@ impl Backend for DirectBackend {
664 472 let db = self.db.lock();
665 473 Ok(vfs::find_nodes_by_hashes(&db, vfs_id, hashes)?)
666 474 }
475 + }
476 +
477 + impl TagBackend for DirectBackend {
667 478
668 479 // --- Tags ---
669 480
@@ -715,6 +526,9 @@ impl Backend for DirectBackend {
715 526 let db = self.db.lock();
716 527 Ok(tags::remove_tag_globally(&db, tag)?)
717 528 }
529 + }
530 +
531 + impl SearchBackend for DirectBackend {
718 532
719 533 // --- Search ---
720 534
@@ -732,6 +546,9 @@ impl Backend for DirectBackend {
732 546 let db = self.db.lock();
733 547 Ok(search::search_global(&db, filter)?)
734 548 }
549 + }
550 +
551 + impl CollectionBackend for DirectBackend {
735 552
736 553 // --- Collections ---
737 554
@@ -781,6 +598,9 @@ impl Backend for DirectBackend {
781 598 let db = self.db.lock();
782 599 Ok(collections::get_sample_collections(&db, sample_hash)?)
783 600 }
601 + }
602 +
603 + impl AnalysisBackend for DirectBackend {
784 604
785 605 // --- Analysis ---
786 606
@@ -820,6 +640,9 @@ impl Backend for DirectBackend {
820 640 let db = self.db.lock();
821 641 Ok(audiofiles_core::analysis::samples_needing_features(&db)?)
822 642 }
643 + }
644 +
645 + impl RulesBackend for DirectBackend {
823 646
824 647 fn list_rules(&self) -> BackendResult<Vec<audiofiles_core::rules::Rule>> {
825 648 let db = self.db.lock();
@@ -866,6 +689,9 @@ impl Backend for DirectBackend {
866 689 let db = self.db.lock();
867 690 Ok(audiofiles_core::rules::sample_tag_provenance(&db, hash)?)
868 691 }
692 + }
693 +
694 + impl ClassifierBackend for DirectBackend {
869 695
870 696 fn ml_auto_apply_all(&self, k: usize) -> BackendResult<usize> {
871 697 use audiofiles_core::analysis::{exemplar, trained_head};
@@ -1031,6 +857,9 @@ impl Backend for DirectBackend {
1031 857 let db = self.db.lock();
1032 858 Ok(audiofiles_core::analysis::waveform::load_waveform(&db, hash))
1033 859 }
860 + }
861 +
862 + impl SimilarityBackend for DirectBackend {
1034 863
1035 864 // --- Similarity ---
1036 865
@@ -1068,6 +897,9 @@ impl Backend for DirectBackend {
1068 897 }
1069 898 Ok(())
1070 899 }
900 + }
901 +
902 + impl StoreBackend for DirectBackend {
1071 903
1072 904 // --- Store ---
1073 905
@@ -1175,6 +1007,9 @@ impl Backend for DirectBackend {
1175 1007 fn start_store_verify(&self) -> BackendResult<()> {
1176 1008 self.loose_files_send(crate::loose_files_worker::LooseFilesCommand::VerifyStore)
1177 1009 }
1010 + }
1011 +
1012 + impl ExportBackend for DirectBackend {
1178 1013
1179 1014 // --- Export ---
1180 1015
@@ -1224,6 +1059,9 @@ impl Backend for DirectBackend {
1224 1059 Ok(None)
1225 1060 }
1226 1061 }
1062 + }
1063 +
1064 + impl ForgeBackend for DirectBackend {
1227 1065
1228 1066 // --- Sample Forge ---
1229 1067
@@ -1422,6 +1260,9 @@ impl Backend for DirectBackend {
1422 1260 });
1423 1261 Ok(())
1424 1262 }
1263 + }
1264 +
1265 + impl ConfigBackend for DirectBackend {
1425 1266
1426 1267 // --- Config ---
1427 1268
@@ -1468,6 +1309,9 @@ impl Backend for DirectBackend {
1468 1309 let db = self.db.lock();
1469 1310 Ok(vfs::get_vfs_sync_files(&db, id)?)
1470 1311 }
1312 + }
1313 +
1314 + impl OperationsBackend for DirectBackend {
1471 1315
1472 1316 // --- VFS Mirror ---
1473 1317
@@ -2054,6 +1898,8 @@ impl Backend for DirectBackend {
2054 1898 }
2055 1899 }
2056 1900
1901 + impl Backend for DirectBackend {}
1902 +
2057 1903 #[cfg(test)]
2058 1904 mod tests {
2059 1905 use super::*;
@@ -0,0 +1,100 @@
1 + //! Off-thread Sample Forge import plumbing: the in-flight forge context, the
2 + //! GUI-ready outcome, and the WAL-connection import jobs. Driven by the
3 + //! `ForgeBackend` impl in direct.rs; split out to keep that impl delegation-only.
4 +
5 + use std::path::Path;
6 +
7 + use audiofiles_core::db::Database;
8 + use audiofiles_core::store::SampleStore;
9 + use audiofiles_core::{NodeId, VfsId};
10 +
11 + /// Import context for the in-flight forge operation. The worker does the CPU
12 + /// work (decode/slice/conform/encode → temp files); this carries the VFS
13 + /// destination so the GUI thread can import the staged files when the worker
14 + /// signals completion.
15 + pub(super) enum ForgePending {
16 + Chop {
17 + vfs_id: VfsId,
18 + parent_id: Option<NodeId>,
19 + },
20 + Conform {
21 + vfs_id: VfsId,
22 + parent_id: Option<NodeId>,
23 + sample_rate: u32,
24 + bit_depth: u16,
25 + },
26 + }
27 +
28 + /// GUI-ready result of an off-thread forge import job. The import (blob copy + DB
29 + /// writes) runs on its own WAL connection so it never holds the GUI's `db.lock()`;
30 + /// `poll_workers` drains this and emits the matching `BackendEvent` on a later frame.
31 + pub(super) enum ForgeImportOutcome {
32 + Chop { slice_count: usize },
33 + Conform {
34 + sample_rate: u32,
35 + bit_depth: u16,
36 + overshoot: Option<super::ForgeOvershoot>,
37 + },
38 + Error { error: String },
39 + }
40 +
41 + /// Open a fresh WAL connection + store and import chop slices. Runs on a job
42 + /// thread (the established per-worker connection pattern) so the GUI's db.lock()
43 + /// is never held during the blob copies + row writes.
44 + pub(super) fn forge_import_chop(
45 + data_dir: &Path,
46 + root: &Path,
47 + vfs_id: VfsId,
48 + parent_id: Option<NodeId>,
49 + staging: audiofiles_core::forge::ChopStaging,
50 + ) -> ForgeImportOutcome {
51 + let db = match Database::open(data_dir.join("audiofiles.db")) {
52 + Ok(d) => d,
53 + Err(e) => return ForgeImportOutcome::Error { error: e.to_string() },
54 + };
55 + let store = match SampleStore::new(root) {
56 + Ok(s) => s,
57 + Err(e) => return ForgeImportOutcome::Error { error: e.to_string() },
58 + };
59 + match audiofiles_core::forge::import_chop(&store, &db, vfs_id, parent_id, staging) {
60 + Ok(r) => ForgeImportOutcome::Chop { slice_count: r.slice_count },
61 + Err(e) => ForgeImportOutcome::Error { error: e.to_string() },
62 + }
63 + }
64 +
65 + /// Off-thread counterpart for conform import (see [`forge_import_chop`]). Maps the
66 + /// core overshoot result into the GUI-facing `ForgeOvershoot` here so the outcome
67 + /// is ready to emit.
68 + #[allow(clippy::too_many_arguments)]
69 + pub(super) fn forge_import_conform(
70 + data_dir: &Path,
71 + root: &Path,
72 + vfs_id: VfsId,
73 + parent_id: Option<NodeId>,
74 + sample_rate: u32,
75 + bit_depth: u16,
76 + staging: audiofiles_core::forge::ConformStaging,
77 + ) -> ForgeImportOutcome {
78 + let db = match Database::open(data_dir.join("audiofiles.db")) {
79 + Ok(d) => d,
80 + Err(e) => return ForgeImportOutcome::Error { error: e.to_string() },
81 + };
82 + let store = match SampleStore::new(root) {
83 + Ok(s) => s,
84 + Err(e) => return ForgeImportOutcome::Error { error: e.to_string() },
85 + };
86 + match audiofiles_core::forge::import_conform(&store, &db, vfs_id, parent_id, staging) {
87 + Ok(r) => {
88 + let overshoot = r.overshoot.map(|o| match o.action {
89 + audiofiles_core::forge::OvershootAction::Trimmed { gain_db } => {
90 + super::ForgeOvershoot::Trimmed { peak_dbfs: o.peak_dbfs, gain_db }
91 + }
92 + audiofiles_core::forge::OvershootAction::Flagged => {
93 + super::ForgeOvershoot::Flagged { peak_dbfs: o.peak_dbfs }
94 + }
95 + });
96 + ForgeImportOutcome::Conform { sample_rate, bit_depth, overshoot }
97 + }
98 + Err(e) => ForgeImportOutcome::Error { error: e.to_string() },
99 + }
100 + }
@@ -9,6 +9,8 @@
9 9
10 10 pub mod direct;
11 11 pub mod search_worker;
12 + mod forge;
13 + mod sample_info;
12 14
13 15 use std::path::{Path, PathBuf};
14 16
@@ -278,7 +280,8 @@ pub enum ClassifierJobResult {
278 280 ///
279 281 /// Every method is synchronous and blocking. The trait is `Send + Sync` so it
280 282 /// can live inside `BrowserState` (which must be Send + Sync for nih-plug).
281 - pub trait Backend: Send + Sync {
283 + /// Virtual-filesystem navigation: vaults, directories, and sample links.
284 + pub trait VfsBackend {
282 285 // --- VFS ---
283 286
284 287 /// List all VFS roots, ordered alphabetically.
@@ -354,6 +357,10 @@ pub trait Backend: Send + Sync {
354 357 vfs_id: VfsId,
355 358 hashes: &[&str],
356 359 ) -> BackendResult<Vec<VfsNodeWithAnalysis>>;
360 + }
361 +
362 + /// Sample tagging: per-sample and bulk tag mutation and lookup.
363 + pub trait TagBackend {
357 364
358 365 // --- Tags ---
359 366
@@ -383,6 +390,10 @@ pub trait Backend: Send + Sync {
383 390
384 391 /// Remove a tag from every sample that carries it. Returns count of samples affected.
385 392 fn remove_tag_globally(&self, tag: &str) -> BackendResult<usize>;
393 + }
394 +
395 + /// Text/filter search over the library.
396 + pub trait SearchBackend {
386 397
387 398 // --- Search ---
388 399
@@ -396,6 +407,10 @@ pub trait Backend: Send + Sync {
396 407
397 408 /// Search globally across all VFS roots.
398 409 fn search_global(&self, filter: &SearchFilter) -> BackendResult<Vec<VfsNodeWithAnalysis>>;
410 + }
411 +
412 + /// Collections and smart (dynamic) folders.
413 + pub trait CollectionBackend {
399 414
400 415 // --- Smart folders ---
401 416
@@ -427,6 +442,10 @@ pub trait Backend: Send + Sync {
427 442
428 443 /// Get all collections containing a given sample.
429 444 fn get_sample_collections(&self, sample_hash: &str) -> BackendResult<Vec<Collection>>;
445 + }
446 +
447 + /// Feature analysis: read/persist results and rule application over them.
448 + pub trait AnalysisBackend {
430 449
431 450 // --- Analysis ---
432 451
@@ -450,6 +469,10 @@ pub trait Backend: Send + Sync {
450 469 /// Hashes (+ extension) of samples lacking a current-version feature vector
451 470 /// (the feature-backfill work-list).
452 471 fn samples_needing_features(&self) -> BackendResult<Vec<(String, String)>>;
472 + }
473 +
474 + /// Tag rules (classifier Layer A).
475 + pub trait RulesBackend {
453 476
454 477 // --- Tag rules (classifier Layer A) ---
455 478
@@ -483,6 +506,10 @@ pub trait Backend: Send + Sync {
483 506 &self,
484 507 hash: &str,
485 508 ) -> BackendResult<Vec<(String, String, Option<String>)>>;
509 + }
510 +
511 + /// Auto-tagging (Layer B): k-NN suggestions, thresholds, the trained head, .afcl sharing, clustering, and harvesting.
512 + pub trait ClassifierBackend {
486 513
487 514 // --- Auto-tagging: k-NN suggestions, thresholds, bootstrap (Layer B) ---
488 515
@@ -582,6 +609,10 @@ pub trait Backend: Send + Sync {
582 609
583 610 /// Get waveform display data for a sample.
584 611 fn get_waveform(&self, hash: &str) -> BackendResult<Option<WaveformData>>;
612 + }
613 +
614 + /// Similarity and near-duplicate search.
615 + pub trait SimilarityBackend {
585 616
586 617 // --- Similarity ---
587 618
@@ -593,6 +624,10 @@ pub trait Backend: Send + Sync {
593 624 /// Start a near-duplicate search for `hash` (fingerprint comparison). Results
594 625 /// arrive via [`BackendEvent::NearDuplicateResults`] (or `SearchError`).
595 626 fn start_find_near_duplicates(&self, hash: &str, limit: usize) -> BackendResult<()>;
627 + }
628 +
629 + /// Content-addressed store: import, sample paths, trash, and loose-file maintenance.
630 + pub trait StoreBackend {
596 631
597 632 // --- Store ---
598 633
@@ -680,6 +715,10 @@ pub trait Backend: Send + Sync {
680 715 /// `BackendEvent::StoreIntegrity`. Off the GUI thread because it re-hashes
681 716 /// the whole managed library.
682 717 fn start_store_verify(&self) -> BackendResult<()>;
718 + }
719 +
720 + /// Export item collection and device profiles.
721 + pub trait ExportBackend {
683 722
684 723 // --- Export ---
685 724
@@ -706,6 +745,10 @@ pub trait Backend: Send + Sync {
706 745 profile_name: &str,
707 746 source_rate: u32,
708 747 ) -> BackendResult<Option<ConformTarget>>;
748 + }
749 +
750 + /// Sample Forge: chop and conform.
751 + pub trait ForgeBackend {
709 752
710 753 // --- Sample Forge ---
711 754
@@ -767,6 +810,10 @@ pub trait Backend: Send + Sync {
767 810 parent_id: Option<NodeId>,
768 811 target: &ConformTarget,
769 812 ) -> BackendResult<()>;
813 + }
814 +
815 + /// App config key/value and per-VFS sync toggles.
816 + pub trait ConfigBackend {
770 817
771 818 // --- Config ---
772 819
@@ -784,6 +831,10 @@ pub trait Backend: Send + Sync {
784 831
785 832 /// Get whether a VFS has audio file blob syncing enabled.
786 833 fn get_vfs_sync_files(&self, id: VfsId) -> BackendResult<bool>;
834 + }
835 +
836 + /// VFS mirror sync plus the long-running background workers (import/analysis/export/edit/cleanup), edit history, storage stats, and event polling.
837 + pub trait OperationsBackend {
787 838
788 839 // --- VFS Mirror ---
789 840
@@ -873,6 +924,28 @@ pub trait Backend: Send + Sync {
873 924 fn poll_events(&self) -> Vec<BackendEvent>;
874 925 }
875 926
927 + /// The full backend surface. A set of capability supertraits so a
928 + /// `dyn Backend` still exposes every method while each capability can be
929 + /// reasoned about (and implemented) on its own.
930 + pub trait Backend:
931 + VfsBackend
932 + + TagBackend
933 + + SearchBackend
934 + + CollectionBackend
935 + + AnalysisBackend
936 + + RulesBackend
937 + + ClassifierBackend
938 + + SimilarityBackend
939 + + StoreBackend
940 + + ExportBackend
941 + + ForgeBackend
942 + + ConfigBackend
943 + + OperationsBackend
944 + + Send
945 + + Sync
946 + {
947 + }
948 +
876 949 #[cfg(test)]
877 950 mod tests {
878 951 use super::*;
@@ -0,0 +1,109 @@
1 + //! Export sample-info helpers: assemble a `RhaiSampleInfo` for device-profile
2 + //! conform and probe bit depth from a file header. Split out of direct.rs.
3 +
4 + #[cfg(feature = "device-profiles")]
5 + use audiofiles_core::export::ExportItem;
6 +
7 + /// Build a RhaiSampleInfo from an ExportItem and database lookups.
8 + #[cfg(feature = "device-profiles")]
9 + pub(super) fn build_sample_info(
10 + db: &audiofiles_core::db::Database,
11 + store: &audiofiles_core::store::SampleStore,
12 + item: &ExportItem,
13 + ) -> audiofiles_rhai::types::RhaiSampleInfo {
14 + // Query audio_analysis for sample_rate and channels. Read the integers as
15 + // i64 so a valid-but-out-of-range stored value isn't reported as a query
16 + // error and lumped in with the legitimate "no analysis row yet" case; range
17 + // problems are surfaced explicitly below instead. Only QueryReturnedNoRows
18 + // is a silent (0,0) fallback — a genuine read error or a corrupt value
19 + // (negative / oversized rate or channels) is logged so a device profile
20 + // can't silently drop the sample on mystery zeros.
21 + let (sample_rate, channels, duration) = db
22 + .conn()
23 + .query_row(
24 + "SELECT sample_rate, channels, duration FROM audio_analysis WHERE hash = ?1",
25 + [&item.hash],
26 + |row| {
27 + Ok((
28 + row.get::<_, i64>(0)?,
29 + row.get::<_, i64>(1)?,
30 + row.get::<_, f64>(2)?,
31 + ))
32 + },
33 + )
34 + .map(|(sr, ch, dur)| {
35 + let sample_rate = u32::try_from(sr).unwrap_or_else(|_| {
36 + tracing::warn!("Corrupt sample_rate {sr} for {}", &item.hash[..8]);
37 + 0
38 + });
39 + let channels = u16::try_from(ch).unwrap_or_else(|_| {
40 + tracing::warn!("Corrupt channels {ch} for {}", &item.hash[..8]);
41 + 0
42 + });
43 + (sample_rate, channels, dur)
44 + })
45 + .unwrap_or_else(|e| {
46 + if !matches!(e, rusqlite::Error::QueryReturnedNoRows) {
47 + tracing::warn!("Failed to query audio_analysis for {}: {e}", &item.hash[..8]);
48 + }
49 + (0, 0, item.duration.unwrap_or(0.0))
50 + });
51 +
52 + // Query samples for file_size
53 + let file_size = db
54 + .conn()
55 + .query_row(
56 + "SELECT file_size FROM samples WHERE hash = ?1 AND deleted_at IS NULL",
57 + [&item.hash],
58 + |row| row.get::<_, u64>(0),
59 + )
60 + .unwrap_or_else(|_| {
61 + // Fallback: read from store
62 + store
63 + .sample_path(&item.hash, &item.ext)
64 + .ok()
65 + .and_then(|p| std::fs::metadata(p).ok())
66 + .map(|m| m.len())
67 + .unwrap_or(0)
68 + });
69 +
70 + // bit_depth is filled by the caller in a parallel pass off the DB lock — see
71 + // resolve_device_profile. Probing it here would open a file per item while
72 + // holding the DB lock on the GUI thread.
73 + audiofiles_rhai::types::RhaiSampleInfo {
74 + hash: item.hash.to_string(),
75 + name: item.name.clone(),
76 + extension: item.ext.clone(),
77 + sample_rate,
78 + bit_depth: 0,
79 + channels,
80 + duration,
81 + file_size,
82 + }
83 + }
84 +
85 + /// Probe bit depth from a WAV/AIFF file header without full decode.
86 + #[cfg(feature = "device-profiles")]
87 + pub(super) fn probe_bit_depth(path: &std::path::Path) -> Option<u16> {
88 + // Try hound first (WAV)
89 + if let Ok(reader) = hound::WavReader::open(path) {
90 + return Some(reader.spec().bits_per_sample);
91 + }
92 + // Try symphonia for AIFF/other formats
93 + let file = std::fs::File::open(path).ok()?;
94 + let mss = symphonia::core::io::MediaSourceStream::new(Box::new(file), Default::default());
95 + let mut hint = symphonia::core::probe::Hint::new();
96 + if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
97 + hint.with_extension(ext);
98 + }
99 + let probed = symphonia::default::get_probe()
100 + .format(
101 + &hint,
102 + mss,
103 + &symphonia::core::formats::FormatOptions::default(),
104 + &symphonia::core::meta::MetadataOptions::default(),
105 + )
106 + .ok()?;
107 + let track = probed.format.default_track()?;
108 + track.codec_params.bits_per_sample.map(|b| b as u16)
109 + }