max / audiofiles
18 files changed,
+507 insertions,
-32 deletions
| @@ -418,7 +418,7 @@ dependencies = [ | |||
| 418 | 418 | ||
| 419 | 419 | [[package]] | |
| 420 | 420 | name = "audiofiles-app" | |
| 421 | - | version = "0.2.0" | |
| 421 | + | version = "0.2.1" | |
| 422 | 422 | dependencies = [ | |
| 423 | 423 | "audiofiles-browser", | |
| 424 | 424 | "audiofiles-core", | |
| @@ -436,7 +436,7 @@ dependencies = [ | |||
| 436 | 436 | ||
| 437 | 437 | [[package]] | |
| 438 | 438 | name = "audiofiles-browser" | |
| 439 | - | version = "0.2.0" | |
| 439 | + | version = "0.2.1" | |
| 440 | 440 | dependencies = [ | |
| 441 | 441 | "audiofiles-core", | |
| 442 | 442 | "audiofiles-rhai", | |
| @@ -458,7 +458,7 @@ dependencies = [ | |||
| 458 | 458 | ||
| 459 | 459 | [[package]] | |
| 460 | 460 | name = "audiofiles-core" | |
| 461 | - | version = "0.2.0" | |
| 461 | + | version = "0.2.1" | |
| 462 | 462 | dependencies = [ | |
| 463 | 463 | "bs1770", | |
| 464 | 464 | "hound", | |
| @@ -476,7 +476,7 @@ dependencies = [ | |||
| 476 | 476 | ||
| 477 | 477 | [[package]] | |
| 478 | 478 | name = "audiofiles-ipc" | |
| 479 | - | version = "0.2.0" | |
| 479 | + | version = "0.2.1" | |
| 480 | 480 | dependencies = [ | |
| 481 | 481 | "audiofiles-core", | |
| 482 | 482 | "serde", | |
| @@ -486,7 +486,7 @@ dependencies = [ | |||
| 486 | 486 | ||
| 487 | 487 | [[package]] | |
| 488 | 488 | name = "audiofiles-plugin" | |
| 489 | - | version = "0.2.0" | |
| 489 | + | version = "0.2.1" | |
| 490 | 490 | dependencies = [ | |
| 491 | 491 | "audiofiles-browser", | |
| 492 | 492 | "audiofiles-core", | |
| @@ -494,11 +494,12 @@ dependencies = [ | |||
| 494 | 494 | "nih_plug", | |
| 495 | 495 | "nih_plug_egui", | |
| 496 | 496 | "parking_lot", | |
| 497 | + | "smallvec", | |
| 497 | 498 | ] | |
| 498 | 499 | ||
| 499 | 500 | [[package]] | |
| 500 | 501 | name = "audiofiles-rhai" | |
| 501 | - | version = "0.2.0" | |
| 502 | + | version = "0.2.1" | |
| 502 | 503 | dependencies = [ | |
| 503 | 504 | "audiofiles-core", | |
| 504 | 505 | "dirs", | |
| @@ -511,7 +512,7 @@ dependencies = [ | |||
| 511 | 512 | ||
| 512 | 513 | [[package]] | |
| 513 | 514 | name = "audiofiles-sync" | |
| 514 | - | version = "0.2.0" | |
| 515 | + | version = "0.2.1" | |
| 515 | 516 | dependencies = [ | |
| 516 | 517 | "audiofiles-core", | |
| 517 | 518 | "base64", | |
| @@ -2852,6 +2853,15 @@ dependencies = [ | |||
| 2852 | 2853 | ] | |
| 2853 | 2854 | ||
| 2854 | 2855 | [[package]] | |
| 2856 | + | name = "matchers" | |
| 2857 | + | version = "0.2.0" | |
| 2858 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2859 | + | checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" | |
| 2860 | + | dependencies = [ | |
| 2861 | + | "regex-automata", | |
| 2862 | + | ] | |
| 2863 | + | ||
| 2864 | + | [[package]] | |
| 2855 | 2865 | name = "memchr" | |
| 2856 | 2866 | version = "2.8.0" | |
| 2857 | 2867 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -4973,7 +4983,7 @@ dependencies = [ | |||
| 4973 | 4983 | ||
| 4974 | 4984 | [[package]] | |
| 4975 | 4985 | name = "synckit-client" | |
| 4976 | - | version = "0.2.0" | |
| 4986 | + | version = "0.2.1" | |
| 4977 | 4987 | dependencies = [ | |
| 4978 | 4988 | "argon2", | |
| 4979 | 4989 | "base64", | |
| @@ -4988,6 +4998,7 @@ dependencies = [ | |||
| 4988 | 4998 | "thiserror 1.0.69", | |
| 4989 | 4999 | "tokio", | |
| 4990 | 5000 | "tracing", | |
| 5001 | + | "unicode-normalization", | |
| 4991 | 5002 | "urlencoding", | |
| 4992 | 5003 | "uuid 1.21.0", | |
| 4993 | 5004 | ] | |
| @@ -5050,7 +5061,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 5050 | 5061 | checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" | |
| 5051 | 5062 | dependencies = [ | |
| 5052 | 5063 | "fastrand", | |
| 5053 | - | "getrandom 0.3.4", | |
| 5064 | + | "getrandom 0.4.2", | |
| 5054 | 5065 | "once_cell", | |
| 5055 | 5066 | "rustix 1.1.3", | |
| 5056 | 5067 | "windows-sys 0.61.2", | |
| @@ -5187,6 +5198,21 @@ dependencies = [ | |||
| 5187 | 5198 | ] | |
| 5188 | 5199 | ||
| 5189 | 5200 | [[package]] | |
| 5201 | + | name = "tinyvec" | |
| 5202 | + | version = "1.10.0" | |
| 5203 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5204 | + | checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" | |
| 5205 | + | dependencies = [ | |
| 5206 | + | "tinyvec_macros", | |
| 5207 | + | ] | |
| 5208 | + | ||
| 5209 | + | [[package]] | |
| 5210 | + | name = "tinyvec_macros" | |
| 5211 | + | version = "0.1.1" | |
| 5212 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5213 | + | checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" | |
| 5214 | + | ||
| 5215 | + | [[package]] | |
| 5190 | 5216 | name = "tokio" | |
| 5191 | 5217 | version = "1.50.0" | |
| 5192 | 5218 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -5447,10 +5473,14 @@ version = "0.3.22" | |||
| 5447 | 5473 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5448 | 5474 | checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" | |
| 5449 | 5475 | dependencies = [ | |
| 5476 | + | "matchers", | |
| 5450 | 5477 | "nu-ansi-term", | |
| 5478 | + | "once_cell", | |
| 5479 | + | "regex-automata", | |
| 5451 | 5480 | "sharded-slab", | |
| 5452 | 5481 | "smallvec", | |
| 5453 | 5482 | "thread_local", | |
| 5483 | + | "tracing", | |
| 5454 | 5484 | "tracing-core", | |
| 5455 | 5485 | "tracing-log", | |
| 5456 | 5486 | ] | |
| @@ -5522,6 +5552,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 5522 | 5552 | checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" | |
| 5523 | 5553 | ||
| 5524 | 5554 | [[package]] | |
| 5555 | + | name = "unicode-normalization" | |
| 5556 | + | version = "0.1.25" | |
| 5557 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5558 | + | checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" | |
| 5559 | + | dependencies = [ | |
| 5560 | + | "tinyvec", | |
| 5561 | + | ] | |
| 5562 | + | ||
| 5563 | + | [[package]] | |
| 5525 | 5564 | name = "unicode-segmentation" | |
| 5526 | 5565 | version = "1.12.0" | |
| 5527 | 5566 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -6574,7 +6613,7 @@ checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" | |||
| 6574 | 6613 | ||
| 6575 | 6614 | [[package]] | |
| 6576 | 6615 | name = "xtask" | |
| 6577 | - | version = "0.2.0" | |
| 6616 | + | version = "0.2.1" | |
| 6578 | 6617 | dependencies = [ | |
| 6579 | 6618 | "nih_plug_xtask", | |
| 6580 | 6619 | ] |
| @@ -34,7 +34,7 @@ serde_json = "1.0.149" | |||
| 34 | 34 | rubato = "0.14" | |
| 35 | 35 | hound = "3.5" | |
| 36 | 36 | tracing = "0.1.44" | |
| 37 | - | tracing-subscriber = "0.3.22" | |
| 37 | + | tracing-subscriber = { version = "0.3.22", features = ["env-filter"] } | |
| 38 | 38 | rhai = { version = "1.21", features = ["sync"] } | |
| 39 | 39 | tray-icon = "0.21" | |
| 40 | 40 | tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "sync"] } | |
| @@ -42,3 +42,4 @@ uuid = { version = "1", features = ["v4"] } | |||
| 42 | 42 | base64 = "0.22" | |
| 43 | 43 | chrono = "0.4" | |
| 44 | 44 | rand = "0.8" | |
| 45 | + | smallvec = "1.13" |
| @@ -0,0 +1,58 @@ | |||
| 1 | + | # AudioFiles | |
| 2 | + | ||
| 3 | + | A sample manager with content-addressed storage and a virtual file system. Runs as a CLAP/VST3 audio plugin inside your DAW or as a standalone desktop app. Built with Rust, egui, and SQLite. | |
| 4 | + | ||
| 5 | + | ## Prerequisites | |
| 6 | + | ||
| 7 | + | - **Rust** (stable toolchain, 2021 edition) | |
| 8 | + | ||
| 9 | + | No platform-specific audio libraries are required. Audio decoding uses Symphonia (pure Rust), SQLite is bundled via rusqlite, and the standalone app uses cpal for system audio output. | |
| 10 | + | ||
| 11 | + | ## Build and Run | |
| 12 | + | ||
| 13 | + | ```sh | |
| 14 | + | # Standalone app | |
| 15 | + | cargo run -p audiofiles-app | |
| 16 | + | ||
| 17 | + | # Standalone app — import a folder on launch | |
| 18 | + | cargo run -p audiofiles-app -- /path/to/samples | |
| 19 | + | ||
| 20 | + | # CLAP/VST3 plugin bundle (uses nih-plug xtask) | |
| 21 | + | cargo xtask bundle audiofiles-plugin --release | |
| 22 | + | ||
| 23 | + | # Run all workspace tests | |
| 24 | + | cargo test --workspace | |
| 25 | + | ``` | |
| 26 | + | ||
| 27 | + | The `cargo xtask bundle` command produces both CLAP and VST3 bundles in `target/bundled/`. Copy them to your DAW's plugin directory. | |
| 28 | + | ||
| 29 | + | ## Workspace Architecture | |
| 30 | + | ||
| 31 | + | Seven crates plus an xtask build helper: | |
| 32 | + | ||
| 33 | + | | Crate | Path | Role | | |
| 34 | + | |-------|------|------| | |
| 35 | + | | `audiofiles-core` | `crates/audiofiles-core/` | Domain library. SQLite database, content-addressed store (SHA-256), audio decoding (Symphonia), analysis pipeline (loudness, BPM, key, spectral, classification), VFS, tag system. | | |
| 36 | + | | `audiofiles-browser` | `crates/audiofiles-browser/` | Shared egui UI. File list, detail panel, waveform display, search/filter, import wizard, analysis progress, export. Used by both plugin and standalone. | | |
| 37 | + | | `audiofiles-plugin` | `crates/audiofiles-plugin/` | CLAP/VST3 plugin via nih-plug. Stereo output for preview playback, egui editor window, persistent state across DAW sessions. | | |
| 38 | + | | `audiofiles-app` | `crates/audiofiles-app/` | Standalone desktop app via eframe. System audio output (cpal), drag-and-drop import, CLI argument import, system tray. | | |
| 39 | + | | `audiofiles-sync` | `crates/audiofiles-sync/` | Cloud sync integration via SyncKit. Pushes/pulls sample metadata, tags, and VFS structure across devices. | | |
| 40 | + | | `audiofiles-rhai` | `crates/audiofiles-rhai/` | Rhai scripting engine for device export profiles. Transforms sample metadata and file layout for hardware samplers. | | |
| 41 | + | | `audiofiles-ipc` | `crates/audiofiles-ipc/` | IPC message types for communication between plugin and standalone instances. Shared database coordination. | | |
| 42 | + | ||
| 43 | + | Dependency flow: `audiofiles-core` is the leaf -> `audiofiles-rhai`, `audiofiles-sync`, and `audiofiles-ipc` depend on core -> `audiofiles-browser` depends on core, sync, and rhai -> `audiofiles-plugin` and `audiofiles-app` depend on browser and core. | |
| 44 | + | ||
| 45 | + | ## Features | |
| 46 | + | ||
| 47 | + | - **Content-addressed storage** -- samples stored by SHA-256 hash, automatic deduplication | |
| 48 | + | - **Virtual file system** -- organize samples in virtual directories independent of disk location, multiple VFS roots | |
| 49 | + | - **Analysis pipeline** -- loudness (peak/RMS/LUFS), BPM detection, key detection, spectral analysis, loop detection, classification into 12 categories | |
| 50 | + | - **Tag system** -- hierarchical dot-notation tags with auto-suggestions from analysis results | |
| 51 | + | - **Search and filtering** -- text search, BPM/duration ranges, key selector, classification filters, tag prefix matching | |
| 52 | + | - **Rhai export engine** -- scriptable device profiles for exporting to hardware samplers | |
| 53 | + | - **Cloud sync** -- cross-device sync of metadata, tags, and VFS via SyncKit (E2E encrypted) | |
| 54 | + | - **Audio formats** -- WAV, FLAC, MP3, OGG, AIFF | |
| 55 | + | ||
| 56 | + | ## License | |
| 57 | + | ||
| 58 | + | PolyForm Noncommercial 1.0.0 |
| @@ -7,6 +7,7 @@ use audiofiles_browser::state::SharedState; | |||
| 7 | 7 | use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; | |
| 8 | 8 | use cpal::Stream; | |
| 9 | 9 | use parking_lot::Mutex; | |
| 10 | + | use tracing::instrument; | |
| 10 | 11 | use thiserror::Error; | |
| 11 | 12 | ||
| 12 | 13 | /// Errors from audio output stream setup. | |
| @@ -26,6 +27,7 @@ pub enum AudioError { | |||
| 26 | 27 | ||
| 27 | 28 | /// Build and start a cpal output stream that reads from the shared preview state. | |
| 28 | 29 | /// Returns the stream handle (must be kept alive for playback to continue). | |
| 30 | + | #[instrument(skip_all)] | |
| 29 | 31 | pub fn start_output_stream(shared: Arc<SharedState>) -> Result<Stream, AudioError> { | |
| 30 | 32 | let host = cpal::default_host(); | |
| 31 | 33 | let device = host |
| @@ -13,6 +13,7 @@ use audiofiles_browser::state::{BrowserState, SharedState}; | |||
| 13 | 13 | use audiofiles_sync::{SyncKitConfig, SyncManager}; | |
| 14 | 14 | use eframe::egui; | |
| 15 | 15 | use eframe::egui::ViewportCommand; | |
| 16 | + | use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt}; | |
| 16 | 17 | ||
| 17 | 18 | /// Launch the AudioFiles standalone app. | |
| 18 | 19 | /// | |
| @@ -20,7 +21,12 @@ use eframe::egui::ViewportCommand; | |||
| 20 | 21 | /// audio output stream for sample preview, and opens an eframe window running | |
| 21 | 22 | /// the shared egui browser UI. | |
| 22 | 23 | fn main() -> eframe::Result<()> { | |
| 23 | - | tracing_subscriber::fmt::init(); | |
| 24 | + | tracing_subscriber::registry() | |
| 25 | + | .with(EnvFilter::try_from_default_env().unwrap_or_else(|_| { | |
| 26 | + | "audiofiles_app=info,audiofiles_browser=debug,audiofiles_sync=debug,audiofiles_core=info,warn".into() | |
| 27 | + | })) | |
| 28 | + | .with(tracing_subscriber::fmt::layer()) | |
| 29 | + | .init(); | |
| 24 | 30 | ||
| 25 | 31 | let data_dir = dirs::data_dir() | |
| 26 | 32 | .unwrap_or_else(|| PathBuf::from(".")) |
| @@ -625,26 +625,23 @@ impl Backend for DirectBackend { | |||
| 625 | 625 | } | |
| 626 | 626 | ||
| 627 | 627 | fn cancel_import(&self) -> BackendResult<()> { | |
| 628 | - | if let Some(ref worker) = *self.import_worker.lock() { | |
| 628 | + | if let Some(worker) = self.import_worker.lock().take() { | |
| 629 | 629 | worker.send(ImportCommand::Cancel); | |
| 630 | 630 | } | |
| 631 | - | *self.import_worker.lock() = None; | |
| 632 | 631 | Ok(()) | |
| 633 | 632 | } | |
| 634 | 633 | ||
| 635 | 634 | fn cancel_analysis(&self) -> BackendResult<()> { | |
| 636 | - | if let Some(ref worker) = *self.analysis_worker.lock() { | |
| 635 | + | if let Some(worker) = self.analysis_worker.lock().take() { | |
| 637 | 636 | worker.send(WorkerCommand::Cancel); | |
| 638 | 637 | } | |
| 639 | - | *self.analysis_worker.lock() = None; | |
| 640 | 638 | Ok(()) | |
| 641 | 639 | } | |
| 642 | 640 | ||
| 643 | 641 | fn cancel_export(&self) -> BackendResult<()> { | |
| 644 | - | if let Some(ref worker) = *self.export_worker.lock() { | |
| 642 | + | if let Some(worker) = self.export_worker.lock().take() { | |
| 645 | 643 | worker.send(ExportCommand::Cancel); | |
| 646 | 644 | } | |
| 647 | - | *self.export_worker.lock() = None; | |
| 648 | 645 | Ok(()) | |
| 649 | 646 | } | |
| 650 | 647 |
| @@ -7,7 +7,7 @@ use std::path::PathBuf; | |||
| 7 | 7 | use std::sync::{mpsc, Mutex}; | |
| 8 | 8 | use std::thread; | |
| 9 | 9 | ||
| 10 | - | use tracing::error; | |
| 10 | + | use tracing::{error, instrument}; | |
| 11 | 11 | ||
| 12 | 12 | use audiofiles_core::export::{ExportConfig, ExportItem, ExportSummary}; | |
| 13 | 13 | use audiofiles_core::store::SampleStore; | |
| @@ -74,6 +74,7 @@ impl Drop for ExportHandle { | |||
| 74 | 74 | /// Spawn the background export worker thread. | |
| 75 | 75 | /// | |
| 76 | 76 | /// The worker opens its own `SampleStore` to avoid Mutex contention with the GUI thread. | |
| 77 | + | #[instrument(skip_all)] | |
| 77 | 78 | pub fn spawn_export_worker(store_root: PathBuf) -> ExportHandle { | |
| 78 | 79 | let (cmd_tx, cmd_rx) = mpsc::channel::<ExportCommand>(); | |
| 79 | 80 | let (event_tx, event_rx) = mpsc::channel::<ExportEvent>(); |
| @@ -9,7 +9,7 @@ use std::path::{Path, PathBuf}; | |||
| 9 | 9 | use std::sync::{mpsc, Mutex}; | |
| 10 | 10 | use std::thread; | |
| 11 | 11 | ||
| 12 | - | use tracing::error; | |
| 12 | + | use tracing::{error, instrument}; | |
| 13 | 13 | ||
| 14 | 14 | use audiofiles_core::db::Database; | |
| 15 | 15 | use audiofiles_core::error::CoreError; | |
| @@ -116,6 +116,7 @@ impl Drop for ImportHandle { | |||
| 116 | 116 | /// | |
| 117 | 117 | /// The worker opens its own `Database` and `SampleStore` to avoid Mutex contention | |
| 118 | 118 | /// with the GUI thread. Returns a handle for sending commands and polling events. | |
| 119 | + | #[instrument(skip_all)] | |
| 119 | 120 | pub fn spawn_import_worker(db_path: PathBuf, store_root: PathBuf) -> ImportHandle { | |
| 120 | 121 | let (cmd_tx, cmd_rx) = mpsc::channel::<ImportCommand>(); | |
| 121 | 122 | let (event_tx, event_rx) = mpsc::channel::<ImportEvent>(); |
| @@ -10,10 +10,16 @@ use realfft::RealFftPlanner; | |||
| 10 | 10 | /// Results of spectral analysis across the entire signal. | |
| 11 | 11 | #[derive(Debug, Clone, Default)] | |
| 12 | 12 | pub struct SpectralFeatures { | |
| 13 | + | /// Spectral center of mass in Hz (low = bassy, high = bright). | |
| 13 | 14 | pub centroid: f64, | |
| 15 | + | /// Ratio from 0.0 (pure tone) to 1.0 (white noise), computed as geometric/arithmetic | |
| 16 | + | /// mean of magnitudes. | |
| 14 | 17 | pub flatness: f64, | |
| 18 | + | /// Frequency in Hz below which 85% of spectral energy resides. | |
| 15 | 19 | pub rolloff: f64, | |
| 20 | + | /// Proportion of sign changes per sample (high = noisy/bright transients). | |
| 16 | 21 | pub zero_crossing_rate: f64, | |
| 22 | + | /// Sum of positive spectral flux across frames (higher = more percussive onsets). | |
| 17 | 23 | pub onset_strength: f64, | |
| 18 | 24 | } | |
| 19 | 25 |
| @@ -70,6 +70,10 @@ pub enum CoreError { | |||
| 70 | 70 | /// JSON serialization/deserialization failure. | |
| 71 | 71 | #[error("serialization error: {0}")] | |
| 72 | 72 | Serialization(String), | |
| 73 | + | ||
| 74 | + | /// Internal logic error (e.g. invalid arguments to an internal function). | |
| 75 | + | #[error("internal error: {0}")] | |
| 76 | + | Internal(String), | |
| 73 | 77 | } | |
| 74 | 78 | ||
| 75 | 79 | /// Typed errors for the audio analysis/decode pipeline. | |
| @@ -169,6 +173,7 @@ mod tests { | |||
| 169 | 173 | CoreError::Analysis(AnalysisError::NoAudioData), | |
| 170 | 174 | CoreError::Export("test export error".into()), | |
| 171 | 175 | CoreError::Serialization("test serialization error".into()), | |
| 176 | + | CoreError::Internal("test internal error".into()), | |
| 172 | 177 | ]; | |
| 173 | 178 | for err in &variants { | |
| 174 | 179 | let msg = format!("{err}"); |
| @@ -9,6 +9,24 @@ use sha2::{Digest, Sha256}; | |||
| 9 | 9 | use crate::db::Database; | |
| 10 | 10 | use crate::error::{io_err, unix_now, CoreError, Result}; | |
| 11 | 11 | ||
| 12 | + | /// Validate that a file extension contains only safe characters. | |
| 13 | + | /// | |
| 14 | + | /// Allows alphanumeric, dots, and hyphens (covers wav, mp3, flac, aiff, ogg, | |
| 15 | + | /// tar.gz, etc.). Rejects path separators, null bytes, and anything else that | |
| 16 | + | /// could be used for directory traversal. | |
| 17 | + | fn validate_extension(ext: &str) -> Result<()> { | |
| 18 | + | if !ext.is_empty() | |
| 19 | + | && !ext | |
| 20 | + | .bytes() | |
| 21 | + | .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'-') | |
| 22 | + | { | |
| 23 | + | return Err(CoreError::Internal(format!( | |
| 24 | + | "invalid file extension: {ext:?}" | |
| 25 | + | ))); | |
| 26 | + | } | |
| 27 | + | Ok(()) | |
| 28 | + | } | |
| 29 | + | ||
| 12 | 30 | /// Validate that a hash string is exactly 64 lowercase hex characters (SHA-256). | |
| 13 | 31 | fn validate_hash(hash: &str) -> Result<()> { | |
| 14 | 32 | if hash.len() != 64 || !hash.bytes().all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) | |
| @@ -22,6 +40,14 @@ fn validate_hash(hash: &str) -> Result<()> { | |||
| 22 | 40 | Ok(()) | |
| 23 | 41 | } | |
| 24 | 42 | ||
| 43 | + | /// Manages on-disk sample blobs in a flat directory structure, storing files as | |
| 44 | + | /// `{sha256_hex}.{ext}` directly in the root directory. | |
| 45 | + | /// | |
| 46 | + | /// Deduplication via SHA-256: import streams the file through a hasher and skips | |
| 47 | + | /// the copy if a blob with the same hash already exists. | |
| 48 | + | /// | |
| 49 | + | /// Thread-safe: all operations are stateless reads/writes against the filesystem | |
| 50 | + | /// (no interior mutability or locks). | |
| 25 | 51 | pub struct SampleStore { | |
| 26 | 52 | root: PathBuf, | |
| 27 | 53 | } | |
| @@ -41,6 +67,13 @@ impl SampleStore { | |||
| 41 | 67 | let metadata = file.metadata().map_err(|e| io_err(path, e))?; | |
| 42 | 68 | let file_size = metadata.len() as i64; | |
| 43 | 69 | ||
| 70 | + | if file_size == 0 { | |
| 71 | + | return Err(CoreError::Internal(format!( | |
| 72 | + | "cannot import zero-byte file: {}", | |
| 73 | + | path.display() | |
| 74 | + | ))); | |
| 75 | + | } | |
| 76 | + | ||
| 44 | 77 | // Stream through SHA-256 | |
| 45 | 78 | let mut hasher = Sha256::new(); | |
| 46 | 79 | let mut buf = [0u8; 8192]; | |
| @@ -84,6 +117,7 @@ impl SampleStore { | |||
| 84 | 117 | /// to prevent directory traversal or malformed paths. | |
| 85 | 118 | pub fn sample_path(&self, hash: &str, ext: &str) -> Result<PathBuf> { | |
| 86 | 119 | validate_hash(hash)?; | |
| 120 | + | validate_extension(ext)?; | |
| 87 | 121 | if ext.is_empty() { | |
| 88 | 122 | Ok(self.root.join(hash)) | |
| 89 | 123 | } else { | |
| @@ -92,20 +126,25 @@ impl SampleStore { | |||
| 92 | 126 | } | |
| 93 | 127 | ||
| 94 | 128 | /// Remove a sample from store and database. CASCADE handles VFS/tag refs. | |
| 129 | + | /// | |
| 130 | + | /// Deletes the DB row first, then removes the file from disk. This ordering | |
| 131 | + | /// ensures that if the file deletion fails, the only residue is an orphaned | |
| 132 | + | /// blob on disk (harmless, can be cleaned up later). The reverse order would | |
| 133 | + | /// risk deleting the file while the DB row still references it. | |
| 95 | 134 | pub fn remove(&self, hash: &str, db: &Database) -> Result<()> { | |
| 96 | 135 | // Look up extension before deleting the row | |
| 97 | 136 | let ext = sample_extension(db, hash)?; | |
| 98 | - | ||
| 99 | - | // Delete file | |
| 100 | 137 | let path = self.sample_path(hash, &ext)?; | |
| 101 | - | if path.exists() { | |
| 102 | - | fs::remove_file(&path).map_err(|e| io_err(&path, e))?; | |
| 103 | - | } | |
| 104 | 138 | ||
| 105 | - | // Delete DB row (CASCADE handles tags, vfs_nodes, etc.) | |
| 139 | + | // Delete DB row first (CASCADE handles tags, vfs_nodes, etc.) | |
| 106 | 140 | db.conn() | |
| 107 | 141 | .execute("DELETE FROM samples WHERE hash = ?1", [hash])?; | |
| 108 | 142 | ||
| 143 | + | // Then delete the file — an orphaned blob is harmless if this fails | |
| 144 | + | if path.exists() { | |
| 145 | + | fs::remove_file(&path).map_err(|e| io_err(&path, e))?; | |
| 146 | + | } | |
| 147 | + | ||
| 109 | 148 | Ok(()) | |
| 110 | 149 | } | |
| 111 | 150 | ||
| @@ -113,12 +152,45 @@ impl SampleStore { | |||
| 113 | 152 | pub fn root(&self) -> &Path { | |
| 114 | 153 | &self.root | |
| 115 | 154 | } | |
| 155 | + | ||
| 156 | + | /// Re-hash a stored sample and compare against the expected hash. | |
| 157 | + | /// | |
| 158 | + | /// Returns `Ok(true)` if the file's SHA-256 matches `hash`, `Ok(false)` if | |
| 159 | + | /// it differs. Returns an error if the file cannot be read. | |
| 160 | + | pub fn verify_sample(&self, hash: &str, ext: &str) -> Result<bool> { | |
| 161 | + | let path = self.sample_path(hash, ext)?; | |
| 162 | + | let mut file = fs::File::open(&path).map_err(|e| io_err(&path, e))?; | |
| 163 | + | ||
| 164 | + | let mut hasher = Sha256::new(); | |
| 165 | + | let mut buf = [0u8; 8192]; | |
| 166 | + | loop { | |
| 167 | + | let n = file.read(&mut buf).map_err(|e| io_err(&path, e))?; | |
| 168 | + | if n == 0 { | |
| 169 | + | break; | |
| 170 | + | } | |
| 171 | + | hasher.update(&buf[..n]); | |
| 172 | + | } | |
| 173 | + | let computed = format!("{:x}", hasher.finalize()); | |
| 174 | + | ||
| 175 | + | Ok(computed == hash) | |
| 176 | + | } | |
| 116 | 177 | } | |
| 117 | 178 | ||
| 118 | 179 | // --- Sample metadata queries --- | |
| 119 | 180 | ||
| 120 | 181 | /// Helper to query a single text column from the samples table by hash. | |
| 182 | + | /// | |
| 183 | + | /// Only fields in the allowlist may be queried; any other value returns an error | |
| 184 | + | /// to prevent SQL injection through the interpolated column name. | |
| 121 | 185 | fn query_sample_field(db: &Database, hash: &str, field: &str) -> Result<String> { | |
| 186 | + | const ALLOWED_FIELDS: &[&str] = &["file_extension", "original_name"]; | |
| 187 | + | ||
| 188 | + | if !ALLOWED_FIELDS.contains(&field) { | |
| 189 | + | return Err(CoreError::Internal(format!( | |
| 190 | + | "query_sample_field: disallowed field {field:?}" | |
| 191 | + | ))); | |
| 192 | + | } | |
| 193 | + | ||
| 122 | 194 | let sql = format!("SELECT {field} FROM samples WHERE hash = ?1"); | |
| 123 | 195 | db.conn() | |
| 124 | 196 | .query_row(&sql, [hash], |row| row.get(0)) | |
| @@ -257,4 +329,124 @@ mod tests { | |||
| 257 | 329 | let path = store.sample_path(&hash, "wav").unwrap(); | |
| 258 | 330 | assert!(path.to_string_lossy().ends_with(".wav")); | |
| 259 | 331 | } | |
| 332 | + | ||
| 333 | + | #[test] | |
| 334 | + | fn sample_path_rejects_traversal_in_extension() { | |
| 335 | + | let (_dir, _db, store) = setup(); | |
| 336 | + | let hash = "a".repeat(64); | |
| 337 | + | let result = store.sample_path(&hash, "../etc/passwd"); | |
| 338 | + | assert!(matches!(result, Err(CoreError::Internal(_)))); | |
| 339 | + | } | |
| 340 | + | ||
| 341 | + | #[test] | |
| 342 | + | fn sample_path_rejects_path_separator_in_extension() { | |
| 343 | + | let (_dir, _db, store) = setup(); | |
| 344 | + | let hash = "a".repeat(64); | |
| 345 | + | let result = store.sample_path(&hash, "wav/../../etc"); | |
| 346 | + | assert!(matches!(result, Err(CoreError::Internal(_)))); | |
| 347 | + | } | |
| 348 | + | ||
| 349 | + | #[test] | |
| 350 | + | fn sample_path_accepts_common_extensions() { | |
| 351 | + | let (_dir, _db, store) = setup(); | |
| 352 | + | let hash = "a".repeat(64); | |
| 353 | + | for ext in &["wav", "mp3", "flac", "aiff", "ogg", "tar.gz"] { | |
| 354 | + | assert!(store.sample_path(&hash, ext).is_ok(), "rejected valid ext: {ext}"); | |
| 355 | + | } | |
| 356 | + | } | |
| 357 | + | ||
| 358 | + | #[test] | |
| 359 | + | fn query_sample_field_rejects_disallowed_field() { | |
| 360 | + | let (_dir, db, _store) = setup(); | |
| 361 | + | let hash = "a".repeat(64); | |
| 362 | + | let result = query_sample_field(&db, &hash, "hash; DROP TABLE samples --"); | |
| 363 | + | assert!(matches!(result, Err(CoreError::Internal(_)))); | |
| 364 | + | } | |
| 365 | + | ||
| 366 | + | #[test] | |
| 367 | + | fn verify_sample_matches_after_import() { | |
| 368 | + | let (dir, db, store) = setup(); | |
| 369 | + | let src = create_test_file(&dir, "hihat.wav", b"hihat audio data"); | |
| 370 | + | ||
| 371 | + | let hash = store.import(&src, &db).unwrap(); | |
| 372 | + | assert!(store.verify_sample(&hash, "wav").unwrap()); | |
| 373 | + | } | |
| 374 | + | ||
| 375 | + | #[test] | |
| 376 | + | fn verify_sample_detects_corruption() { | |
| 377 | + | let (dir, db, store) = setup(); | |
| 378 | + | let src = create_test_file(&dir, "snare.wav", b"original data"); | |
| 379 | + | ||
| 380 | + | let hash = store.import(&src, &db).unwrap(); | |
| 381 | + | ||
| 382 | + | // Corrupt the stored file | |
| 383 | + | let stored_path = store.sample_path(&hash, "wav").unwrap(); | |
| 384 | + | fs::write(&stored_path, b"corrupted data").unwrap(); | |
| 385 | + | ||
| 386 | + | assert!(!store.verify_sample(&hash, "wav").unwrap()); | |
| 387 | + | } | |
| 388 | + | ||
| 389 | + | #[test] | |
| 390 | + | fn verify_sample_errors_on_missing_file() { | |
| 391 | + | let (_dir, _db, store) = setup(); | |
| 392 | + | let fake_hash = "b".repeat(64); | |
| 393 | + | let result = store.verify_sample(&fake_hash, "wav"); | |
| 394 | + | assert!(matches!(result, Err(CoreError::Io { .. }))); | |
| 395 | + | } | |
| 396 | + | ||
| 397 | + | #[test] | |
| 398 | + | fn import_rejects_zero_byte_file() { | |
| 399 | + | let (dir, db, store) = setup(); | |
| 400 | + | let src = create_test_file(&dir, "empty.wav", b""); | |
| 401 | + | ||
| 402 | + | let result = store.import(&src, &db); | |
| 403 | + | assert!(result.is_err()); | |
| 404 | + | let err_msg = format!("{}", result.unwrap_err()); | |
| 405 | + | assert!( | |
| 406 | + | err_msg.contains("zero-byte"), | |
| 407 | + | "expected zero-byte error, got: {err_msg}" | |
| 408 | + | ); | |
| 409 | + | ||
| 410 | + | // No row should have been inserted | |
| 411 | + | let count: i64 = db | |
| 412 | + | .conn() | |
| 413 | + | .query_row("SELECT COUNT(*) FROM samples", [], |row| row.get(0)) | |
| 414 | + | .unwrap(); | |
| 415 | + | assert_eq!(count, 0); | |
| 416 | + | } | |
| 417 | + | ||
| 418 | + | #[test] | |
| 419 | + | fn import_accepts_non_empty_file() { | |
| 420 | + | let (dir, db, store) = setup(); | |
| 421 | + | let src = create_test_file(&dir, "valid.wav", b"audio content"); | |
| 422 | + | ||
| 423 | + | let hash = store.import(&src, &db).unwrap(); | |
| 424 | + | assert!(!hash.is_empty()); | |
| 425 | + | assert!(store.exists(&hash, "wav").unwrap()); | |
| 426 | + | } | |
| 427 | + | ||
| 428 | + | #[test] | |
| 429 | + | fn remove_deletes_db_row_before_file() { | |
| 430 | + | // Verify that after remove(), both the DB row and the file are gone. | |
| 431 | + | // The ordering guarantee (DB first, then file) means an orphaned blob | |
| 432 | + | // is the only possible failure mode — never a dangling DB reference. | |
| 433 | + | let (dir, db, store) = setup(); | |
| 434 | + | let src = create_test_file(&dir, "tom.wav", b"tom data"); | |
| 435 | + | ||
| 436 | + | let hash = store.import(&src, &db).unwrap(); | |
| 437 | + | let stored_path = store.sample_path(&hash, "wav").unwrap(); | |
| 438 | + | assert!(stored_path.exists()); | |
| 439 | + | ||
| 440 | + | store.remove(&hash, &db).unwrap(); | |
| 441 | + | ||
| 442 | + | // DB row gone | |
| 443 | + | let count: i64 = db | |
| 444 | + | .conn() | |
| 445 | + | .query_row("SELECT COUNT(*) FROM samples", [], |row| row.get(0)) | |
| 446 | + | .unwrap(); | |
| 447 | + | assert_eq!(count, 0); | |
| 448 | + | ||
| 449 | + | // File gone | |
| 450 | + | assert!(!stored_path.exists()); | |
| 451 | + | } | |
| 260 | 452 | } |
| @@ -260,7 +260,25 @@ pub fn rename_node(db: &Database, id: NodeId, new_name: &str) -> Result<()> { | |||
| 260 | 260 | } | |
| 261 | 261 | ||
| 262 | 262 | /// Move a VFS node to a new parent directory (or root if `None`). | |
| 263 | + | /// | |
| 264 | + | /// Returns an error if the move would create a circular parent reference | |
| 265 | + | /// (e.g. moving a node to be a child of one of its own descendants). | |
| 263 | 266 | pub fn move_node(db: &Database, id: NodeId, new_parent_id: Option<NodeId>) -> Result<()> { | |
| 267 | + | // Check for circular reference: walk from new_parent_id up to root. | |
| 268 | + | // If we encounter `id` along the way, the move would create a cycle. | |
| 269 | + | if let Some(parent) = new_parent_id { | |
| 270 | + | let mut current = Some(parent); | |
| 271 | + | while let Some(cur_id) = current { | |
| 272 | + | if cur_id == id { | |
| 273 | + | return Err(CoreError::Internal( | |
| 274 | + | "move would create a circular parent reference".to_string(), | |
| 275 | + | )); | |
| 276 | + | } | |
| 277 | + | let node = get_node(db, cur_id)?; | |
| 278 | + | current = node.parent_id; | |
| 279 | + | } | |
| 280 | + | } | |
| 281 | + | ||
| 264 | 282 | let changed = db.conn().execute( | |
| 265 | 283 | "UPDATE vfs_nodes SET parent_id = ?1 WHERE id = ?2", | |
| 266 | 284 | rusqlite::params![new_parent_id, id], | |
| @@ -445,9 +463,11 @@ pub fn find_nodes_by_hashes( | |||
| 445 | 463 | .collect(); | |
| 446 | 464 | let sql = format!( | |
| 447 | 465 | "SELECT n.id, n.vfs_id, n.parent_id, n.name, n.node_type, n.sample_hash, n.created_at, | |
| 448 | - | a.bpm, a.musical_key, a.duration, a.classification, a.peak_db, a.is_loop | |
| 466 | + | a.bpm, a.musical_key, a.duration, a.classification, a.peak_db, a.is_loop, | |
| 467 | + | s.cloud_only | |
| 449 | 468 | FROM vfs_nodes n | |
| 450 | 469 | LEFT JOIN audio_analysis a ON n.sample_hash = a.hash | |
| 470 | + | LEFT JOIN samples s ON n.sample_hash = s.hash | |
| 451 | 471 | WHERE n.vfs_id = ?1 AND n.sample_hash IN ({}) | |
| 452 | 472 | GROUP BY n.sample_hash", | |
| 453 | 473 | placeholders.join(", ") | |
| @@ -733,4 +753,121 @@ mod tests { | |||
| 733 | 753 | // Directories have no sample_hash so cloud_only defaults to false | |
| 734 | 754 | assert!(!nodes[0].cloud_only); | |
| 735 | 755 | } | |
| 756 | + | ||
| 757 | + | #[test] | |
| 758 | + | fn find_nodes_by_hashes_returns_correct_results() { | |
| 759 | + | let db = setup(); | |
| 760 | + | let vfs_id = create_vfs(&db, "Lib").unwrap(); | |
| 761 | + | insert_fake_sample(&db, "hash_a"); | |
| 762 | + | insert_fake_sample(&db, "hash_b"); | |
| 763 | + | insert_fake_sample(&db, "hash_c"); | |
| 764 | + | create_sample_link(&db, vfs_id, None, "a.wav", "hash_a").unwrap(); | |
| 765 | + | create_sample_link(&db, vfs_id, None, "b.wav", "hash_b").unwrap(); | |
| 766 | + | create_sample_link(&db, vfs_id, None, "c.wav", "hash_c").unwrap(); | |
| 767 | + | ||
| 768 | + | let results = | |
| 769 | + | find_nodes_by_hashes(&db, vfs_id, &["hash_b", "hash_a"]).unwrap(); | |
| 770 | + | ||
| 771 | + | // Returns results in input order | |
| 772 | + | assert_eq!(results.len(), 2); | |
| 773 | + | assert_eq!(results[0].node.sample_hash.as_deref(), Some("hash_b")); | |
| 774 | + | assert_eq!(results[1].node.sample_hash.as_deref(), Some("hash_a")); | |
| 775 | + | } | |
| 776 | + | ||
| 777 | + | #[test] | |
| 778 | + | fn find_nodes_by_hashes_empty_input() { | |
| 779 | + | let db = setup(); | |
| 780 | + | let vfs_id = create_vfs(&db, "Lib").unwrap(); | |
| 781 | + | ||
| 782 | + | let results = find_nodes_by_hashes(&db, vfs_id, &[]).unwrap(); | |
| 783 | + | assert!(results.is_empty()); | |
| 784 | + | } | |
| 785 | + | ||
| 786 | + | #[test] | |
| 787 | + | fn find_nodes_by_hashes_nonexistent_hash() { | |
| 788 | + | let db = setup(); | |
| 789 | + | let vfs_id = create_vfs(&db, "Lib").unwrap(); | |
| 790 | + | ||
| 791 | + | let results = | |
| 792 | + | find_nodes_by_hashes(&db, vfs_id, &["nonexistent"]).unwrap(); | |
| 793 | + | assert!(results.is_empty()); | |
| 794 | + | } | |
| 795 | + | ||
| 796 | + | #[test] | |
| 797 | + | fn find_nodes_by_hashes_includes_cloud_only() { | |
| 798 | + | let db = setup(); | |
| 799 | + | let vfs_id = create_vfs(&db, "Lib").unwrap(); | |
| 800 | + | insert_fake_sample(&db, "hash_cloud"); | |
| 801 | + | create_sample_link(&db, vfs_id, None, "cloud.wav", "hash_cloud").unwrap(); | |
| 802 | + | ||
| 803 | + | db.conn() | |
| 804 | + | .execute( | |
| 805 | + | "UPDATE samples SET cloud_only = 1 WHERE hash = 'hash_cloud'", | |
| 806 | + | [], | |
| 807 | + | ) | |
| 808 | + | .unwrap(); | |
| 809 | + | ||
| 810 | + | let results = | |
| 811 | + | find_nodes_by_hashes(&db, vfs_id, &["hash_cloud"]).unwrap(); | |
| 812 | + | assert_eq!(results.len(), 1); | |
| 813 | + | assert!(results[0].cloud_only); | |
| 814 | + | } | |
| 815 | + | ||
| 816 | + | #[test] | |
| 817 | + | fn move_node_rejects_circular_parent_to_child() { | |
| 818 | + | let db = setup(); | |
| 819 | + | let vfs_id = create_vfs(&db, "Lib").unwrap(); | |
| 820 | + | let a = create_directory(&db, vfs_id, None, "A").unwrap(); | |
| 821 | + | let b = create_directory(&db, vfs_id, Some(a), "B").unwrap(); | |
| 822 | + | let c = create_directory(&db, vfs_id, Some(b), "C").unwrap(); | |
| 823 | + | ||
| 824 | + | // Moving A under C would create A -> B -> C -> A cycle | |
| 825 | + | let result = move_node(&db, a, Some(c)); | |
| 826 | + | assert!(result.is_err()); | |
| 827 | + | let err_msg = format!("{}", result.unwrap_err()); | |
| 828 | + | assert!(err_msg.contains("circular"), "expected circular error, got: {err_msg}"); | |
| 829 | + | } | |
| 830 | + | ||
| 831 | + | #[test] | |
| 832 | + | fn move_node_allows_valid_reparent() { | |
| 833 | + | let db = setup(); | |
| 834 | + | let vfs_id = create_vfs(&db, "Lib").unwrap(); | |
| 835 | + | let a = create_directory(&db, vfs_id, None, "A").unwrap(); | |
| 836 | + | let b = create_directory(&db, vfs_id, Some(a), "B").unwrap(); | |
| 837 | + | let c = create_directory(&db, vfs_id, Some(b), "C").unwrap(); | |
| 838 | + | ||
| 839 | + | // Moving C under A (skipping B) is valid — no cycle | |
| 840 | + | move_node(&db, c, Some(a)).unwrap(); | |
| 841 | + | let node = get_node(&db, c).unwrap(); | |
| 842 | + | assert_eq!(node.parent_id, Some(a)); | |
| 843 | + | } | |
| 844 | + | ||
| 845 | + | #[test] | |
| 846 | + | fn move_node_to_root_succeeds() { | |
| 847 | + | let db = setup(); | |
| 848 | + | let vfs_id = create_vfs(&db, "Lib").unwrap(); | |
| 849 | + | let a = create_directory(&db, vfs_id, None, "A").unwrap(); | |
| 850 | + | let b = create_directory(&db, vfs_id, Some(a), "B").unwrap(); | |
| 851 | + | ||
| 852 | + | // Moving A to root is always valid | |
| 853 | + | move_node(&db, a, None).unwrap(); | |
| 854 | + | let node = get_node(&db, a).unwrap(); | |
| 855 | + | assert_eq!(node.parent_id, None); | |
| 856 | + | ||
| 857 | + | // Moving B to root is also valid | |
| 858 | + | move_node(&db, b, None).unwrap(); | |
| 859 | + | let node = get_node(&db, b).unwrap(); | |
| 860 | + | assert_eq!(node.parent_id, None); | |
| 861 | + | } | |
| 862 | + | ||
| 863 | + | #[test] | |
| 864 | + | fn move_node_rejects_self_as_parent() { | |
| 865 | + | let db = setup(); | |
| 866 | + | let vfs_id = create_vfs(&db, "Lib").unwrap(); | |
| 867 | + | let a = create_directory(&db, vfs_id, None, "A").unwrap(); | |
| 868 | + | ||
| 869 | + | // Moving A under itself creates a trivial cycle | |
| 870 | + | let result = move_node(&db, a, Some(a)); | |
| 871 | + | assert!(result.is_err()); | |
| 872 | + | } | |
| 736 | 873 | } |
| @@ -13,3 +13,4 @@ nih_plug = { workspace = true } | |||
| 13 | 13 | nih_plug_egui = { workspace = true } | |
| 14 | 14 | parking_lot = { workspace = true } | |
| 15 | 15 | dirs = { workspace = true } | |
| 16 | + | smallvec = { workspace = true } |
| @@ -4,6 +4,7 @@ use audiofiles_browser::instrument::{EnvelopePhase, InstrumentPlayback}; | |||
| 4 | 4 | use nih_plug::buffer::Buffer; | |
| 5 | 5 | use nih_plug::prelude::*; | |
| 6 | 6 | use parking_lot::Mutex; | |
| 7 | + | use smallvec::SmallVec; | |
| 7 | 8 | ||
| 8 | 9 | /// Process MIDI events and render instrument voices into the output buffer. | |
| 9 | 10 | /// | |
| @@ -17,7 +18,7 @@ pub fn fill_instrument<P: Plugin>( | |||
| 17 | 18 | sample_rate: f32, | |
| 18 | 19 | ) -> bool { | |
| 19 | 20 | // Drain MIDI events regardless of lock state to prevent event backup | |
| 20 | - | let mut events = Vec::new(); | |
| 21 | + | let mut events: SmallVec<[NoteEvent<P::SysExMessage>; 8]> = SmallVec::new(); | |
| 21 | 22 | while let Some(event) = context.next_event() { | |
| 22 | 23 | events.push(event); | |
| 23 | 24 | } |
| @@ -5,6 +5,8 @@ use base64::Engine; | |||
| 5 | 5 | use sha2::{Digest, Sha256}; | |
| 6 | 6 | use synckit_client::SyncKitClient; | |
| 7 | 7 | ||
| 8 | + | use tracing::instrument; | |
| 9 | + | ||
| 8 | 10 | use crate::error::{Result, SyncError}; | |
| 9 | 11 | ||
| 10 | 12 | /// Result of the localhost callback: authorization code + state. | |
| @@ -45,6 +47,7 @@ pub fn generate_state() -> String { | |||
| 45 | 47 | } | |
| 46 | 48 | ||
| 47 | 49 | /// Start the OAuth2 PKCE flow: bind a localhost callback server, build the auth URL. | |
| 50 | + | #[instrument(skip_all)] | |
| 48 | 51 | pub fn start_auth(client: &SyncKitClient) -> Result<AuthSession> { | |
| 49 | 52 | let code_verifier = generate_code_verifier(); | |
| 50 | 53 | let code_challenge = generate_code_challenge(&code_verifier); | |
| @@ -92,8 +95,19 @@ pub fn start_auth(client: &SyncKitClient) -> Result<AuthSession> { | |||
| 92 | 95 | ||
| 93 | 96 | // Parse GET /callback?code=X&state=Y or GET /?code=X&state=Y | |
| 94 | 97 | if let Some(query_start) = request.find('?') { | |
| 95 | - | let query_end = request[query_start..].find(' ').unwrap_or(request.len() - query_start); | |
| 96 | - | let query = &request[query_start + 1..query_start + query_end]; | |
| 98 | + | let after_q = query_start + 1; | |
| 99 | + | let query_end = request[query_start..] | |
| 100 | + | .find(' ') | |
| 101 | + | .unwrap_or(request.len().saturating_sub(query_start)); | |
| 102 | + | let end = query_start + query_end; | |
| 103 | + | ||
| 104 | + | let query = match request.get(after_q..end) { | |
| 105 | + | Some(q) => q, | |
| 106 | + | None => { | |
| 107 | + | tracing::warn!("Malformed OAuth callback request, could not parse query string"); | |
| 108 | + | break; | |
| 109 | + | } | |
| 110 | + | }; | |
| 97 | 111 | ||
| 98 | 112 | let mut code = None; | |
| 99 | 113 | let mut cb_state = None; |
| @@ -222,7 +222,7 @@ impl SyncManager { | |||
| 222 | 222 | ||
| 223 | 223 | /// Try to restore a previous session from keychain on startup. | |
| 224 | 224 | pub fn try_restore_session(&self) { | |
| 225 | - | if self.client.session_info().is_some() { | |
| 225 | + | if self.client.session_info().ok().and_then(|o| o).is_some() { | |
| 226 | 226 | let has_key = self.client.try_load_key_from_keychain().unwrap_or(false); | |
| 227 | 227 | if has_key { | |
| 228 | 228 | let mut s = self.status.lock(); |
| @@ -7,6 +7,8 @@ use parking_lot::Mutex; | |||
| 7 | 7 | use synckit_client::SyncKitClient; | |
| 8 | 8 | use tokio::sync::mpsc; | |
| 9 | 9 | ||
| 10 | + | use tracing::instrument; | |
| 11 | + | ||
| 10 | 12 | use crate::error::SyncError; | |
| 11 | 13 | use crate::service; | |
| 12 | 14 | use crate::SyncStatus; | |
| @@ -22,6 +24,7 @@ pub enum SyncCommand { | |||
| 22 | 24 | /// Run the background sync scheduler loop. | |
| 23 | 25 | /// | |
| 24 | 26 | /// Uses `db_path` to open short-lived connections inside `spawn_blocking`. | |
| 27 | + | #[instrument(skip_all)] | |
| 25 | 28 | pub async fn run_scheduler( | |
| 26 | 29 | client: Arc<SyncKitClient>, | |
| 27 | 30 | db_path: PathBuf, | |
| @@ -107,7 +110,9 @@ pub async fn run_scheduler( | |||
| 107 | 110 | ||
| 108 | 111 | /// Check if auto-sync is enabled and the client is ready (authenticated + has key). | |
| 109 | 112 | fn should_auto_sync(db_path: &std::path::Path, client: &SyncKitClient) -> bool { | |
| 110 | - | if client.session_info().is_none() || !client.has_master_key() { | |
| 113 | + | if client.session_info().ok().and_then(|o| o).is_none() | |
| 114 | + | || !client.has_master_key().unwrap_or(false) | |
| 115 | + | { | |
| 111 | 116 | return false; | |
| 112 | 117 | } | |
| 113 | 118 | let conn = match rusqlite::Connection::open(db_path) { | |
| @@ -142,6 +147,7 @@ fn interval_elapsed(db_path: &std::path::Path) -> bool { | |||
| 142 | 147 | } | |
| 143 | 148 | ||
| 144 | 149 | /// Run a single sync cycle: snapshot if needed, sync, blob sync, cleanup. | |
| 150 | + | #[instrument(skip_all)] | |
| 145 | 151 | async fn run_sync_cycle( | |
| 146 | 152 | db_path: &Path, | |
| 147 | 153 | content_dir: &Path, |
| @@ -9,6 +9,8 @@ use rusqlite::Connection; | |||
| 9 | 9 | use synckit_client::{ChangeEntry, ChangeOp, SyncKitClient}; | |
| 10 | 10 | use uuid::Uuid; | |
| 11 | 11 | ||
| 12 | + | use tracing::instrument; | |
| 13 | + | ||
| 12 | 14 | use crate::error::{Result, SyncError}; | |
| 13 | 15 | ||
| 14 | 16 | const PUSH_BATCH_LIMIT: usize = 500; | |
| @@ -92,6 +94,7 @@ fn pk_columns(table: &str) -> &'static [&'static str] { | |||
| 92 | 94 | /// | |
| 93 | 95 | /// The `db_path` is used to open separate connections inside `spawn_blocking`, | |
| 94 | 96 | /// avoiding the `Connection` is `!Send` issue. | |
| 97 | + | #[instrument(skip_all)] | |
| 95 | 98 | pub async fn perform_sync( | |
| 96 | 99 | db_path: &std::path::Path, | |
| 97 | 100 | client: &SyncKitClient, | |
| @@ -125,6 +128,7 @@ pub async fn perform_sync( | |||
| 125 | 128 | /// | |
| 126 | 129 | /// Finds samples that have local files (cloud_only = 0) in sync-enabled VFS | |
| 127 | 130 | /// entries, and uploads them to the server via presigned URLs. | |
| 131 | + | #[instrument(skip_all)] | |
| 128 | 132 | pub async fn upload_pending_blobs( | |
| 129 | 133 | db_path: &Path, | |
| 130 | 134 | content_dir: &Path, | |
| @@ -191,6 +195,7 @@ pub async fn upload_pending_blobs( | |||
| 191 | 195 | /// | |
| 192 | 196 | /// For samples in sync-enabled VFS entries where the local file is missing, | |
| 193 | 197 | /// downloads from the server and writes to the content directory. | |
| 198 | + | #[instrument(skip_all)] | |
| 194 | 199 | pub async fn download_missing_blobs( | |
| 195 | 200 | db_path: &Path, | |
| 196 | 201 | content_dir: &Path, | |
| @@ -306,6 +311,7 @@ fn open_conn(db_path: &std::path::Path) -> Result<Connection> { | |||
| 306 | 311 | } | |
| 307 | 312 | ||
| 308 | 313 | /// Ensure this device is registered with the server. Caches device_id in sync_state. | |
| 314 | + | #[instrument(skip_all)] | |
| 309 | 315 | async fn ensure_device_registered( | |
| 310 | 316 | db_path: &std::path::Path, | |
| 311 | 317 | client: &SyncKitClient, | |
| @@ -347,6 +353,7 @@ async fn ensure_device_registered( | |||
| 347 | 353 | } | |
| 348 | 354 | ||
| 349 | 355 | /// Push unpushed changelog entries to the server. | |
| 356 | + | #[instrument(skip_all)] | |
| 350 | 357 | async fn push_changes( | |
| 351 | 358 | db_path: &std::path::Path, | |
| 352 | 359 | client: &SyncKitClient, | |
| @@ -435,6 +442,7 @@ async fn push_changes( | |||
| 435 | 442 | } | |
| 436 | 443 | ||
| 437 | 444 | /// Pull remote changes from the server and apply them locally. | |
| 445 | + | #[instrument(skip_all)] | |
| 438 | 446 | async fn pull_changes( | |
| 439 | 447 | db_path: &std::path::Path, | |
| 440 | 448 | client: &SyncKitClient, |