| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
|
| 7 |
use std::path::{Path, PathBuf}; |
| 8 |
|
| 9 |
use tracing::instrument; |
| 10 |
|
| 11 |
use audiofiles_core::analysis::AnalysisResult; |
| 12 |
use audiofiles_core::analysis::config::AnalysisConfig; |
| 13 |
use audiofiles_core::analysis::waveform::WaveformData; |
| 14 |
use audiofiles_core::collections::Collection; |
| 15 |
use audiofiles_core::db::Database; |
| 16 |
use audiofiles_core::edit::EditOperation; |
| 17 |
use audiofiles_core::edit::worker::{EditCommand, EditEvent, EditWorkerHandle}; |
| 18 |
use audiofiles_core::export::ExportItem; |
| 19 |
use audiofiles_core::export::profile::DeviceProfileSummary; |
| 20 |
use audiofiles_core::forge::{ChopMethod, ConformResult, ConformTarget}; |
| 21 |
use audiofiles_core::search::SearchFilter; |
| 22 |
use audiofiles_core::store::SampleStore; |
| 23 |
use audiofiles_core::vfs::{self, Vfs, VfsNode, VfsNodeWithAnalysis}; |
| 24 |
use audiofiles_core::{CollectionId, NodeId, VfsId, collections, search, tags}; |
| 25 |
|
| 26 |
use super::search_worker::{self, SearchCommand, SearchEvent}; |
| 27 |
use parking_lot::Mutex; |
| 28 |
|
| 29 |
use super::{ |
| 30 |
AnalysisBackend, Backend, BackendError, BackendEvent, BackendResult, ClassifierBackend, |
| 31 |
CollectionBackend, ConfigBackend, ExportBackend, ExportConfigDesc, ExportItemDesc, |
| 32 |
ForgeBackend, ImportStrategyDesc, ImportedFolderDesc, OperationsBackend, RulesBackend, |
| 33 |
SearchBackend, SimilarityBackend, StoreBackend, TagBackend, TrainedHeadInfo, VfsBackend, |
| 34 |
}; |
| 35 |
|
| 36 |
use super::forge::{ForgeImportOutcome, ForgePending, forge_import_chop, forge_import_conform}; |
| 37 |
|
| 38 |
use crate::cleanup::{CleanupCommand, CleanupHandle}; |
| 39 |
use crate::export::{ExportCommand, ExportHandle}; |
| 40 |
use crate::import::{ImportCommand, ImportEvent, ImportHandle, ImportStrategy}; |
| 41 |
|
| 42 |
use audiofiles_core::analysis::worker::{WorkerCommand, WorkerEvent, WorkerHandle}; |
| 43 |
use audiofiles_core::forge::{ForgeCommand, ForgeEvent}; |
| 44 |
|
| 45 |
|
| 46 |
|
| 47 |
fn resolve_import_strategy(strategy: ImportStrategyDesc) -> ImportStrategy { |
| 48 |
match strategy { |
| 49 |
ImportStrategyDesc::Flat { vfs_id, parent_id } => { |
| 50 |
ImportStrategy::Flat { vfs_id, parent_id } |
| 51 |
} |
| 52 |
ImportStrategyDesc::NewVfs { vfs_name } => ImportStrategy::NewVfs { vfs_name }, |
| 53 |
ImportStrategyDesc::MergeIntoVfs { vfs_id, parent_id } => { |
| 54 |
ImportStrategy::MergeIntoVfs { vfs_id, parent_id } |
| 55 |
} |
| 56 |
} |
| 57 |
} |
| 58 |
|
| 59 |
|
| 60 |
pub struct DirectBackend { |
| 61 |
db: Mutex<Database>, |
| 62 |
store: SampleStore, |
| 63 |
data_dir: PathBuf, |
| 64 |
|
| 65 |
import_worker: Mutex<Option<ImportHandle>>, |
| 66 |
analysis_worker: Mutex<Option<WorkerHandle>>, |
| 67 |
export_worker: Mutex<Option<ExportHandle>>, |
| 68 |
cleanup_worker: Mutex<Option<CleanupHandle>>, |
| 69 |
loose_files_worker: Mutex<Option<crate::loose_files_worker::LooseFilesHandle>>, |
| 70 |
edit_worker: Mutex<Option<EditWorkerHandle>>, |
| 71 |
forge_worker: Mutex<Option<audiofiles_core::forge::ForgeWorkerHandle>>, |
| 72 |
classifier_worker: Mutex<Option<crate::classifier_worker::ClassifierHandle>>, |
| 73 |
|
| 74 |
|
| 75 |
forge_pending: Mutex<Option<ForgePending>>, |
| 76 |
|
| 77 |
|
| 78 |
forge_import_job: Mutex<Option<audiofiles_core::worker_runtime::JobHandle<ForgeImportOutcome>>>, |
| 79 |
|
| 80 |
|
| 81 |
|
| 82 |
search_worker: Mutex<Option<search_worker::SearchWorkerHandle>>, |
| 83 |
|
| 84 |
#[cfg(feature = "device-profiles")] |
| 85 |
plugin_registry: audiofiles_rhai::registry::PluginRegistry, |
| 86 |
} |
| 87 |
|
| 88 |
impl DirectBackend { |
| 89 |
|
| 90 |
pub fn new(db: Database, store: SampleStore, data_dir: PathBuf) -> Self { |
| 91 |
|
| 92 |
|
| 93 |
|
| 94 |
|
| 95 |
match store.sweep_expired_tombstones(&db) { |
| 96 |
Ok(0) => {} |
| 97 |
Ok(n) => tracing::info!("tombstone sweep: hard-deleted {n} expired sample(s)"), |
| 98 |
Err(e) => tracing::warn!("tombstone sweep failed at startup: {e}"), |
| 99 |
} |
| 100 |
Self { |
| 101 |
db: Mutex::new(db), |
| 102 |
store, |
| 103 |
data_dir, |
| 104 |
import_worker: Mutex::new(None), |
| 105 |
analysis_worker: Mutex::new(None), |
| 106 |
export_worker: Mutex::new(None), |
| 107 |
cleanup_worker: Mutex::new(None), |
| 108 |
loose_files_worker: Mutex::new(None), |
| 109 |
edit_worker: Mutex::new(None), |
| 110 |
forge_worker: Mutex::new(None), |
| 111 |
classifier_worker: Mutex::new(None), |
| 112 |
forge_pending: Mutex::new(None), |
| 113 |
forge_import_job: Mutex::new(None), |
| 114 |
search_worker: Mutex::new(None), |
| 115 |
#[cfg(feature = "device-profiles")] |
| 116 |
plugin_registry: audiofiles_rhai::create_registry().unwrap_or_else(|e| { |
| 117 |
|
| 118 |
|
| 119 |
|
| 120 |
|
| 121 |
tracing::error!( |
| 122 |
"Failed to load device-profile registry, falling back to empty: {e}" |
| 123 |
); |
| 124 |
audiofiles_rhai::registry::PluginRegistry::new() |
| 125 |
}), |
| 126 |
} |
| 127 |
} |
| 128 |
|
| 129 |
|
| 130 |
pub fn store(&self) -> &SampleStore { |
| 131 |
&self.store |
| 132 |
} |
| 133 |
|
| 134 |
|
| 135 |
pub fn data_dir(&self) -> &Path { |
| 136 |
&self.data_dir |
| 137 |
} |
| 138 |
|
| 139 |
|
| 140 |
|
| 141 |
fn loose_files_send( |
| 142 |
&self, |
| 143 |
cmd: crate::loose_files_worker::LooseFilesCommand, |
| 144 |
) -> BackendResult<()> { |
| 145 |
let mut guard = self.loose_files_worker.lock(); |
| 146 |
if guard.is_none() { |
| 147 |
let db_path = self.data_dir.join("audiofiles.db"); |
| 148 |
let handle = crate::loose_files_worker::spawn_loose_files_worker( |
| 149 |
db_path, |
| 150 |
self.store.root().to_path_buf(), |
| 151 |
) |
| 152 |
.map_err(|e| BackendError::Other(format!("failed to spawn loose-files worker: {e}")))?; |
| 153 |
*guard = Some(handle); |
| 154 |
} |
| 155 |
|
| 156 |
|
| 157 |
|
| 158 |
|
| 159 |
if !guard |
| 160 |
.as_ref() |
| 161 |
.expect("loose-files worker present") |
| 162 |
.send(cmd) |
| 163 |
{ |
| 164 |
*guard = None; |
| 165 |
return Err(BackendError::Other( |
| 166 |
"loose-files worker is not running".to_string(), |
| 167 |
)); |
| 168 |
} |
| 169 |
Ok(()) |
| 170 |
} |
| 171 |
|
| 172 |
|
| 173 |
|
| 174 |
|
| 175 |
fn respawn_import_worker(&self) -> BackendResult<ImportHandle> { |
| 176 |
let db_path = self.data_dir.join("audiofiles.db"); |
| 177 |
let store_root = self.store.root().to_path_buf(); |
| 178 |
if let Some(old) = self.import_worker.lock().take() { |
| 179 |
old.send(ImportCommand::Cancel); |
| 180 |
drop(old); |
| 181 |
} |
| 182 |
crate::import::spawn_import_worker(db_path, store_root) |
| 183 |
.map_err(|e| BackendError::Other(format!("failed to spawn import worker: {e}"))) |
| 184 |
} |
| 185 |
|
| 186 |
|
| 187 |
|
| 188 |
fn ensure_search_worker(&self) -> BackendResult<()> { |
| 189 |
let mut guard = self.search_worker.lock(); |
| 190 |
if guard.is_none() { |
| 191 |
let db_path = self.data_dir.join("audiofiles.db"); |
| 192 |
let handle = search_worker::spawn_search_worker(db_path) |
| 193 |
.map_err(|e| BackendError::Other(format!("failed to spawn search worker: {e}")))?; |
| 194 |
*guard = Some(handle); |
| 195 |
} |
| 196 |
Ok(()) |
| 197 |
} |
| 198 |
|
| 199 |
|
| 200 |
|
| 201 |
|
| 202 |
|
| 203 |
#[cfg(feature = "device-profiles")] |
| 204 |
#[instrument(skip_all)] |
| 205 |
fn resolve_device_profile( |
| 206 |
&self, |
| 207 |
config: &mut audiofiles_core::export::ExportConfig, |
| 208 |
items: &mut Vec<ExportItem>, |
| 209 |
) { |
| 210 |
use audiofiles_core::export::profile::ChannelConstraint; |
| 211 |
use audiofiles_core::export::{ExportChannels, ExportFormat}; |
| 212 |
|
| 213 |
let profile_name = match config.device_profile { |
| 214 |
Some(ref name) => name.clone(), |
| 215 |
None => return, |
| 216 |
}; |
| 217 |
|
| 218 |
let Some(plugin) = self.plugin_registry.get(&profile_name) else { |
| 219 |
return; |
| 220 |
}; |
| 221 |
|
| 222 |
let profile = &plugin.profile; |
| 223 |
|
| 224 |
|
| 225 |
if config.format == ExportFormat::Original |
| 226 |
&& let Some(fmt) = profile.audio.formats.first() |
| 227 |
{ |
| 228 |
config.format = fmt.clone(); |
| 229 |
} |
| 230 |
|
| 231 |
|
| 232 |
if config.sample_rate.is_none() { |
| 233 |
config.sample_rate = profile.audio.sample_rates.first().copied(); |
| 234 |
} |
| 235 |
|
| 236 |
|
| 237 |
if config.bit_depth.is_none() { |
| 238 |
config.bit_depth = profile.audio.bit_depths.first().copied(); |
| 239 |
} |
| 240 |
|
| 241 |
|
| 242 |
match profile.audio.channels { |
| 243 |
ChannelConstraint::Mono => config.channels = ExportChannels::Mono, |
| 244 |
ChannelConstraint::Stereo => config.channels = ExportChannels::Stereo, |
| 245 |
ChannelConstraint::Both => {} |
| 246 |
} |
| 247 |
|
| 248 |
|
| 249 |
config.naming_rules.clone_from(&profile.naming); |
| 250 |
|
| 251 |
|
| 252 |
config.max_file_size_bytes = profile.limits.as_ref().and_then(|l| l.max_file_size_bytes); |
| 253 |
|
| 254 |
|
| 255 |
if let Some(ref ast) = plugin.hooks.validate_sample { |
| 256 |
|
| 257 |
|
| 258 |
let mut infos: Vec<_> = { |
| 259 |
let db = self.db.lock(); |
| 260 |
items |
| 261 |
.iter() |
| 262 |
.map(|item| super::sample_info::build_sample_info(&db, &self.store, item)) |
| 263 |
.collect() |
| 264 |
}; |
| 265 |
|
| 266 |
|
| 267 |
|
| 268 |
{ |
| 269 |
use rayon::prelude::*; |
| 270 |
let paths: Vec<Option<std::path::PathBuf>> = items |
| 271 |
.iter() |
| 272 |
.map(|item| self.store.sample_path(&item.hash, &item.ext).ok()) |
| 273 |
.collect(); |
| 274 |
let depths: Vec<u16> = paths |
| 275 |
.into_par_iter() |
| 276 |
.map(|p| { |
| 277 |
p.and_then(|p| super::sample_info::probe_bit_depth(&p)) |
| 278 |
.unwrap_or(0) |
| 279 |
}) |
| 280 |
.collect(); |
| 281 |
for (info, depth) in infos.iter_mut().zip(depths) { |
| 282 |
info.bit_depth = depth; |
| 283 |
} |
| 284 |
} |
| 285 |
let engine = self.plugin_registry.engine(); |
| 286 |
let mut keep = vec![true; items.len()]; |
| 287 |
for (i, info) in infos.into_iter().enumerate() { |
| 288 |
keep[i] = match audiofiles_rhai::hooks::run_validate_sample(engine, ast, info) { |
| 289 |
|
| 290 |
Ok(decision) => decision, |
| 291 |
|
| 292 |
|
| 293 |
|
| 294 |
|
| 295 |
Err(e) => { |
| 296 |
tracing::error!( |
| 297 |
profile = %profile_name, |
| 298 |
error = %e, |
| 299 |
"device-profile validate_sample script errored; keeping sample in export rather than dropping it silently" |
| 300 |
); |
| 301 |
true |
| 302 |
} |
| 303 |
}; |
| 304 |
} |
| 305 |
let mut ki = 0; |
| 306 |
items.retain(|_| { |
| 307 |
let k = keep[ki]; |
| 308 |
ki += 1; |
| 309 |
k |
| 310 |
}); |
| 311 |
} |
| 312 |
|
| 313 |
|
| 314 |
if let Some(ref ast) = plugin.hooks.transform_filename { |
| 315 |
let pattern = config |
| 316 |
.naming_pattern |
| 317 |
.as_ref() |
| 318 |
.and_then(|p| audiofiles_core::rename::RenamePattern::parse(p).ok()); |
| 319 |
|
| 320 |
let mut names = |
| 321 |
audiofiles_core::export::resolve_output_names(items, config, pattern.as_ref()); |
| 322 |
|
| 323 |
let device_name = profile.name.clone(); |
| 324 |
let destination = config.destination.display().to_string(); |
| 325 |
let total = names.len() as i64; |
| 326 |
|
| 327 |
for (i, name) in names.iter_mut().enumerate() { |
| 328 |
let (stem, ext) = split_name_ext(name); |
| 329 |
let ctx = audiofiles_rhai::types::RhaiExportContext { |
| 330 |
device_name: device_name.clone(), |
| 331 |
destination: destination.clone(), |
| 332 |
filename: stem.clone(), |
| 333 |
extension: ext.clone(), |
| 334 |
index: i as i64, |
| 335 |
total, |
| 336 |
}; |
| 337 |
if let Ok(new_stem) = audiofiles_rhai::hooks::run_transform_filename( |
| 338 |
self.plugin_registry.engine(), |
| 339 |
ast, |
| 340 |
stem, |
| 341 |
ctx, |
| 342 |
) { |
| 343 |
|
| 344 |
|
| 345 |
|
| 346 |
let safe_stem = new_stem.replace(['/', '\\', '\0'], "_"); |
| 347 |
let safe_stem = if safe_stem.is_empty() || safe_stem == ".." || safe_stem == "." |
| 348 |
{ |
| 349 |
"untitled".to_string() |
| 350 |
} else { |
| 351 |
safe_stem |
| 352 |
}; |
| 353 |
*name = if ext.is_empty() { |
| 354 |
safe_stem |
| 355 |
} else { |
| 356 |
format!("{safe_stem}.{ext}") |
| 357 |
}; |
| 358 |
} |
| 359 |
} |
| 360 |
|
| 361 |
config.name_overrides = Some(names); |
| 362 |
} |
| 363 |
} |
| 364 |
} |
| 365 |
|
| 366 |
#[cfg(feature = "device-profiles")] |
| 367 |
use audiofiles_core::util::split_name_ext; |
| 368 |
|
| 369 |
impl Backend for DirectBackend {} |
| 370 |
|
| 371 |
mod analysis; |
| 372 |
mod classify; |
| 373 |
mod forge; |
| 374 |
mod library; |
| 375 |
mod operations; |
| 376 |
mod store; |
| 377 |
|
| 378 |
#[cfg(test)] |
| 379 |
mod tests; |
| 380 |
|