max / audiofiles
6 files changed,
+74 insertions,
-27 deletions
| @@ -10,6 +10,7 @@ use std::sync::Arc; | |||
| 10 | 10 | ||
| 11 | 11 | use parking_lot::Mutex; | |
| 12 | 12 | use serde::{Deserialize, Serialize}; | |
| 13 | + | use thiserror::Error; | |
| 13 | 14 | ||
| 14 | 15 | /// Cached license data, persisted to `license.json`. | |
| 15 | 16 | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| @@ -205,12 +206,27 @@ pub async fn activate_key(server_url: &str, key: &str, machine_id: &str) -> Resu | |||
| 205 | 206 | } | |
| 206 | 207 | } | |
| 207 | 208 | ||
| 209 | + | /// Best-effort deactivation failure. The caller logs; no user-facing copy. | |
| 210 | + | #[derive(Error, Debug)] | |
| 211 | + | pub enum DeactivationError { | |
| 212 | + | #[error("HTTP client error: {0}")] | |
| 213 | + | ClientBuild(reqwest::Error), | |
| 214 | + | #[error("Network error: {0}")] | |
| 215 | + | Network(reqwest::Error), | |
| 216 | + | #[error("Server returned {0}")] | |
| 217 | + | Server(reqwest::StatusCode), | |
| 218 | + | #[error("Invalid response: {0}")] | |
| 219 | + | InvalidResponse(reqwest::Error), | |
| 220 | + | #[error("Server rejected deactivation: {0}")] | |
| 221 | + | Rejected(String), | |
| 222 | + | } | |
| 223 | + | ||
| 208 | 224 | /// Deactivate a license key (best-effort, fire-and-forget). | |
| 209 | - | pub async fn deactivate_key(server_url: &str, key: &str, machine_id: &str) -> Result<(), String> { | |
| 225 | + | pub async fn deactivate_key(server_url: &str, key: &str, machine_id: &str) -> Result<(), DeactivationError> { | |
| 210 | 226 | let client = reqwest::Client::builder() | |
| 211 | 227 | .timeout(std::time::Duration::from_secs(15)) | |
| 212 | 228 | .build() | |
| 213 | - | .map_err(|e| format!("HTTP client error: {e}"))?; | |
| 229 | + | .map_err(DeactivationError::ClientBuild)?; | |
| 214 | 230 | ||
| 215 | 231 | let url = format!("{server_url}/api/keys/deactivate"); | |
| 216 | 232 | let body = DeactivateRequest { key, machine_id }; | |
| @@ -220,21 +236,21 @@ pub async fn deactivate_key(server_url: &str, key: &str, machine_id: &str) -> Re | |||
| 220 | 236 | .json(&body) | |
| 221 | 237 | .send() | |
| 222 | 238 | .await | |
| 223 | - | .map_err(|e| format!("Network error: {e}"))?; | |
| 239 | + | .map_err(DeactivationError::Network)?; | |
| 224 | 240 | ||
| 225 | 241 | if !resp.status().is_success() { | |
| 226 | - | return Err(format!("Server returned {}", resp.status())); | |
| 242 | + | return Err(DeactivationError::Server(resp.status())); | |
| 227 | 243 | } | |
| 228 | 244 | ||
| 229 | 245 | let parsed: DeactivateResponse = resp | |
| 230 | 246 | .json() | |
| 231 | 247 | .await | |
| 232 | - | .map_err(|e| format!("Invalid response: {e}"))?; | |
| 248 | + | .map_err(DeactivationError::InvalidResponse)?; | |
| 233 | 249 | ||
| 234 | 250 | if parsed.success { | |
| 235 | 251 | Ok(()) | |
| 236 | 252 | } else { | |
| 237 | - | Err(parsed.message) | |
| 253 | + | Err(DeactivationError::Rejected(parsed.message)) | |
| 238 | 254 | } | |
| 239 | 255 | } | |
| 240 | 256 |
| @@ -5,7 +5,8 @@ use std::time::Instant; | |||
| 5 | 5 | ||
| 6 | 6 | use audiofiles_browser::state::{MidiNoteEvent, SharedState}; | |
| 7 | 7 | use audiofiles_core::instrument::note_name; | |
| 8 | - | use midir::{MidiInput, MidiInputConnection}; | |
| 8 | + | use midir::{ConnectError, InitError, MidiInput, MidiInputConnection}; | |
| 9 | + | use thiserror::Error; | |
| 9 | 10 | use tracing::instrument; | |
| 10 | 11 | ||
| 11 | 12 | /// An active MIDI input connection. Dropping this disconnects. | |
| @@ -13,6 +14,17 @@ pub struct MidiConnection { | |||
| 13 | 14 | _conn: MidiInputConnection<()>, | |
| 14 | 15 | } | |
| 15 | 16 | ||
| 17 | + | /// Errors from MIDI port enumeration and connection. | |
| 18 | + | #[derive(Error, Debug)] | |
| 19 | + | pub enum MidiError { | |
| 20 | + | #[error("MIDI init: {0}")] | |
| 21 | + | Init(#[from] InitError), | |
| 22 | + | #[error("MIDI port index {idx} out of range ({count} ports available)")] | |
| 23 | + | PortOutOfRange { idx: usize, count: usize }, | |
| 24 | + | #[error("MIDI connect: {0}")] | |
| 25 | + | Connect(#[from] ConnectError<MidiInput>), | |
| 26 | + | } | |
| 27 | + | ||
| 16 | 28 | /// List available MIDI input port names. | |
| 17 | 29 | #[instrument(skip_all)] | |
| 18 | 30 | pub fn list_input_ports() -> Vec<String> { | |
| @@ -32,13 +44,12 @@ pub fn list_input_ports() -> Vec<String> { | |||
| 32 | 44 | /// on the instrument directly (low latency). Also pushes `MidiNoteEvent`s to | |
| 33 | 45 | /// `shared.midi_recent_notes` for the GUI activity display. | |
| 34 | 46 | #[instrument(skip_all)] | |
| 35 | - | pub fn connect(port_index: usize, shared: Arc<SharedState>) -> Result<MidiConnection, String> { | |
| 36 | - | let midi_in = MidiInput::new("audiofiles-input") | |
| 37 | - | .map_err(|e| format!("MIDI init: {e}"))?; | |
| 47 | + | pub fn connect(port_index: usize, shared: Arc<SharedState>) -> Result<MidiConnection, MidiError> { | |
| 48 | + | let midi_in = MidiInput::new("audiofiles-input")?; | |
| 38 | 49 | let ports = midi_in.ports(); | |
| 39 | 50 | let port = ports | |
| 40 | 51 | .get(port_index) | |
| 41 | - | .ok_or_else(|| format!("MIDI port index {port_index} out of range"))?; | |
| 52 | + | .ok_or(MidiError::PortOutOfRange { idx: port_index, count: ports.len() })?; | |
| 42 | 53 | ||
| 43 | 54 | let shared_cb = shared.clone(); | |
| 44 | 55 | let conn = midi_in | |
| @@ -72,8 +83,7 @@ pub fn connect(port_index: usize, shared: Arc<SharedState>) -> Result<MidiConnec | |||
| 72 | 83 | } | |
| 73 | 84 | }, | |
| 74 | 85 | (), | |
| 75 | - | ) | |
| 76 | - | .map_err(|e| format!("MIDI connect: {e}"))?; | |
| 86 | + | )?; | |
| 77 | 87 | ||
| 78 | 88 | Ok(MidiConnection { _conn: conn }) | |
| 79 | 89 | } |
| @@ -564,11 +564,12 @@ impl Backend for DirectBackend { | |||
| 564 | 564 | }; | |
| 565 | 565 | *idx = Some(similarity::SimilarityIndex::build_from_data(data)); | |
| 566 | 566 | } | |
| 567 | + | let built = idx.as_ref().expect("set on the line above"); | |
| 567 | 568 | let features = { | |
| 568 | 569 | let db = self.db.lock(); | |
| 569 | 570 | similarity::load_features(&db, hash)? | |
| 570 | 571 | }; | |
| 571 | - | Ok(idx.as_ref().unwrap().find_similar(hash, &features, limit)) | |
| 572 | + | Ok(built.find_similar(hash, &features, limit)) | |
| 572 | 573 | } | |
| 573 | 574 | ||
| 574 | 575 | fn find_near_duplicates( | |
| @@ -586,14 +587,12 @@ impl Backend for DirectBackend { | |||
| 586 | 587 | }; | |
| 587 | 588 | *idx = Some(fingerprint::FingerprintIndex::build_from_data(entries)); | |
| 588 | 589 | } | |
| 590 | + | let built = idx.as_ref().expect("set on the line above"); | |
| 589 | 591 | let reference = { | |
| 590 | 592 | let db = self.db.lock(); | |
| 591 | 593 | fingerprint::load_fingerprint(&db, hash)? | |
| 592 | 594 | }; | |
| 593 | - | Ok(idx | |
| 594 | - | .as_ref() | |
| 595 | - | .unwrap() | |
| 596 | - | .find_near_duplicates(hash, &reference.envelope, limit)) | |
| 595 | + | Ok(built.find_near_duplicates(hash, &reference.envelope, limit)) | |
| 597 | 596 | } | |
| 598 | 597 | ||
| 599 | 598 | // --- Store --- |
| @@ -23,6 +23,10 @@ pub enum PreviewError { | |||
| 23 | 23 | Decode(String), | |
| 24 | 24 | #[error("no audio data decoded")] | |
| 25 | 25 | NoData, | |
| 26 | + | #[error("invalid hash: {0}")] | |
| 27 | + | InvalidHash(String), | |
| 28 | + | #[error("file not found: {0}")] | |
| 29 | + | FileNotFound(PathBuf), | |
| 26 | 30 | } | |
| 27 | 31 | ||
| 28 | 32 | /// Errors from theme file loading. |
| @@ -6,20 +6,20 @@ impl BrowserState { | |||
| 6 | 6 | // --- Sample resolution --- | |
| 7 | 7 | ||
| 8 | 8 | /// Resolve a sample hash to its filesystem path via the backend. | |
| 9 | - | pub fn resolve_sample_path(&self, hash: &str) -> Result<PathBuf, String> { | |
| 9 | + | pub fn resolve_sample_path(&self, hash: &str) -> Result<PathBuf, crate::error::PreviewError> { | |
| 10 | 10 | let ext = self.backend.sample_extension(hash).unwrap_or_default(); | |
| 11 | 11 | let path = self.backend.sample_path(hash, &ext) | |
| 12 | - | .map_err(|e| format!("Invalid hash: {e}"))?; | |
| 12 | + | .map_err(|e| crate::error::PreviewError::InvalidHash(e.to_string()))?; | |
| 13 | 13 | if !path.exists() { | |
| 14 | - | return Err(format!("File not found: {}", path.display())); | |
| 14 | + | return Err(crate::error::PreviewError::FileNotFound(path)); | |
| 15 | 15 | } | |
| 16 | 16 | Ok(path) | |
| 17 | 17 | } | |
| 18 | 18 | ||
| 19 | 19 | /// Resolve a sample hash and decode it to an interleaved stereo f32 buffer. | |
| 20 | - | pub fn resolve_and_decode(&self, hash: &str) -> Result<crate::preview::PreviewBuffer, String> { | |
| 20 | + | pub fn resolve_and_decode(&self, hash: &str) -> Result<crate::preview::PreviewBuffer, crate::error::PreviewError> { | |
| 21 | 21 | let path = self.resolve_sample_path(hash)?; | |
| 22 | - | crate::preview::decode_to_f32(&path).map_err(|e| format!("Decode error: {e}")) | |
| 22 | + | crate::preview::decode_to_f32(&path) | |
| 23 | 23 | } | |
| 24 | 24 | ||
| 25 | 25 | // --- Preview --- | |
| @@ -33,7 +33,7 @@ impl BrowserState { | |||
| 33 | 33 | let path = match self.resolve_sample_path(hash) { | |
| 34 | 34 | Ok(p) => p, | |
| 35 | 35 | Err(e) => { | |
| 36 | - | self.status = e; | |
| 36 | + | self.status = e.to_string(); | |
| 37 | 37 | self.previewing_hash = None; | |
| 38 | 38 | return; | |
| 39 | 39 | } | |
| @@ -141,7 +141,7 @@ impl BrowserState { | |||
| 141 | 141 | let buf = match self.resolve_and_decode(hash) { | |
| 142 | 142 | Ok(b) => b, | |
| 143 | 143 | Err(e) => { | |
| 144 | - | self.status = e; | |
| 144 | + | self.status = e.to_string(); | |
| 145 | 145 | return; | |
| 146 | 146 | } | |
| 147 | 147 | }; | |
| @@ -195,7 +195,7 @@ impl BrowserState { | |||
| 195 | 195 | let buf = match self.resolve_and_decode(hash) { | |
| 196 | 196 | Ok(b) => b, | |
| 197 | 197 | Err(e) => { | |
| 198 | - | self.status = e; | |
| 198 | + | self.status = e.to_string(); | |
| 199 | 199 | return; | |
| 200 | 200 | } | |
| 201 | 201 | }; |
| @@ -3,7 +3,16 @@ | |||
| 3 | 3 | use std::path::Path; | |
| 4 | 4 | ||
| 5 | 5 | /// Audio file extensions recognised throughout audiofiles. | |
| 6 | - | pub const AUDIO_EXTENSIONS: &[&str] = &["wav", "flac", "mp3", "ogg", "aiff", "aif"]; | |
| 6 | + | /// | |
| 7 | + | /// `bwf` is WAV with a `bext` chunk; symphonia decodes it as WAV. | |
| 8 | + | /// `m4a`/`alac` route through symphonia's `isomp4` format with `aac`/`alac` | |
| 9 | + | /// codecs. `caf` uses the `caf` format with `pcm`/`alac`. | |
| 10 | + | /// | |
| 11 | + | /// Missing on purpose: `opus` (no symphonia codec), `w64` (Wave64; no | |
| 12 | + | /// symphonia format). Add when upstream support lands or we swap decoders. | |
| 13 | + | pub const AUDIO_EXTENSIONS: &[&str] = &[ | |
| 14 | + | "wav", "flac", "mp3", "ogg", "aiff", "aif", "m4a", "alac", "caf", "bwf", | |
| 15 | + | ]; | |
| 7 | 16 | ||
| 8 | 17 | /// Get the lowercase file extension, or empty string if none. | |
| 9 | 18 | pub fn get_extension(path: &Path) -> String { | |
| @@ -146,6 +155,15 @@ mod tests { | |||
| 146 | 155 | } | |
| 147 | 156 | ||
| 148 | 157 | #[test] | |
| 158 | + | fn is_audio_file_extended_formats() { | |
| 159 | + | assert!(is_audio_file(Path::new("vocal.m4a"))); | |
| 160 | + | assert!(is_audio_file(Path::new("loop.alac"))); | |
| 161 | + | assert!(is_audio_file(Path::new("snare.caf"))); | |
| 162 | + | assert!(is_audio_file(Path::new("field.bwf"))); | |
| 163 | + | assert!(is_audio_file(Path::new("FIELD.BWF"))); | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | #[test] | |
| 149 | 167 | fn is_audio_file_txt_rejected() { | |
| 150 | 168 | assert!(!is_audio_file(Path::new("readme.txt"))); | |
| 151 | 169 | } |