| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
|
| 7 |
|
| 8 |
|
| 9 |
|
| 10 |
|
| 11 |
|
| 12 |
|
| 13 |
use std::sync::atomic::{AtomicU64, Ordering}; |
| 14 |
|
| 15 |
use serde::{Deserialize, Serialize}; |
| 16 |
use tracing::instrument; |
| 17 |
|
| 18 |
use crate::db::{Database, Tx}; |
| 19 |
use crate::error::{CoreError, Result}; |
| 20 |
use crate::tags::validate_tag; |
| 21 |
|
| 22 |
|
| 23 |
|
| 24 |
|
| 25 |
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] |
| 26 |
#[serde(rename_all = "snake_case")] |
| 27 |
pub enum MatchMode { |
| 28 |
|
| 29 |
#[default] |
| 30 |
All, |
| 31 |
|
| 32 |
Any, |
| 33 |
} |
| 34 |
|
| 35 |
|
| 36 |
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] |
| 37 |
#[serde(rename_all = "snake_case")] |
| 38 |
pub enum RuleField { |
| 39 |
|
| 40 |
Name, |
| 41 |
SourcePath, |
| 42 |
MusicalKey, |
| 43 |
FileExtension, |
| 44 |
|
| 45 |
VfsPath, |
| 46 |
Tag, |
| 47 |
|
| 48 |
Duration, |
| 49 |
Bpm, |
| 50 |
SpectralCentroid, |
| 51 |
SpectralFlatness, |
| 52 |
SpectralRolloff, |
| 53 |
Zcr, |
| 54 |
SpectralBandwidth, |
| 55 |
CentroidVariance, |
| 56 |
CrestFactor, |
| 57 |
AttackTime, |
| 58 |
PeakDb, |
| 59 |
RmsDb, |
| 60 |
Lufs, |
| 61 |
SampleRate, |
| 62 |
Channels, |
| 63 |
FileSize, |
| 64 |
|
| 65 |
IsLoop, |
| 66 |
} |
| 67 |
|
| 68 |
|
| 69 |
|
| 70 |
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] |
| 71 |
#[serde(rename_all = "snake_case")] |
| 72 |
pub enum RuleOp { |
| 73 |
|
| 74 |
Contains, |
| 75 |
NotContains, |
| 76 |
Equals, |
| 77 |
NotEquals, |
| 78 |
StartsWith, |
| 79 |
EndsWith, |
| 80 |
|
| 81 |
Lt, |
| 82 |
Le, |
| 83 |
Gt, |
| 84 |
Ge, |
| 85 |
|
| 86 |
IsTrue, |
| 87 |
IsFalse, |
| 88 |
|
| 89 |
Exists, |
| 90 |
NotExists, |
| 91 |
} |
| 92 |
|
| 93 |
|
| 94 |
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 95 |
pub struct RuleCondition { |
| 96 |
pub field: RuleField, |
| 97 |
pub op: RuleOp, |
| 98 |
|
| 99 |
|
| 100 |
#[serde(default)] |
| 101 |
pub value: String, |
| 102 |
} |
| 103 |
|
| 104 |
|
| 105 |
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 106 |
#[serde(rename_all = "snake_case", tag = "kind", content = "tag")] |
| 107 |
pub enum RuleAction { |
| 108 |
|
| 109 |
AddTag(String), |
| 110 |
|
| 111 |
|
| 112 |
RemoveTag(String), |
| 113 |
|
| 114 |
#[serde(rename = "stop")] |
| 115 |
Stop, |
| 116 |
} |
| 117 |
|
| 118 |
|
| 119 |
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 120 |
pub struct Rule { |
| 121 |
pub id: String, |
| 122 |
pub name: String, |
| 123 |
pub enabled: bool, |
| 124 |
|
| 125 |
pub priority: i64, |
| 126 |
pub match_mode: MatchMode, |
| 127 |
pub conditions: Vec<RuleCondition>, |
| 128 |
pub actions: Vec<RuleAction>, |
| 129 |
pub created_at: i64, |
| 130 |
} |
| 131 |
|
| 132 |
|
| 133 |
#[derive(Debug, Clone)] |
| 134 |
pub struct NewRule { |
| 135 |
pub name: String, |
| 136 |
pub enabled: bool, |
| 137 |
|
| 138 |
pub priority: Option<i64>, |
| 139 |
pub match_mode: MatchMode, |
| 140 |
pub conditions: Vec<RuleCondition>, |
| 141 |
pub actions: Vec<RuleAction>, |
| 142 |
} |
| 143 |
|
| 144 |
|
| 145 |
|
| 146 |
static RULE_COUNTER: AtomicU64 = AtomicU64::new(0); |
| 147 |
|
| 148 |
fn new_rule_id() -> String { |
| 149 |
use std::time::{SystemTime, UNIX_EPOCH}; |
| 150 |
let nanos = SystemTime::now() |
| 151 |
.duration_since(UNIX_EPOCH) |
| 152 |
.unwrap_or_default() |
| 153 |
.as_nanos(); |
| 154 |
let n = RULE_COUNTER.fetch_add(1, Ordering::Relaxed); |
| 155 |
format!("{nanos:x}{n:x}") |
| 156 |
} |
| 157 |
|
| 158 |
fn now_secs() -> i64 { |
| 159 |
use std::time::{SystemTime, UNIX_EPOCH}; |
| 160 |
SystemTime::now() |
| 161 |
.duration_since(UNIX_EPOCH) |
| 162 |
.map_or(0, |d| d.as_secs() as i64) |
| 163 |
} |
| 164 |
|
| 165 |
|
| 166 |
|
| 167 |
|
| 168 |
#[derive(Debug, Clone, Default)] |
| 169 |
pub struct RuleContext { |
| 170 |
pub hash: String, |
| 171 |
pub name: String, |
| 172 |
pub source_path: Option<String>, |
| 173 |
pub file_extension: Option<String>, |
| 174 |
pub file_size: Option<f64>, |
| 175 |
pub vfs_paths: Vec<String>, |
| 176 |
pub tags: Vec<String>, |
| 177 |
pub musical_key: Option<String>, |
| 178 |
pub duration: Option<f64>, |
| 179 |
pub bpm: Option<f64>, |
| 180 |
pub is_loop: Option<bool>, |
| 181 |
pub spectral_centroid: Option<f64>, |
| 182 |
pub spectral_flatness: Option<f64>, |
| 183 |
pub spectral_rolloff: Option<f64>, |
| 184 |
pub zcr: Option<f64>, |
| 185 |
pub spectral_bandwidth: Option<f64>, |
| 186 |
pub centroid_variance: Option<f64>, |
| 187 |
pub crest_factor: Option<f64>, |
| 188 |
pub attack_time: Option<f64>, |
| 189 |
pub peak_db: Option<f64>, |
| 190 |
pub rms_db: Option<f64>, |
| 191 |
pub lufs: Option<f64>, |
| 192 |
pub sample_rate: Option<f64>, |
| 193 |
pub channels: Option<f64>, |
| 194 |
} |
| 195 |
|
| 196 |
|
| 197 |
enum FieldVal<'a> { |
| 198 |
Str(Option<&'a str>), |
| 199 |
Num(Option<f64>), |
| 200 |
Bool(Option<bool>), |
| 201 |
List(&'a [String]), |
| 202 |
} |
| 203 |
|
| 204 |
impl RuleContext { |
| 205 |
fn field(&self, field: RuleField) -> FieldVal<'_> { |
| 206 |
use RuleField::{ |
| 207 |
AttackTime, Bpm, CentroidVariance, Channels, CrestFactor, Duration, FileExtension, |
| 208 |
FileSize, IsLoop, Lufs, MusicalKey, Name, PeakDb, RmsDb, SampleRate, SourcePath, |
| 209 |
SpectralBandwidth, SpectralCentroid, SpectralFlatness, SpectralRolloff, Tag, VfsPath, |
| 210 |
Zcr, |
| 211 |
}; |
| 212 |
match field { |
| 213 |
Name => FieldVal::Str(Some(self.name.as_str())), |
| 214 |
SourcePath => FieldVal::Str(self.source_path.as_deref()), |
| 215 |
MusicalKey => FieldVal::Str(self.musical_key.as_deref()), |
| 216 |
FileExtension => FieldVal::Str(self.file_extension.as_deref()), |
| 217 |
VfsPath => FieldVal::List(&self.vfs_paths), |
| 218 |
Tag => FieldVal::List(&self.tags), |
| 219 |
Duration => FieldVal::Num(self.duration), |
| 220 |
Bpm => FieldVal::Num(self.bpm), |
| 221 |
SpectralCentroid => FieldVal::Num(self.spectral_centroid), |
| 222 |
SpectralFlatness => FieldVal::Num(self.spectral_flatness), |
| 223 |
SpectralRolloff => FieldVal::Num(self.spectral_rolloff), |
| 224 |
Zcr => FieldVal::Num(self.zcr), |
| 225 |
SpectralBandwidth => FieldVal::Num(self.spectral_bandwidth), |
| 226 |
CentroidVariance => FieldVal::Num(self.centroid_variance), |
| 227 |
CrestFactor => FieldVal::Num(self.crest_factor), |
| 228 |
AttackTime => FieldVal::Num(self.attack_time), |
| 229 |
PeakDb => FieldVal::Num(self.peak_db), |
| 230 |
RmsDb => FieldVal::Num(self.rms_db), |
| 231 |
Lufs => FieldVal::Num(self.lufs), |
| 232 |
SampleRate => FieldVal::Num(self.sample_rate), |
| 233 |
Channels => FieldVal::Num(self.channels), |
| 234 |
FileSize => FieldVal::Num(self.file_size), |
| 235 |
IsLoop => FieldVal::Bool(self.is_loop), |
| 236 |
} |
| 237 |
} |
| 238 |
} |
| 239 |
|
| 240 |
|
| 241 |
|
| 242 |
|
| 243 |
|
| 244 |
fn str_op(op: RuleOp, s: &str, target: &str) -> Option<bool> { |
| 245 |
let s = s.to_lowercase(); |
| 246 |
let t = target.to_lowercase(); |
| 247 |
Some(match op { |
| 248 |
RuleOp::Contains => s.contains(&t), |
| 249 |
RuleOp::NotContains => !s.contains(&t), |
| 250 |
RuleOp::Equals => s == t, |
| 251 |
RuleOp::NotEquals => s != t, |
| 252 |
RuleOp::StartsWith => s.starts_with(&t), |
| 253 |
RuleOp::EndsWith => s.ends_with(&t), |
| 254 |
_ => return None, |
| 255 |
}) |
| 256 |
} |
| 257 |
|
| 258 |
|
| 259 |
fn is_negative_str_op(op: RuleOp) -> bool { |
| 260 |
matches!(op, RuleOp::NotContains | RuleOp::NotEquals) |
| 261 |
} |
| 262 |
|
| 263 |
fn num_op(op: RuleOp, n: f64, target: &str) -> bool { |
| 264 |
let Ok(t) = target.trim().parse::<f64>() else { |
| 265 |
return false; |
| 266 |
}; |
| 267 |
match op { |
| 268 |
RuleOp::Lt => n < t, |
| 269 |
RuleOp::Le => n <= t, |
| 270 |
RuleOp::Gt => n > t, |
| 271 |
RuleOp::Ge => n >= t, |
| 272 |
RuleOp::Equals => (n - t).abs() < f64::EPSILON.max(t.abs() * 1e-9), |
| 273 |
RuleOp::NotEquals => (n - t).abs() >= f64::EPSILON.max(t.abs() * 1e-9), |
| 274 |
_ => false, |
| 275 |
} |
| 276 |
} |
| 277 |
|
| 278 |
fn eval_condition(ctx: &RuleContext, cond: &RuleCondition) -> bool { |
| 279 |
match ctx.field(cond.field) { |
| 280 |
FieldVal::Str(opt) => match cond.op { |
| 281 |
RuleOp::Exists => opt.is_some(), |
| 282 |
RuleOp::NotExists => opt.is_none(), |
| 283 |
_ => match opt { |
| 284 |
Some(s) => str_op(cond.op, s, &cond.value).unwrap_or(false), |
| 285 |
|
| 286 |
None => is_negative_str_op(cond.op), |
| 287 |
}, |
| 288 |
}, |
| 289 |
FieldVal::Num(opt) => match cond.op { |
| 290 |
RuleOp::Exists => opt.is_some(), |
| 291 |
RuleOp::NotExists => opt.is_none(), |
| 292 |
_ => opt.is_some_and(|n| num_op(cond.op, n, &cond.value)), |
| 293 |
}, |
| 294 |
FieldVal::Bool(opt) => match cond.op { |
| 295 |
RuleOp::Exists => opt.is_some(), |
| 296 |
RuleOp::NotExists => opt.is_none(), |
| 297 |
RuleOp::IsTrue => opt == Some(true), |
| 298 |
RuleOp::IsFalse => opt == Some(false), |
| 299 |
_ => false, |
| 300 |
}, |
| 301 |
FieldVal::List(items) => match cond.op { |
| 302 |
RuleOp::Exists => !items.is_empty(), |
| 303 |
RuleOp::NotExists => items.is_empty(), |
| 304 |
_ if is_negative_str_op(cond.op) => { |
| 305 |
|
| 306 |
let positive = match cond.op { |
| 307 |
RuleOp::NotContains => RuleOp::Contains, |
| 308 |
RuleOp::NotEquals => RuleOp::Equals, |
| 309 |
_ => cond.op, |
| 310 |
}; |
| 311 |
!items |
| 312 |
.iter() |
| 313 |
.any(|it| str_op(positive, it, &cond.value).unwrap_or(false)) |
| 314 |
} |
| 315 |
_ => items |
| 316 |
.iter() |
| 317 |
.any(|it| str_op(cond.op, it, &cond.value).unwrap_or(false)), |
| 318 |
}, |
| 319 |
} |
| 320 |
} |
| 321 |
|
| 322 |
|
| 323 |
|
| 324 |
pub fn rule_matches(rule: &Rule, ctx: &RuleContext) -> bool { |
| 325 |
if rule.conditions.is_empty() { |
| 326 |
return true; |
| 327 |
} |
| 328 |
match rule.match_mode { |
| 329 |
MatchMode::All => rule.conditions.iter().all(|c| eval_condition(ctx, c)), |
| 330 |
MatchMode::Any => rule.conditions.iter().any(|c| eval_condition(ctx, c)), |
| 331 |
} |
| 332 |
} |
| 333 |
|
| 334 |
|
| 335 |
|
| 336 |
fn evaluate(rules: &[Rule], ctx: &RuleContext) -> Vec<(String, String)> { |
| 337 |
|
| 338 |
let mut desired: Vec<(String, String)> = Vec::new(); |
| 339 |
for rule in rules.iter().filter(|r| r.enabled) { |
| 340 |
if !rule_matches(rule, ctx) { |
| 341 |
continue; |
| 342 |
} |
| 343 |
let mut stop = false; |
| 344 |
for action in &rule.actions { |
| 345 |
match action { |
| 346 |
RuleAction::AddTag(tag) => { |
| 347 |
if validate_tag(tag).is_err() { |
| 348 |
tracing::warn!(rule = %rule.id, %tag, "rule produced an invalid tag; skipped"); |
| 349 |
continue; |
| 350 |
} |
| 351 |
if let Some(slot) = desired.iter_mut().find(|(t, _)| t == tag) { |
| 352 |
slot.1.clone_from(&rule.id); |
| 353 |
} else { |
| 354 |
desired.push((tag.clone(), rule.id.clone())); |
| 355 |
} |
| 356 |
} |
| 357 |
RuleAction::RemoveTag(tag) => { |
| 358 |
desired.retain(|(t, _)| t != tag); |
| 359 |
} |
| 360 |
RuleAction::Stop => stop = true, |
| 361 |
} |
| 362 |
} |
| 363 |
if stop { |
| 364 |
break; |
| 365 |
} |
| 366 |
} |
| 367 |
desired |
| 368 |
} |
| 369 |
|
| 370 |
|
| 371 |
|
| 372 |
|
| 373 |
#[instrument(skip_all)] |
| 374 |
pub fn load_context(db: &Database, hash: &str) -> Result<Option<RuleContext>> { |
| 375 |
let conn = db.conn(); |
| 376 |
let base = conn |
| 377 |
.query_row( |
| 378 |
"SELECT original_name, file_extension, file_size, source_path |
| 379 |
FROM live_samples WHERE hash = ?1", |
| 380 |
[hash], |
| 381 |
|row| { |
| 382 |
Ok(( |
| 383 |
row.get::<_, String>(0)?, |
| 384 |
row.get::<_, Option<String>>(1)?, |
| 385 |
row.get::<_, Option<i64>>(2)?, |
| 386 |
row.get::<_, Option<String>>(3)?, |
| 387 |
)) |
| 388 |
}, |
| 389 |
) |
| 390 |
.ok(); |
| 391 |
|
| 392 |
let Some((name, file_extension, file_size, source_path)) = base else { |
| 393 |
return Ok(None); |
| 394 |
}; |
| 395 |
|
| 396 |
let mut ctx = RuleContext { |
| 397 |
hash: hash.to_string(), |
| 398 |
name, |
| 399 |
source_path, |
| 400 |
file_extension, |
| 401 |
file_size: file_size.map(|v| v as f64), |
| 402 |
..Default::default() |
| 403 |
}; |
| 404 |
|
| 405 |
|
| 406 |
let _ = conn.query_row( |
| 407 |
"SELECT duration, bpm, musical_key, is_loop, spectral_centroid, spectral_flatness, |
| 408 |
spectral_rolloff, zero_crossing_rate, spectral_bandwidth, centroid_variance, |
| 409 |
crest_factor, attack_time, peak_db, rms_db, lufs, sample_rate, channels |
| 410 |
FROM audio_analysis WHERE hash = ?1", |
| 411 |
[hash], |
| 412 |
|row| { |
| 413 |
ctx.duration = row.get(0)?; |
| 414 |
ctx.bpm = row.get(1)?; |
| 415 |
ctx.musical_key = row.get(2)?; |
| 416 |
ctx.is_loop = row.get::<_, Option<bool>>(3)?; |
| 417 |
ctx.spectral_centroid = row.get(4)?; |
| 418 |
ctx.spectral_flatness = row.get(5)?; |
| 419 |
ctx.spectral_rolloff = row.get(6)?; |
| 420 |
ctx.zcr = row.get(7)?; |
| 421 |
ctx.spectral_bandwidth = row.get(8)?; |
| 422 |
ctx.centroid_variance = row.get(9)?; |
| 423 |
ctx.crest_factor = row.get(10)?; |
| 424 |
ctx.attack_time = row.get(11)?; |
| 425 |
ctx.peak_db = row.get(12)?; |
| 426 |
ctx.rms_db = row.get(13)?; |
| 427 |
ctx.lufs = row.get(14)?; |
| 428 |
ctx.sample_rate = row.get::<_, Option<i64>>(15)?.map(|v| v as f64); |
| 429 |
ctx.channels = row.get::<_, Option<i64>>(16)?.map(|v| v as f64); |
| 430 |
Ok(()) |
| 431 |
}, |
| 432 |
); |
| 433 |
|
| 434 |
|
| 435 |
{ |
| 436 |
let mut stmt = conn.prepare("SELECT tag FROM tags WHERE sample_hash = ?1")?; |
| 437 |
ctx.tags = stmt |
| 438 |
.query_map([hash], |row| row.get(0))? |
| 439 |
.collect::<std::result::Result<Vec<_>, _>>()?; |
| 440 |
} |
| 441 |
|
| 442 |
|
| 443 |
ctx.vfs_paths = sample_vfs_paths(db, hash)?; |
| 444 |
|
| 445 |
Ok(Some(ctx)) |
| 446 |
} |
| 447 |
|
| 448 |
|
| 449 |
fn sample_vfs_paths(db: &Database, hash: &str) -> Result<Vec<String>> { |
| 450 |
let mut stmt = db.conn().prepare( |
| 451 |
"WITH RECURSIVE chain(node_id, parent_id, name, depth) AS ( |
| 452 |
SELECT vn.id, vn.parent_id, p.name, 0 |
| 453 |
FROM vfs_nodes vn |
| 454 |
JOIN vfs_nodes p ON p.id = vn.parent_id |
| 455 |
WHERE vn.sample_hash = ?1 AND vn.node_type = 'sample' |
| 456 |
UNION ALL |
| 457 |
SELECT c.node_id, p.parent_id, p.name, c.depth + 1 |
| 458 |
FROM chain c |
| 459 |
JOIN vfs_nodes p ON p.id = c.parent_id |
| 460 |
) |
| 461 |
SELECT node_id, group_concat(name, '/') FROM ( |
| 462 |
SELECT node_id, name, depth FROM chain ORDER BY node_id, depth DESC |
| 463 |
) GROUP BY node_id", |
| 464 |
)?; |
| 465 |
let rows = stmt.query_map([hash], |row| row.get::<_, Option<String>>(1))?; |
| 466 |
let mut paths = Vec::new(); |
| 467 |
for r in rows { |
| 468 |
if let Some(p) = r? { |
| 469 |
paths.push(p); |
| 470 |
} |
| 471 |
} |
| 472 |
Ok(paths) |
| 473 |
} |
| 474 |
|
| 475 |
|
| 476 |
|
| 477 |
fn parse_rule(row: &rusqlite::Row) -> rusqlite::Result<Rule> { |
| 478 |
let conditions_json: String = row.get(5)?; |
| 479 |
let actions_json: String = row.get(6)?; |
| 480 |
Ok(Rule { |
| 481 |
id: row.get(0)?, |
| 482 |
name: row.get(1)?, |
| 483 |
enabled: row.get(2)?, |
| 484 |
priority: row.get(3)?, |
| 485 |
match_mode: serde_json::from_str(&row.get::<_, String>(4)?).unwrap_or(MatchMode::All), |
| 486 |
conditions: serde_json::from_str(&conditions_json).unwrap_or_default(), |
| 487 |
actions: serde_json::from_str(&actions_json).unwrap_or_default(), |
| 488 |
created_at: row.get(7)?, |
| 489 |
}) |
| 490 |
} |
| 491 |
|
| 492 |
const RULE_COLUMNS: &str = |
| 493 |
"id, name, enabled, priority, match_mode, conditions, actions, created_at"; |
| 494 |
|
| 495 |
|
| 496 |
#[instrument(skip_all)] |
| 497 |
pub fn list_rules(db: &Database) -> Result<Vec<Rule>> { |
| 498 |
let sql = format!("SELECT {RULE_COLUMNS} FROM tag_rules ORDER BY priority, created_at"); |
| 499 |
let mut stmt = db.conn().prepare(&sql)?; |
| 500 |
let rows = stmt.query_map([], parse_rule)?; |
| 501 |
Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?) |
| 502 |
} |
| 503 |
|
| 504 |
|
| 505 |
#[instrument(skip_all)] |
| 506 |
pub fn get_rule(db: &Database, id: &str) -> Result<Option<Rule>> { |
| 507 |
let sql = format!("SELECT {RULE_COLUMNS} FROM tag_rules WHERE id = ?1"); |
| 508 |
Ok(db.conn().query_row(&sql, [id], parse_rule).ok()) |
| 509 |
} |
| 510 |
|
| 511 |
|
| 512 |
fn rule_json(rule: &Rule) -> Result<(String, String, String)> { |
| 513 |
let match_mode = serde_json::to_string(&rule.match_mode) |
| 514 |
.map_err(|e| CoreError::Serialization(e.to_string()))?; |
| 515 |
let conditions = serde_json::to_string(&rule.conditions) |
| 516 |
.map_err(|e| CoreError::Serialization(e.to_string()))?; |
| 517 |
let actions = serde_json::to_string(&rule.actions) |
| 518 |
.map_err(|e| CoreError::Serialization(e.to_string()))?; |
| 519 |
Ok((match_mode, conditions, actions)) |
| 520 |
} |
| 521 |
|
| 522 |
|
| 523 |
|
| 524 |
fn insert_rule(db: &Database, rule: &Rule) -> Result<()> { |
| 525 |
let (match_mode, conditions, actions) = rule_json(rule)?; |
| 526 |
db.conn().execute( |
| 527 |
"INSERT INTO tag_rules |
| 528 |
(id, name, enabled, priority, match_mode, conditions, actions, created_at) |
| 529 |
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", |
| 530 |
rusqlite::params![ |
| 531 |
rule.id, |
| 532 |
rule.name, |
| 533 |
rule.enabled, |
| 534 |
rule.priority, |
| 535 |
match_mode, |
| 536 |
conditions, |
| 537 |
actions, |
| 538 |
rule.created_at, |
| 539 |
], |
| 540 |
)?; |
| 541 |
Ok(()) |
| 542 |
} |
| 543 |
|
| 544 |
|
| 545 |
#[instrument(skip_all)] |
| 546 |
pub fn create_rule(db: &Database, new: NewRule) -> Result<Rule> { |
| 547 |
for action in &new.actions { |
| 548 |
if let RuleAction::AddTag(tag) | RuleAction::RemoveTag(tag) = action { |
| 549 |
validate_tag(tag)?; |
| 550 |
} |
| 551 |
} |
| 552 |
let priority = match new.priority { |
| 553 |
Some(p) => p, |
| 554 |
None => db |
| 555 |
.conn() |
| 556 |
.query_row( |
| 557 |
"SELECT COALESCE(MAX(priority), -1) + 1 FROM tag_rules", |
| 558 |
[], |
| 559 |
|r| r.get(0), |
| 560 |
) |
| 561 |
.unwrap_or(0), |
| 562 |
}; |
| 563 |
let rule = Rule { |
| 564 |
id: new_rule_id(), |
| 565 |
name: new.name, |
| 566 |
enabled: new.enabled, |
| 567 |
priority, |
| 568 |
match_mode: new.match_mode, |
| 569 |
conditions: new.conditions, |
| 570 |
actions: new.actions, |
| 571 |
created_at: now_secs(), |
| 572 |
}; |
| 573 |
insert_rule(db, &rule)?; |
| 574 |
Ok(rule) |
| 575 |
} |
| 576 |
|
| 577 |
|
| 578 |
#[instrument(skip_all)] |
| 579 |
pub fn update_rule(db: &Database, rule: &Rule) -> Result<()> { |
| 580 |
for action in &rule.actions { |
| 581 |
if let RuleAction::AddTag(tag) | RuleAction::RemoveTag(tag) = action { |
| 582 |
validate_tag(tag)?; |
| 583 |
} |
| 584 |
} |
| 585 |
let (match_mode, conditions, actions) = rule_json(rule)?; |
| 586 |
|
| 587 |
|
| 588 |
|
| 589 |
let changed = db.conn().execute( |
| 590 |
"UPDATE tag_rules SET |
| 591 |
name = ?2, enabled = ?3, priority = ?4, |
| 592 |
match_mode = ?5, conditions = ?6, actions = ?7 |
| 593 |
WHERE id = ?1", |
| 594 |
rusqlite::params![ |
| 595 |
rule.id, |
| 596 |
rule.name, |
| 597 |
rule.enabled, |
| 598 |
rule.priority, |
| 599 |
match_mode, |
| 600 |
conditions, |
| 601 |
actions, |
| 602 |
], |
| 603 |
)?; |
| 604 |
if changed == 0 { |
| 605 |
return Err(CoreError::RuleNotFound(rule.id.clone())); |
| 606 |
} |
| 607 |
Ok(()) |
| 608 |
} |
| 609 |
|
| 610 |
|
| 611 |
|
| 612 |
|
| 613 |
|
| 614 |
|
| 615 |
|
| 616 |
|
| 617 |
|
| 618 |
|
| 619 |
#[instrument(skip_all)] |
| 620 |
pub fn set_rule_enabled(db: &Database, id: &str, enabled: bool) -> Result<()> { |
| 621 |
let previously_tagged = samples_tagged_by_rule(db, id)?; |
| 622 |
db.transaction_core(|tx| { |
| 623 |
db.conn().execute( |
| 624 |
"UPDATE tag_rules SET enabled = ?2 WHERE id = ?1", |
| 625 |
rusqlite::params![id, enabled], |
| 626 |
)?; |
| 627 |
let rules = list_rules(db)?; |
| 628 |
let to_reconcile: Vec<String> = if enabled { |
| 629 |
let mut stmt = db.conn().prepare("SELECT hash FROM live_samples")?; |
| 630 |
stmt.query_map([], |row| row.get(0))? |
| 631 |
.collect::<std::result::Result<Vec<_>, _>>()? |
| 632 |
} else { |
| 633 |
previously_tagged |
| 634 |
}; |
| 635 |
for hash in &to_reconcile { |
| 636 |
reconcile_sample(db, tx, hash, &rules)?; |
| 637 |
} |
| 638 |
Ok(()) |
| 639 |
})?; |
| 640 |
Ok(()) |
| 641 |
} |
| 642 |
|
| 643 |
|
| 644 |
|
| 645 |
#[instrument(skip_all)] |
| 646 |
pub fn delete_rule(db: &Database, id: &str) -> Result<()> { |
| 647 |
let affected = samples_tagged_by_rule(db, id)?; |
| 648 |
|
| 649 |
|
| 650 |
|
| 651 |
|
| 652 |
db.transaction_core(|tx| { |
| 653 |
db.conn() |
| 654 |
.execute("DELETE FROM tag_rules WHERE id = ?1", [id])?; |
| 655 |
let rules = list_rules(db)?; |
| 656 |
for hash in &affected { |
| 657 |
reconcile_sample(db, tx, hash, &rules)?; |
| 658 |
} |
| 659 |
Ok(()) |
| 660 |
})?; |
| 661 |
Ok(()) |
| 662 |
} |
| 663 |
|
| 664 |
fn samples_tagged_by_rule(db: &Database, id: &str) -> Result<Vec<String>> { |
| 665 |
let mut stmt = db.conn().prepare( |
| 666 |
"SELECT DISTINCT sample_hash FROM tag_provenance WHERE source = 'rule' AND rule_id = ?1", |
| 667 |
)?; |
| 668 |
let rows = stmt.query_map([id], |row| row.get(0))?; |
| 669 |
Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?) |
| 670 |
} |
| 671 |
|
| 672 |
|
| 673 |
|
| 674 |
|
| 675 |
|
| 676 |
|
| 677 |
|
| 678 |
fn reconcile_sample(db: &Database, _tx: &Tx, hash: &str, rules: &[Rule]) -> Result<bool> { |
| 679 |
let Some(ctx) = load_context(db, hash)? else { |
| 680 |
return Ok(false); |
| 681 |
}; |
| 682 |
let desired = evaluate(rules, &ctx); |
| 683 |
let desired_tags: std::collections::HashSet<&str> = |
| 684 |
desired.iter().map(|(t, _)| t.as_str()).collect(); |
| 685 |
|
| 686 |
let conn = db.conn(); |
| 687 |
|
| 688 |
|
| 689 |
let current_rule_tags: Vec<String> = { |
| 690 |
let mut stmt = conn |
| 691 |
.prepare("SELECT tag FROM tag_provenance WHERE sample_hash = ?1 AND source = 'rule'")?; |
| 692 |
stmt.query_map([hash], |row| row.get(0))? |
| 693 |
.collect::<std::result::Result<Vec<_>, _>>()? |
| 694 |
}; |
| 695 |
let existing_tags: std::collections::HashSet<String> = { |
| 696 |
let mut stmt = conn.prepare("SELECT tag FROM tags WHERE sample_hash = ?1")?; |
| 697 |
stmt.query_map([hash], |row| row.get(0))? |
| 698 |
.collect::<std::result::Result<std::collections::HashSet<_>, _>>()? |
| 699 |
}; |
| 700 |
|
| 701 |
let mut changed = false; |
| 702 |
|
| 703 |
|
| 704 |
for tag in ¤t_rule_tags { |
| 705 |
if !desired_tags.contains(tag.as_str()) { |
| 706 |
conn.execute( |
| 707 |
"DELETE FROM tags WHERE sample_hash = ?1 AND tag = ?2", |
| 708 |
rusqlite::params![hash, tag], |
| 709 |
)?; |
| 710 |
conn.execute( |
| 711 |
"DELETE FROM tag_provenance WHERE sample_hash = ?1 AND tag = ?2", |
| 712 |
rusqlite::params![hash, tag], |
| 713 |
)?; |
| 714 |
changed = true; |
| 715 |
} |
| 716 |
} |
| 717 |
|
| 718 |
|
| 719 |
|
| 720 |
let current_rule_set: std::collections::HashSet<&str> = current_rule_tags |
| 721 |
.iter() |
| 722 |
.map(std::string::String::as_str) |
| 723 |
.collect(); |
| 724 |
for (tag, rule_id) in &desired { |
| 725 |
let already_manual = |
| 726 |
existing_tags.contains(tag) && !current_rule_set.contains(tag.as_str()); |
| 727 |
if already_manual { |
| 728 |
continue; |
| 729 |
} |
| 730 |
conn.execute( |
| 731 |
"INSERT OR IGNORE INTO tags (sample_hash, tag) VALUES (?1, ?2)", |
| 732 |
rusqlite::params![hash, tag], |
| 733 |
)?; |
| 734 |
conn.execute( |
| 735 |
"INSERT INTO tag_provenance (sample_hash, tag, source, rule_id) |
| 736 |
VALUES (?1, ?2, 'rule', ?3) |
| 737 |
ON CONFLICT(sample_hash, tag) DO UPDATE SET source = 'rule', rule_id = ?3", |
| 738 |
rusqlite::params![hash, tag, rule_id], |
| 739 |
)?; |
| 740 |
if !existing_tags.contains(tag) { |
| 741 |
changed = true; |
| 742 |
} |
| 743 |
} |
| 744 |
|
| 745 |
Ok(changed) |
| 746 |
} |
| 747 |
|
| 748 |
|
| 749 |
#[instrument(skip_all)] |
| 750 |
pub fn apply_rules_to_sample(db: &Database, hash: &str) -> Result<bool> { |
| 751 |
let rules = list_rules(db)?; |
| 752 |
db.transaction_core(|tx| reconcile_sample(db, tx, hash, &rules)) |
| 753 |
} |
| 754 |
|
| 755 |
|
| 756 |
|
| 757 |
|
| 758 |
|
| 759 |
#[instrument(skip_all)] |
| 760 |
pub fn apply_rules_to_samples(db: &Database, hashes: &[String]) -> Result<usize> { |
| 761 |
if hashes.is_empty() { |
| 762 |
return Ok(0); |
| 763 |
} |
| 764 |
let rules = list_rules(db)?; |
| 765 |
db.transaction_core(|tx| { |
| 766 |
let mut changed = 0; |
| 767 |
for hash in hashes { |
| 768 |
if reconcile_sample(db, tx, hash, &rules)? { |
| 769 |
changed += 1; |
| 770 |
} |
| 771 |
} |
| 772 |
Ok(changed) |
| 773 |
}) |
| 774 |
} |
| 775 |
|
| 776 |
|
| 777 |
#[instrument(skip_all)] |
| 778 |
pub fn apply_all_rules(db: &Database) -> Result<usize> { |
| 779 |
let rules = list_rules(db)?; |
| 780 |
let hashes: Vec<String> = { |
| 781 |
let mut stmt = db.conn().prepare("SELECT hash FROM live_samples")?; |
| 782 |
stmt.query_map([], |row| row.get(0))? |
| 783 |
.collect::<std::result::Result<Vec<_>, _>>()? |
| 784 |
}; |
| 785 |
db.transaction_core(|tx| { |
| 786 |
let mut changed = 0; |
| 787 |
for hash in hashes { |
| 788 |
if reconcile_sample(db, tx, &hash, &rules)? { |
| 789 |
changed += 1; |
| 790 |
} |
| 791 |
} |
| 792 |
Ok(changed) |
| 793 |
}) |
| 794 |
} |
| 795 |
|
| 796 |
|
| 797 |
#[instrument(skip_all)] |
| 798 |
pub fn preview_rule_matches(db: &Database, rule: &Rule) -> Result<usize> { |
| 799 |
let hashes: Vec<String> = { |
| 800 |
let mut stmt = db.conn().prepare("SELECT hash FROM live_samples")?; |
| 801 |
stmt.query_map([], |row| row.get(0))? |
| 802 |
.collect::<std::result::Result<Vec<_>, _>>()? |
| 803 |
}; |
| 804 |
let mut count = 0; |
| 805 |
for hash in hashes { |
| 806 |
if let Some(ctx) = load_context(db, &hash)? |
| 807 |
&& rule_matches(rule, &ctx) |
| 808 |
{ |
| 809 |
count += 1; |
| 810 |
} |
| 811 |
} |
| 812 |
Ok(count) |
| 813 |
} |
| 814 |
|
| 815 |
|
| 816 |
|
| 817 |
|
| 818 |
|
| 819 |
|
| 820 |
|
| 821 |
|
| 822 |
|
| 823 |
|
| 824 |
#[instrument(skip_all)] |
| 825 |
pub fn apply_tag_sourced( |
| 826 |
db: &Database, |
| 827 |
_tx: &Tx, |
| 828 |
hash: &str, |
| 829 |
tag: &str, |
| 830 |
source: &str, |
| 831 |
) -> Result<bool> { |
| 832 |
validate_tag(tag)?; |
| 833 |
let conn = db.conn(); |
| 834 |
let added = conn.execute( |
| 835 |
"INSERT OR IGNORE INTO tags (sample_hash, tag) VALUES (?1, ?2)", |
| 836 |
rusqlite::params![hash, tag], |
| 837 |
)? > 0; |
| 838 |
conn.execute( |
| 839 |
"INSERT OR IGNORE INTO tag_provenance (sample_hash, tag, source, rule_id) |
| 840 |
VALUES (?1, ?2, ?3, NULL)", |
| 841 |
rusqlite::params![hash, tag, source], |
| 842 |
)?; |
| 843 |
Ok(added) |
| 844 |
} |
| 845 |
|
| 846 |
|
| 847 |
|
| 848 |
#[instrument(skip_all)] |
| 849 |
pub fn remove_tags_by_source(db: &Database, source: &str) -> Result<usize> { |
| 850 |
|
| 851 |
|
| 852 |
db.transaction_core(|_tx| { |
| 853 |
let conn = db.conn(); |
| 854 |
let removed = conn.execute( |
| 855 |
"DELETE FROM tags WHERE (sample_hash, tag) IN |
| 856 |
(SELECT sample_hash, tag FROM tag_provenance WHERE source = ?1)", |
| 857 |
rusqlite::params![source], |
| 858 |
)?; |
| 859 |
conn.execute( |
| 860 |
"DELETE FROM tag_provenance WHERE source = ?1", |
| 861 |
rusqlite::params![source], |
| 862 |
)?; |
| 863 |
Ok(removed) |
| 864 |
}) |
| 865 |
} |
| 866 |
|
| 867 |
|
| 868 |
|
| 869 |
#[instrument(skip_all)] |
| 870 |
pub fn sample_tag_provenance( |
| 871 |
db: &Database, |
| 872 |
hash: &str, |
| 873 |
) -> Result<Vec<(String, String, Option<String>)>> { |
| 874 |
let mut stmt = db.conn().prepare( |
| 875 |
"SELECT tag, source, rule_id FROM tag_provenance WHERE sample_hash = ?1 ORDER BY tag", |
| 876 |
)?; |
| 877 |
let rows = stmt.query_map([hash], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))?; |
| 878 |
Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?) |
| 879 |
} |
| 880 |
|
| 881 |
#[cfg(test)] |
| 882 |
mod tests { |
| 883 |
use super::*; |
| 884 |
|
| 885 |
fn db_with_sample(hash: &str, name: &str) -> Database { |
| 886 |
let db = Database::open_in_memory().unwrap(); |
| 887 |
db.conn() |
| 888 |
.execute( |
| 889 |
"INSERT INTO samples (hash, original_name, file_extension, file_size, import_date, last_modified) \ |
| 890 |
VALUES (?1, ?2, 'wav', 1000, 0, 0)", |
| 891 |
rusqlite::params![hash, name], |
| 892 |
) |
| 893 |
.unwrap(); |
| 894 |
db |
| 895 |
} |
| 896 |
|
| 897 |
fn cond(field: RuleField, op: RuleOp, value: &str) -> RuleCondition { |
| 898 |
RuleCondition { |
| 899 |
field, |
| 900 |
op, |
| 901 |
value: value.to_string(), |
| 902 |
} |
| 903 |
} |
| 904 |
|
| 905 |
fn new_rule(name: &str, conds: Vec<RuleCondition>, acts: Vec<RuleAction>) -> NewRule { |
| 906 |
NewRule { |
| 907 |
name: name.to_string(), |
| 908 |
enabled: true, |
| 909 |
priority: None, |
| 910 |
match_mode: MatchMode::All, |
| 911 |
conditions: conds, |
| 912 |
actions: acts, |
| 913 |
} |
| 914 |
} |
| 915 |
|
| 916 |
#[test] |
| 917 |
fn name_contains_applies_tag() { |
| 918 |
let db = db_with_sample("h1", "808 Kick Loud.wav"); |
| 919 |
create_rule( |
| 920 |
&db, |
| 921 |
new_rule( |
| 922 |
"kicks", |
| 923 |
vec![cond(RuleField::Name, RuleOp::Contains, "kick")], |
| 924 |
vec![RuleAction::AddTag("instrument.drum.kick".into())], |
| 925 |
), |
| 926 |
) |
| 927 |
.unwrap(); |
| 928 |
|
| 929 |
assert!(apply_rules_to_sample(&db, "h1").unwrap()); |
| 930 |
let tags = crate::tags::get_sample_tags(&db, "h1").unwrap(); |
| 931 |
assert_eq!(tags, vec!["instrument.drum.kick"]); |
| 932 |
|
| 933 |
let prov = sample_tag_provenance(&db, "h1").unwrap(); |
| 934 |
assert_eq!(prov.len(), 1); |
| 935 |
assert_eq!(prov[0].1, "rule"); |
| 936 |
} |
| 937 |
|
| 938 |
#[test] |
| 939 |
fn numeric_condition_on_analysis() { |
| 940 |
let db = db_with_sample("h2", "loop.wav"); |
| 941 |
db.conn() |
| 942 |
.execute( |
| 943 |
"INSERT INTO audio_analysis (hash, duration, sample_rate, channels, bpm, analyzed_at) \ |
| 944 |
VALUES ('h2', 4.0, 44100, 2, 128.0, 0)", |
| 945 |
[], |
| 946 |
) |
| 947 |
.unwrap(); |
| 948 |
create_rule( |
| 949 |
&db, |
| 950 |
new_rule( |
| 951 |
"fast", |
| 952 |
vec![cond(RuleField::Bpm, RuleOp::Ge, "120")], |
| 953 |
vec![RuleAction::AddTag("tempo.fast".into())], |
| 954 |
), |
| 955 |
) |
| 956 |
.unwrap(); |
| 957 |
apply_rules_to_sample(&db, "h2").unwrap(); |
| 958 |
assert!( |
| 959 |
crate::tags::get_sample_tags(&db, "h2") |
| 960 |
.unwrap() |
| 961 |
.contains(&"tempo.fast".to_string()) |
| 962 |
); |
| 963 |
} |
| 964 |
|
| 965 |
#[test] |
| 966 |
fn manual_tags_are_sticky() { |
| 967 |
let db = db_with_sample("h3", "kick.wav"); |
| 968 |
crate::tags::add_tag(&db, "h3", "manual.keep").unwrap(); |
| 969 |
let rule = create_rule( |
| 970 |
&db, |
| 971 |
new_rule( |
| 972 |
"kicks", |
| 973 |
vec![cond(RuleField::Name, RuleOp::Contains, "kick")], |
| 974 |
vec![RuleAction::AddTag("instrument.drum.kick".into())], |
| 975 |
), |
| 976 |
) |
| 977 |
.unwrap(); |
| 978 |
apply_rules_to_sample(&db, "h3").unwrap(); |
| 979 |
|
| 980 |
|
| 981 |
delete_rule(&db, &rule.id).unwrap(); |
| 982 |
let tags = crate::tags::get_sample_tags(&db, "h3").unwrap(); |
| 983 |
assert_eq!(tags, vec!["manual.keep"]); |
| 984 |
} |
| 985 |
|
| 986 |
#[test] |
| 987 |
fn reconcile_removes_tags_when_rule_no_longer_matches() { |
| 988 |
let db = db_with_sample("h4", "kick.wav"); |
| 989 |
let mut rule = create_rule( |
| 990 |
&db, |
| 991 |
new_rule( |
| 992 |
"kicks", |
| 993 |
vec![cond(RuleField::Name, RuleOp::Contains, "kick")], |
| 994 |
vec![RuleAction::AddTag("instrument.drum.kick".into())], |
| 995 |
), |
| 996 |
) |
| 997 |
.unwrap(); |
| 998 |
apply_rules_to_sample(&db, "h4").unwrap(); |
| 999 |
assert!(!crate::tags::get_sample_tags(&db, "h4").unwrap().is_empty()); |
| 1000 |
|
| 1001 |
|
| 1002 |
rule.conditions = vec![cond(RuleField::Name, RuleOp::Contains, "snare")]; |
| 1003 |
update_rule(&db, &rule).unwrap(); |
| 1004 |
apply_rules_to_sample(&db, "h4").unwrap(); |
| 1005 |
assert!(crate::tags::get_sample_tags(&db, "h4").unwrap().is_empty()); |
| 1006 |
} |
| 1007 |
|
| 1008 |
#[test] |
| 1009 |
fn toggling_enabled_reconciles_membership() { |
| 1010 |
let db = db_with_sample("h6", "kick.wav"); |
| 1011 |
let rule = create_rule( |
| 1012 |
&db, |
| 1013 |
new_rule( |
| 1014 |
"kicks", |
| 1015 |
vec![cond(RuleField::Name, RuleOp::Contains, "kick")], |
| 1016 |
vec![RuleAction::AddTag("instrument.drum.kick".into())], |
| 1017 |
), |
| 1018 |
) |
| 1019 |
.unwrap(); |
| 1020 |
apply_rules_to_sample(&db, "h6").unwrap(); |
| 1021 |
assert!(!crate::tags::get_sample_tags(&db, "h6").unwrap().is_empty()); |
| 1022 |
|
| 1023 |
|
| 1024 |
|
| 1025 |
set_rule_enabled(&db, &rule.id, false).unwrap(); |
| 1026 |
assert!(crate::tags::get_sample_tags(&db, "h6").unwrap().is_empty()); |
| 1027 |
|
| 1028 |
|
| 1029 |
|
| 1030 |
set_rule_enabled(&db, &rule.id, true).unwrap(); |
| 1031 |
assert!( |
| 1032 |
crate::tags::get_sample_tags(&db, "h6") |
| 1033 |
.unwrap() |
| 1034 |
.contains(&"instrument.drum.kick".to_string()) |
| 1035 |
); |
| 1036 |
} |
| 1037 |
|
| 1038 |
#[test] |
| 1039 |
fn update_unknown_rule_errors_not_resurrects() { |
| 1040 |
let db = db_with_sample("h7", "kick.wav"); |
| 1041 |
let rule = create_rule( |
| 1042 |
&db, |
| 1043 |
new_rule( |
| 1044 |
"kicks", |
| 1045 |
vec![cond(RuleField::Name, RuleOp::Contains, "kick")], |
| 1046 |
vec![RuleAction::AddTag("instrument.drum.kick".into())], |
| 1047 |
), |
| 1048 |
) |
| 1049 |
.unwrap(); |
| 1050 |
delete_rule(&db, &rule.id).unwrap(); |
| 1051 |
|
| 1052 |
|
| 1053 |
assert!(matches!( |
| 1054 |
update_rule(&db, &rule), |
| 1055 |
Err(CoreError::RuleNotFound(_)) |
| 1056 |
)); |
| 1057 |
assert!(get_rule(&db, &rule.id).unwrap().is_none()); |
| 1058 |
} |
| 1059 |
|
| 1060 |
#[test] |
| 1061 |
fn match_mode_any_vs_all() { |
| 1062 |
let db = db_with_sample("h5", "snare hit.wav"); |
| 1063 |
let any = create_rule( |
| 1064 |
&db, |
| 1065 |
NewRule { |
| 1066 |
match_mode: MatchMode::Any, |
| 1067 |
..new_rule( |
| 1068 |
"any", |
| 1069 |
vec![ |
| 1070 |
cond(RuleField::Name, RuleOp::Contains, "kick"), |
| 1071 |
cond(RuleField::Name, RuleOp::Contains, "snare"), |
| 1072 |
], |
| 1073 |
vec![RuleAction::AddTag("matched.any".into())], |
| 1074 |
) |
| 1075 |
}, |
| 1076 |
) |
| 1077 |
.unwrap(); |
| 1078 |
assert_eq!(preview_rule_matches(&db, &any).unwrap(), 1); |
| 1079 |
|
| 1080 |
let all = Rule { |
| 1081 |
match_mode: MatchMode::All, |
| 1082 |
..any |
| 1083 |
}; |
| 1084 |
assert_eq!(preview_rule_matches(&db, &all).unwrap(), 0); |
| 1085 |
} |
| 1086 |
|
| 1087 |
#[test] |
| 1088 |
fn stop_action_halts_later_rules() { |
| 1089 |
let db = db_with_sample("h6", "kick.wav"); |
| 1090 |
create_rule( |
| 1091 |
&db, |
| 1092 |
NewRule { |
| 1093 |
priority: Some(0), |
| 1094 |
..new_rule( |
| 1095 |
"first", |
| 1096 |
vec![], |
| 1097 |
vec![RuleAction::AddTag("a.first".into()), RuleAction::Stop], |
| 1098 |
) |
| 1099 |
}, |
| 1100 |
) |
| 1101 |
.unwrap(); |
| 1102 |
create_rule( |
| 1103 |
&db, |
| 1104 |
NewRule { |
| 1105 |
priority: Some(1), |
| 1106 |
..new_rule( |
| 1107 |
"second", |
| 1108 |
vec![], |
| 1109 |
vec![RuleAction::AddTag("a.second".into())], |
| 1110 |
) |
| 1111 |
}, |
| 1112 |
) |
| 1113 |
.unwrap(); |
| 1114 |
apply_rules_to_sample(&db, "h6").unwrap(); |
| 1115 |
let tags = crate::tags::get_sample_tags(&db, "h6").unwrap(); |
| 1116 |
assert_eq!(tags, vec!["a.first"]); |
| 1117 |
} |
| 1118 |
|
| 1119 |
#[test] |
| 1120 |
fn rules_round_trip_through_db() { |
| 1121 |
let db = db_with_sample("h7", "x.wav"); |
| 1122 |
let created = create_rule( |
| 1123 |
&db, |
| 1124 |
new_rule( |
| 1125 |
"complex", |
| 1126 |
vec![ |
| 1127 |
cond(RuleField::SpectralFlatness, RuleOp::Lt, "0.2"), |
| 1128 |
cond(RuleField::Tag, RuleOp::StartsWith, "instrument.drum"), |
| 1129 |
], |
| 1130 |
vec![ |
| 1131 |
RuleAction::AddTag("character.tonal".into()), |
| 1132 |
RuleAction::Stop, |
| 1133 |
], |
| 1134 |
), |
| 1135 |
) |
| 1136 |
.unwrap(); |
| 1137 |
let fetched = get_rule(&db, &created.id).unwrap().unwrap(); |
| 1138 |
assert_eq!(created, fetched); |
| 1139 |
} |
| 1140 |
|
| 1141 |
#[test] |
| 1142 |
fn empty_ruleset_is_noop() { |
| 1143 |
let db = db_with_sample("h8", "kick.wav"); |
| 1144 |
assert!(!apply_rules_to_sample(&db, "h8").unwrap()); |
| 1145 |
assert!(crate::tags::get_sample_tags(&db, "h8").unwrap().is_empty()); |
| 1146 |
} |
| 1147 |
|
| 1148 |
|
| 1149 |
|
| 1150 |
|
| 1151 |
|
| 1152 |
|
| 1153 |
|
| 1154 |
|
| 1155 |
|
| 1156 |
fn eval(ctx: &RuleContext, field: RuleField, op: RuleOp, value: &str) -> bool { |
| 1157 |
eval_condition(ctx, &cond(field, op, value)) |
| 1158 |
} |
| 1159 |
|
| 1160 |
#[test] |
| 1161 |
fn str_op_is_case_insensitive_over_all_string_ops() { |
| 1162 |
|
| 1163 |
assert_eq!(str_op(RuleOp::Contains, "Kick DRUM", "kick"), Some(true)); |
| 1164 |
assert_eq!(str_op(RuleOp::Contains, "snare", "KICK"), Some(false)); |
| 1165 |
assert_eq!(str_op(RuleOp::Equals, "WaV", "wav"), Some(true)); |
| 1166 |
assert_eq!(str_op(RuleOp::Equals, "wave", "wav"), Some(false)); |
| 1167 |
assert_eq!(str_op(RuleOp::StartsWith, "808_Kick", "808"), Some(true)); |
| 1168 |
assert_eq!(str_op(RuleOp::StartsWith, "kick", "808"), Some(false)); |
| 1169 |
assert_eq!(str_op(RuleOp::EndsWith, "loop.WAV", ".wav"), Some(true)); |
| 1170 |
assert_eq!(str_op(RuleOp::EndsWith, "loop.aif", ".wav"), Some(false)); |
| 1171 |
|
| 1172 |
assert_eq!(str_op(RuleOp::NotContains, "snare", "kick"), Some(true)); |
| 1173 |
assert_eq!(str_op(RuleOp::NotContains, "Kick", "kick"), Some(false)); |
| 1174 |
assert_eq!(str_op(RuleOp::NotEquals, "snare", "kick"), Some(true)); |
| 1175 |
assert_eq!(str_op(RuleOp::NotEquals, "KICK", "kick"), Some(false)); |
| 1176 |
|
| 1177 |
for op in [RuleOp::Lt, RuleOp::Ge, RuleOp::IsTrue, RuleOp::Exists] { |
| 1178 |
assert_eq!( |
| 1179 |
str_op(op, "x", "y"), |
| 1180 |
None, |
| 1181 |
"{op:?} should not be str-applicable" |
| 1182 |
); |
| 1183 |
} |
| 1184 |
} |
| 1185 |
|
| 1186 |
#[test] |
| 1187 |
fn num_op_covers_every_comparison_and_bad_input() { |
| 1188 |
assert!(num_op(RuleOp::Lt, 1.0, "2")); |
| 1189 |
assert!(!num_op(RuleOp::Lt, 2.0, "2")); |
| 1190 |
assert!(num_op(RuleOp::Le, 2.0, "2")); |
| 1191 |
assert!(!num_op(RuleOp::Le, 3.0, "2")); |
| 1192 |
assert!(num_op(RuleOp::Gt, 3.0, "2")); |
| 1193 |
assert!(!num_op(RuleOp::Gt, 2.0, "2")); |
| 1194 |
assert!(num_op(RuleOp::Ge, 2.0, "2")); |
| 1195 |
assert!(!num_op(RuleOp::Ge, 1.0, "2")); |
| 1196 |
assert!(num_op(RuleOp::Equals, 2.0, "2")); |
| 1197 |
assert!(!num_op(RuleOp::Equals, 2.5, "2")); |
| 1198 |
assert!(num_op(RuleOp::NotEquals, 2.5, "2")); |
| 1199 |
assert!(!num_op(RuleOp::NotEquals, 2.0, "2")); |
| 1200 |
|
| 1201 |
assert!(num_op(RuleOp::Ge, 128.0, " 120 ")); |
| 1202 |
|
| 1203 |
for op in [ |
| 1204 |
RuleOp::Lt, |
| 1205 |
RuleOp::Le, |
| 1206 |
RuleOp::Gt, |
| 1207 |
RuleOp::Ge, |
| 1208 |
RuleOp::Equals, |
| 1209 |
RuleOp::NotEquals, |
| 1210 |
] { |
| 1211 |
assert!( |
| 1212 |
!num_op(op, 1.0, "notanumber"), |
| 1213 |
"{op:?} should fail on bad operand" |
| 1214 |
); |
| 1215 |
} |
| 1216 |
|
| 1217 |
assert!(!num_op(RuleOp::Contains, 1.0, "1")); |
| 1218 |
assert!(!num_op(RuleOp::StartsWith, 1.0, "1")); |
| 1219 |
} |
| 1220 |
|
| 1221 |
#[test] |
| 1222 |
fn num_op_equals_uses_relative_epsilon() { |
| 1223 |
|
| 1224 |
assert!(num_op(RuleOp::Equals, 44100.0, "44100")); |
| 1225 |
assert!(num_op(RuleOp::Equals, 1_000_000.0, "1000000.00005")); |
| 1226 |
assert!(!num_op(RuleOp::Equals, 1_000_000.0, "1000001")); |
| 1227 |
} |
| 1228 |
|
| 1229 |
#[test] |
| 1230 |
fn string_field_present_matrix() { |
| 1231 |
let ctx = RuleContext { |
| 1232 |
name: "808 Kick.wav".into(), |
| 1233 |
..Default::default() |
| 1234 |
}; |
| 1235 |
assert!(eval(&ctx, RuleField::Name, RuleOp::Contains, "kick")); |
| 1236 |
assert!(!eval(&ctx, RuleField::Name, RuleOp::Contains, "snare")); |
| 1237 |
assert!(eval(&ctx, RuleField::Name, RuleOp::StartsWith, "808")); |
| 1238 |
assert!(eval(&ctx, RuleField::Name, RuleOp::EndsWith, ".wav")); |
| 1239 |
assert!(eval(&ctx, RuleField::Name, RuleOp::NotContains, "snare")); |
| 1240 |
assert!(eval(&ctx, RuleField::Name, RuleOp::NotEquals, "other")); |
| 1241 |
assert!(eval(&ctx, RuleField::Name, RuleOp::Exists, "")); |
| 1242 |
assert!(!eval(&ctx, RuleField::Name, RuleOp::NotExists, "")); |
| 1243 |
|
| 1244 |
assert!(!eval(&ctx, RuleField::Name, RuleOp::Gt, "0")); |
| 1245 |
assert!(!eval(&ctx, RuleField::Name, RuleOp::IsTrue, "")); |
| 1246 |
} |
| 1247 |
|
| 1248 |
#[test] |
| 1249 |
fn string_field_missing_matrix() { |
| 1250 |
|
| 1251 |
let ctx = RuleContext::default(); |
| 1252 |
assert!(!eval(&ctx, RuleField::SourcePath, RuleOp::Contains, "x")); |
| 1253 |
assert!(!eval(&ctx, RuleField::SourcePath, RuleOp::Equals, "x")); |
| 1254 |
assert!(!eval(&ctx, RuleField::SourcePath, RuleOp::StartsWith, "x")); |
| 1255 |
assert!(eval(&ctx, RuleField::SourcePath, RuleOp::NotContains, "x")); |
| 1256 |
assert!(eval(&ctx, RuleField::SourcePath, RuleOp::NotEquals, "x")); |
| 1257 |
assert!(!eval(&ctx, RuleField::SourcePath, RuleOp::Exists, "")); |
| 1258 |
assert!(eval(&ctx, RuleField::SourcePath, RuleOp::NotExists, "")); |
| 1259 |
} |
| 1260 |
|
| 1261 |
#[test] |
| 1262 |
fn numeric_field_present_and_missing_matrix() { |
| 1263 |
let ctx = RuleContext { |
| 1264 |
bpm: Some(128.0), |
| 1265 |
..Default::default() |
| 1266 |
}; |
| 1267 |
assert!(eval(&ctx, RuleField::Bpm, RuleOp::Gt, "120")); |
| 1268 |
assert!(eval(&ctx, RuleField::Bpm, RuleOp::Ge, "128")); |
| 1269 |
assert!(eval(&ctx, RuleField::Bpm, RuleOp::Le, "128")); |
| 1270 |
assert!(!eval(&ctx, RuleField::Bpm, RuleOp::Lt, "128")); |
| 1271 |
assert!(eval(&ctx, RuleField::Bpm, RuleOp::Equals, "128")); |
| 1272 |
assert!(eval(&ctx, RuleField::Bpm, RuleOp::NotEquals, "120")); |
| 1273 |
assert!(eval(&ctx, RuleField::Bpm, RuleOp::Exists, "")); |
| 1274 |
assert!(!eval(&ctx, RuleField::Bpm, RuleOp::NotExists, "")); |
| 1275 |
|
| 1276 |
assert!(!eval(&ctx, RuleField::Bpm, RuleOp::Contains, "12")); |
| 1277 |
|
| 1278 |
|
| 1279 |
let empty = RuleContext::default(); |
| 1280 |
for op in [ |
| 1281 |
RuleOp::Lt, |
| 1282 |
RuleOp::Le, |
| 1283 |
RuleOp::Gt, |
| 1284 |
RuleOp::Ge, |
| 1285 |
RuleOp::Equals, |
| 1286 |
RuleOp::NotEquals, |
| 1287 |
] { |
| 1288 |
assert!( |
| 1289 |
!eval(&empty, RuleField::Bpm, op, "128"), |
| 1290 |
"{op:?} on missing num" |
| 1291 |
); |
| 1292 |
} |
| 1293 |
assert!(!eval(&empty, RuleField::Bpm, RuleOp::Exists, "")); |
| 1294 |
assert!(eval(&empty, RuleField::Bpm, RuleOp::NotExists, "")); |
| 1295 |
} |
| 1296 |
|
| 1297 |
#[test] |
| 1298 |
fn boolean_field_matrix() { |
| 1299 |
let t = RuleContext { |
| 1300 |
is_loop: Some(true), |
| 1301 |
..Default::default() |
| 1302 |
}; |
| 1303 |
let f = RuleContext { |
| 1304 |
is_loop: Some(false), |
| 1305 |
..Default::default() |
| 1306 |
}; |
| 1307 |
let n = RuleContext::default(); |
| 1308 |
assert!(eval(&t, RuleField::IsLoop, RuleOp::IsTrue, "")); |
| 1309 |
assert!(!eval(&t, RuleField::IsLoop, RuleOp::IsFalse, "")); |
| 1310 |
assert!(eval(&f, RuleField::IsLoop, RuleOp::IsFalse, "")); |
| 1311 |
assert!(!eval(&f, RuleField::IsLoop, RuleOp::IsTrue, "")); |
| 1312 |
assert!(eval(&t, RuleField::IsLoop, RuleOp::Exists, "")); |
| 1313 |
assert!(eval(&n, RuleField::IsLoop, RuleOp::NotExists, "")); |
| 1314 |
assert!(!eval(&n, RuleField::IsLoop, RuleOp::IsTrue, "")); |
| 1315 |
assert!(!eval(&n, RuleField::IsLoop, RuleOp::IsFalse, "")); |
| 1316 |
|
| 1317 |
assert!(!eval(&t, RuleField::IsLoop, RuleOp::Contains, "true")); |
| 1318 |
assert!(!eval(&t, RuleField::IsLoop, RuleOp::Gt, "0")); |
| 1319 |
} |
| 1320 |
|
| 1321 |
#[test] |
| 1322 |
fn list_field_matrix() { |
| 1323 |
let ctx = RuleContext { |
| 1324 |
tags: vec!["instrument.drum.kick".into(), "character.punchy".into()], |
| 1325 |
..Default::default() |
| 1326 |
}; |
| 1327 |
|
| 1328 |
assert!(eval(&ctx, RuleField::Tag, RuleOp::Contains, "drum")); |
| 1329 |
assert!(eval(&ctx, RuleField::Tag, RuleOp::StartsWith, "instrument")); |
| 1330 |
assert!(eval( |
| 1331 |
&ctx, |
| 1332 |
RuleField::Tag, |
| 1333 |
RuleOp::Equals, |
| 1334 |
"character.punchy" |
| 1335 |
)); |
| 1336 |
assert!(!eval(&ctx, RuleField::Tag, RuleOp::Contains, "bass")); |
| 1337 |
|
| 1338 |
assert!(eval(&ctx, RuleField::Tag, RuleOp::NotContains, "bass")); |
| 1339 |
assert!(!eval(&ctx, RuleField::Tag, RuleOp::NotContains, "drum")); |
| 1340 |
assert!(eval(&ctx, RuleField::Tag, RuleOp::NotEquals, "nope")); |
| 1341 |
assert!(!eval( |
| 1342 |
&ctx, |
| 1343 |
RuleField::Tag, |
| 1344 |
RuleOp::NotEquals, |
| 1345 |
"character.punchy" |
| 1346 |
)); |
| 1347 |
|
| 1348 |
assert!(eval(&ctx, RuleField::Tag, RuleOp::Exists, "")); |
| 1349 |
assert!(!eval(&ctx, RuleField::Tag, RuleOp::NotExists, "")); |
| 1350 |
|
| 1351 |
let empty = RuleContext::default(); |
| 1352 |
assert!(!eval(&empty, RuleField::Tag, RuleOp::Exists, "")); |
| 1353 |
assert!(eval(&empty, RuleField::Tag, RuleOp::NotExists, "")); |
| 1354 |
|
| 1355 |
assert!(eval(&empty, RuleField::Tag, RuleOp::NotContains, "x")); |
| 1356 |
assert!(!eval(&empty, RuleField::Tag, RuleOp::Contains, "x")); |
| 1357 |
|
| 1358 |
assert!(!eval(&ctx, RuleField::Tag, RuleOp::Gt, "0")); |
| 1359 |
assert!(!eval(&ctx, RuleField::Tag, RuleOp::IsTrue, "")); |
| 1360 |
} |
| 1361 |
|
| 1362 |
#[test] |
| 1363 |
fn match_mode_all_vs_any_over_conditions() { |
| 1364 |
let ctx = RuleContext { |
| 1365 |
name: "kick".into(), |
| 1366 |
bpm: Some(90.0), |
| 1367 |
..Default::default() |
| 1368 |
}; |
| 1369 |
let conds = vec![ |
| 1370 |
cond(RuleField::Name, RuleOp::Contains, "kick"), |
| 1371 |
cond(RuleField::Bpm, RuleOp::Gt, "120"), |
| 1372 |
]; |
| 1373 |
let rule = |mode| Rule { |
| 1374 |
id: "r".into(), |
| 1375 |
name: "r".into(), |
| 1376 |
enabled: true, |
| 1377 |
priority: 0, |
| 1378 |
match_mode: mode, |
| 1379 |
conditions: conds.clone(), |
| 1380 |
actions: vec![], |
| 1381 |
created_at: 0, |
| 1382 |
}; |
| 1383 |
assert!(!rule_matches(&rule(MatchMode::All), &ctx)); |
| 1384 |
assert!(rule_matches(&rule(MatchMode::Any), &ctx)); |
| 1385 |
} |
| 1386 |
|
| 1387 |
#[test] |
| 1388 |
fn empty_conditions_match_unconditionally() { |
| 1389 |
let rule = Rule { |
| 1390 |
id: "r".into(), |
| 1391 |
name: "r".into(), |
| 1392 |
enabled: true, |
| 1393 |
priority: 0, |
| 1394 |
match_mode: MatchMode::All, |
| 1395 |
conditions: vec![], |
| 1396 |
actions: vec![], |
| 1397 |
created_at: 0, |
| 1398 |
}; |
| 1399 |
assert!(rule_matches(&rule, &RuleContext::default())); |
| 1400 |
} |
| 1401 |
} |
| 1402 |
|