Skip to main content

max / audiofiles

Background cleanup worker for orphaned sample removal Channel-based worker thread mirrors the export worker pattern. Backend trait gains cleanup start/cancel/poll methods. DirectBackend integrates the worker with progress and completion events. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-04-13 21:58 UTC
Commit: e32c593ca681e95d2ce943da55ea75c5d2bd52dd
Parent: 0068a67
4 files changed, +362 insertions, -0 deletions
@@ -29,6 +29,7 @@ use super::{
29 29 ImportStrategyDesc, ImportedFolderDesc,
30 30 };
31 31
32 + use crate::cleanup::{CleanupCommand, CleanupHandle};
32 33 use crate::export::{ExportCommand, ExportHandle};
33 34 use crate::import::{ImportCommand, ImportEvent, ImportHandle, ImportStrategy};
34 35
@@ -43,6 +44,7 @@ pub struct DirectBackend {
43 44 import_worker: Mutex<Option<ImportHandle>>,
44 45 analysis_worker: Mutex<Option<WorkerHandle>>,
45 46 export_worker: Mutex<Option<ExportHandle>>,
47 + cleanup_worker: Mutex<Option<CleanupHandle>>,
46 48 edit_worker: Mutex<Option<EditWorkerHandle>>,
47 49 // VP-tree indexes for fast search (lazy, invalidated on new analysis)
48 50 fingerprint_index: Mutex<Option<fingerprint::FingerprintIndex>>,
@@ -62,6 +64,7 @@ impl DirectBackend {
62 64 import_worker: Mutex::new(None),
63 65 analysis_worker: Mutex::new(None),
64 66 export_worker: Mutex::new(None),
67 + cleanup_worker: Mutex::new(None),
65 68 edit_worker: Mutex::new(None),
66 69 fingerprint_index: Mutex::new(None),
67 70 similarity_index: Mutex::new(None),
@@ -568,6 +571,11 @@ impl Backend for DirectBackend {
568 571 Ok(self.store.remove(hash, &db)?)
569 572 }
570 573
574 + fn remove_orphaned_samples(&self) -> BackendResult<usize> {
575 + let db = self.db.lock();
576 + Ok(self.store.remove_orphaned_samples(&db)?)
577 + }
578 +
571 579 // --- Export ---
572 580
573 581 fn collect_export_items(
@@ -769,6 +777,24 @@ impl Backend for DirectBackend {
769 777 Ok(())
770 778 }
771 779
780 + fn start_cleanup(&self) -> BackendResult<()> {
781 + let db_path = self.data_dir.join("audiofiles.db");
782 + let store_root = self.store.root().to_path_buf();
783 +
784 + let handle = crate::cleanup::spawn_cleanup_worker(db_path, store_root)
785 + .map_err(|e| BackendError::Other(format!("failed to spawn cleanup worker: {e}")))?;
786 + handle.send(CleanupCommand::RemoveOrphans);
787 + *self.cleanup_worker.lock() = Some(handle);
788 + Ok(())
789 + }
790 +
791 + fn cancel_cleanup(&self) -> BackendResult<()> {
792 + if let Some(worker) = self.cleanup_worker.lock().take() {
793 + worker.send(CleanupCommand::Cancel);
794 + }
795 + Ok(())
796 + }
797 +
772 798 fn record_edit_history(
773 799 &self,
774 800 source_hash: &str,
@@ -903,6 +929,28 @@ impl Backend for DirectBackend {
903 929 }
904 930 }
905 931
932 + // Poll cleanup worker
933 + if let Some(ref worker) = *self.cleanup_worker.lock() {
934 + while let Some(event) = worker.try_recv() {
935 + match event {
936 + crate::cleanup::CleanupEvent::Progress {
937 + completed,
938 + total,
939 + current_name,
940 + } => {
941 + events.push(BackendEvent::CleanupProgress {
942 + completed,
943 + total,
944 + current_name,
945 + });
946 + }
947 + crate::cleanup::CleanupEvent::Complete { removed, errors } => {
948 + events.push(BackendEvent::CleanupComplete { removed, errors });
949 + }
950 + }
951 + }
952 + }
953 +
906 954 // Poll edit worker
907 955 if let Some(ref worker) = *self.edit_worker.lock() {
908 956 while let Some(event) = worker.try_recv() {
@@ -93,6 +93,17 @@ pub enum BackendEvent {
93 93 errors: Vec<(String, String)>,
94 94 },
95 95
96 + // Cleanup events
97 + CleanupProgress {
98 + completed: usize,
99 + total: usize,
100 + current_name: String,
101 + },
102 + CleanupComplete {
103 + removed: usize,
104 + errors: usize,
105 + },
106 +
96 107 // Edit events
97 108 EditStarted {
98 109 hash: String,
@@ -340,6 +351,9 @@ pub trait Backend: Send + Sync {
340 351 /// Remove a sample from the store and database (CASCADE handles VFS nodes, tags, analysis).
341 352 fn remove_sample(&self, hash: &str) -> BackendResult<()>;
342 353
354 + /// Remove samples no longer referenced by any VFS node. Returns count removed.
355 + fn remove_orphaned_samples(&self) -> BackendResult<usize>;
356 +
343 357 // --- Export ---
344 358
345 359 /// Collect export items from a VFS subtree.
@@ -415,6 +429,12 @@ pub trait Backend: Send + Sync {
415 429 /// Cancel a running edit.
416 430 fn cancel_edit(&self) -> BackendResult<()>;
417 431
432 + /// Start background orphaned sample cleanup.
433 + fn start_cleanup(&self) -> BackendResult<()>;
434 +
435 + /// Cancel a running cleanup.
436 + fn cancel_cleanup(&self) -> BackendResult<()>;
437 +
418 438 /// Record an edit in the edit_history table.
419 439 fn record_edit_history(
420 440 &self,
@@ -0,0 +1,293 @@
1 + //! Background cleanup worker: removes orphaned samples off the GUI thread.
2 + //!
3 + //! Mirrors the pattern in `export.rs` — dedicated thread with its own Database +
4 + //! SampleStore, communicating via channels. The GUI thread polls events each frame.
5 +
6 + use std::path::PathBuf;
7 + use std::sync::{mpsc, Mutex};
8 + use std::thread;
9 +
10 + use tracing::{error, instrument};
11 +
12 + use audiofiles_core::db::Database;
13 + use audiofiles_core::store::SampleStore;
14 +
15 + /// Command sent from the GUI thread to the cleanup worker.
16 + pub enum CleanupCommand {
17 + /// Start removing orphaned samples.
18 + RemoveOrphans,
19 + /// Cancel the current cleanup.
20 + Cancel,
21 + /// Shut down the worker thread.
22 + Shutdown,
23 + }
24 +
25 + /// Event sent from the cleanup worker back to the GUI thread.
26 + pub enum CleanupEvent {
27 + /// Progress update for one sample removed.
28 + Progress {
29 + completed: usize,
30 + total: usize,
31 + current_name: String,
32 + },
33 + /// The cleanup is complete.
34 + Complete {
35 + removed: usize,
36 + errors: usize,
37 + },
38 + }
39 +
40 + /// Handle for communicating with the background cleanup worker.
41 + ///
42 + /// The receiver is wrapped in a `Mutex` so `BrowserState` remains `Sync` (required by nih-plug).
43 + /// Only the GUI thread actually calls `try_recv`, so contention is zero.
44 + pub struct CleanupHandle {
45 + cmd_tx: mpsc::Sender<CleanupCommand>,
46 + event_rx: Mutex<mpsc::Receiver<CleanupEvent>>,
47 + _thread: Option<thread::JoinHandle<()>>,
48 + }
49 +
50 + impl CleanupHandle {
51 + /// Poll for the next event without blocking.
52 + pub fn try_recv(&self) -> Option<CleanupEvent> {
53 + self.event_rx.lock().ok()?.try_recv().ok()
54 + }
55 +
56 + /// Send a command to the worker.
57 + pub fn send(&self, cmd: CleanupCommand) {
58 + let _ = self.cmd_tx.send(cmd);
59 + }
60 + }
61 +
62 + impl Drop for CleanupHandle {
63 + fn drop(&mut self) {
64 + let _ = self.cmd_tx.send(CleanupCommand::Shutdown);
65 + if let Some(handle) = self._thread.take() {
66 + let _ = handle.join();
67 + }
68 + }
69 + }
70 +
71 + /// Spawn the background cleanup worker thread.
72 + ///
73 + /// The worker opens its own `Database` and `SampleStore` to avoid Mutex contention
74 + /// with the GUI thread.
75 + #[instrument(skip_all)]
76 + pub fn spawn_cleanup_worker(
77 + db_path: PathBuf,
78 + store_root: PathBuf,
79 + ) -> std::io::Result<CleanupHandle> {
80 + let (cmd_tx, cmd_rx) = mpsc::channel::<CleanupCommand>();
81 + let (event_tx, event_rx) = mpsc::channel::<CleanupEvent>();
82 +
83 + let thread = thread::Builder::new()
84 + .name("cleanup-worker".to_string())
85 + .spawn(move || {
86 + worker_loop(cmd_rx, event_tx, &db_path, &store_root);
87 + })?;
88 +
89 + Ok(CleanupHandle {
90 + cmd_tx,
91 + event_rx: Mutex::new(event_rx),
92 + _thread: Some(thread),
93 + })
94 + }
95 +
96 + #[instrument(skip_all)]
97 + fn worker_loop(
98 + cmd_rx: mpsc::Receiver<CleanupCommand>,
99 + event_tx: mpsc::Sender<CleanupEvent>,
100 + db_path: &std::path::Path,
101 + store_root: &std::path::Path,
102 + ) {
103 + let db = match Database::open(db_path) {
104 + Ok(d) => d,
105 + Err(e) => {
106 + let _ = event_tx.send(CleanupEvent::Complete {
107 + removed: 0,
108 + errors: 1,
109 + });
110 + error!("Cleanup worker failed to open database: {e}");
111 + return;
112 + }
113 + };
114 +
115 + let store = match SampleStore::new(store_root) {
116 + Ok(s) => s,
117 + Err(e) => {
118 + let _ = event_tx.send(CleanupEvent::Complete {
119 + removed: 0,
120 + errors: 1,
121 + });
122 + error!("Cleanup worker failed to open store: {e}");
123 + return;
124 + }
125 + };
126 +
127 + while let Ok(cmd) = cmd_rx.recv() {
128 + match cmd {
129 + CleanupCommand::Shutdown => break,
130 + CleanupCommand::Cancel => continue,
131 + CleanupCommand::RemoveOrphans => {
132 + remove_orphans(&cmd_rx, &event_tx, &db, &store);
133 + }
134 + }
135 + }
136 + }
137 +
138 + fn remove_orphans(
139 + cmd_rx: &mpsc::Receiver<CleanupCommand>,
140 + event_tx: &mpsc::Sender<CleanupEvent>,
141 + db: &Database,
142 + store: &SampleStore,
143 + ) {
144 + // Query all orphaned samples in one pass
145 + let orphans = match db.conn().prepare(
146 + "SELECT s.hash, s.file_extension, s.original_name
147 + FROM samples s
148 + LEFT JOIN vfs_nodes vn ON s.hash = vn.sample_hash
149 + WHERE vn.id IS NULL",
150 + ) {
151 + Ok(mut stmt) => {
152 + let rows = stmt
153 + .query_map([], |row| {
154 + Ok((
155 + row.get::<_, String>(0)?,
156 + row.get::<_, String>(1)?,
157 + row.get::<_, String>(2)?,
158 + ))
159 + })
160 + .ok()
161 + .map(|iter| iter.flatten().collect::<Vec<_>>())
162 + .unwrap_or_default();
163 + rows
164 + }
165 + Err(e) => {
166 + error!("Cleanup worker failed to query orphans: {e}");
167 + let _ = event_tx.send(CleanupEvent::Complete {
168 + removed: 0,
169 + errors: 1,
170 + });
171 + return;
172 + }
173 + };
174 +
175 + let total = orphans.len();
176 + if total == 0 {
177 + let _ = event_tx.send(CleanupEvent::Complete {
178 + removed: 0,
179 + errors: 0,
180 + });
181 + return;
182 + }
183 +
184 + let mut removed = 0usize;
185 + let mut errors = 0usize;
186 +
187 + for (i, (hash, ext, name)) in orphans.iter().enumerate() {
188 + // Check for cancel between deletions
189 + if let Ok(CleanupCommand::Cancel) | Ok(CleanupCommand::Shutdown) = cmd_rx.try_recv() {
190 + let _ = event_tx.send(CleanupEvent::Complete { removed, errors });
191 + return;
192 + }
193 +
194 + let _ = event_tx.send(CleanupEvent::Progress {
195 + completed: i,
196 + total,
197 + current_name: name.clone(),
198 + });
199 +
200 + // Delete from DB
201 + match db
202 + .conn()
203 + .execute("DELETE FROM samples WHERE hash = ?1", [hash])
204 + {
205 + Ok(_) => {
206 + // Delete from disk
207 + if let Ok(path) = store.sample_path(hash, ext) {
208 + if path.exists() {
209 + let _ = std::fs::remove_file(&path);
210 + }
211 + }
212 + removed += 1;
213 + }
214 + Err(e) => {
215 + error!("Cleanup: failed to delete sample {hash}: {e}");
216 + errors += 1;
217 + }
218 + }
219 + }
220 +
221 + // WAL checkpoint for clean state
222 + let _ = db
223 + .conn()
224 + .execute_batch("PRAGMA wal_checkpoint(TRUNCATE)");
225 +
226 + let _ = event_tx.send(CleanupEvent::Complete { removed, errors });
227 + }
228 +
229 + #[cfg(test)]
230 + mod tests {
231 + use super::*;
232 +
233 + #[test]
234 + fn cleanup_command_variants_constructible() {
235 + let _remove = CleanupCommand::RemoveOrphans;
236 + let _cancel = CleanupCommand::Cancel;
237 + let _shutdown = CleanupCommand::Shutdown;
238 + }
239 +
240 + #[test]
241 + fn cleanup_event_variants_constructible() {
242 + let _progress = CleanupEvent::Progress {
243 + completed: 5,
244 + total: 10,
245 + current_name: "kick.wav".to_string(),
246 + };
247 + let _complete = CleanupEvent::Complete {
248 + removed: 10,
249 + errors: 0,
250 + };
251 + }
252 +
253 + #[test]
254 + fn spawn_and_drop_does_not_hang() {
255 + let dir = tempfile::TempDir::new().unwrap();
256 + let db_path = dir.path().join("audiofiles.db");
257 + let store_root = dir.path().join("store");
258 + std::fs::create_dir_all(&store_root).unwrap();
259 +
260 + // Create the database so the worker can open it
261 + let _db = Database::open(&db_path).unwrap();
262 +
263 + let handle = spawn_cleanup_worker(db_path, store_root).unwrap();
264 + assert!(handle.try_recv().is_none());
265 + drop(handle); // Should send Shutdown and join cleanly
266 + }
267 +
268 + #[test]
269 + fn cleanup_no_orphans_completes_immediately() {
270 + let dir = tempfile::TempDir::new().unwrap();
271 + let db_path = dir.path().join("audiofiles.db");
272 + let store_root = dir.path().join("store");
273 + std::fs::create_dir_all(&store_root).unwrap();
274 +
275 + let _db = Database::open(&db_path).unwrap();
276 +
277 + let handle = spawn_cleanup_worker(db_path, store_root).unwrap();
278 + handle.send(CleanupCommand::RemoveOrphans);
279 +
280 + // Give the worker a moment to process
281 + std::thread::sleep(std::time::Duration::from_millis(100));
282 +
283 + let mut got_complete = false;
284 + while let Some(event) = handle.try_recv() {
285 + if let CleanupEvent::Complete { removed, errors } = event {
286 + assert_eq!(removed, 0);
287 + assert_eq!(errors, 0);
288 + got_complete = true;
289 + }
290 + }
291 + assert!(got_complete);
292 + }
293 + }
@@ -1,6 +1,7 @@
1 1 //! Browser UI for audiofiles — file browsing, import, preview, and waveform display.
2 2
3 3 pub mod backend;
4 + pub mod cleanup;
4 5 pub mod editor;
5 6 pub mod error;
6 7 pub mod export;