1use serde::{Deserialize, Serialize};
30use serde_json::Value;
31
32const MAX_DEPTH: usize = 8;
36const MAX_CONDITIONS: usize = 200;
37
38#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
40pub struct Rules {
41 #[serde(rename = "match", default)]
42 pub matching: Match,
43 #[serde(default)]
44 pub rules: Vec<Condition>,
45 #[serde(default, skip_serializing_if = "Vec::is_empty")]
47 pub sort: Vec<Sort>,
48 #[serde(default, skip_serializing_if = "Option::is_none")]
49 pub limit: Option<u32>,
50}
51
52#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
54#[serde(rename_all = "lowercase")]
55pub enum Match {
56 #[default]
57 All,
58 Any,
59}
60
61#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
64#[serde(try_from = "Value", into = "Value")]
65pub enum Condition {
66 Group {
67 matching: Match,
68 rules: Vec<Condition>,
69 },
70 Rule(Rule),
71}
72
73#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
74pub struct Rule {
75 pub field: Field,
76 pub op: Op,
77 pub value: Value,
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
83#[serde(rename_all = "camelCase")]
84pub enum Field {
85 Title,
86 Artist,
87 AlbumArtist,
88 Album,
89 Genre,
90 Format,
92 Path,
94 Year,
95 Duration,
97 BitDepth,
98 SampleRate,
99 TrackNumber,
100 DiscNumber,
101 PlayCount,
102 LastPlayed,
103 DateAdded,
105 Favourite,
106}
107
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110pub enum Kind {
111 Text,
112 Number,
113 Date,
114 Bool,
115}
116
117impl Field {
118 pub const ALL: [Field; 17] = [
119 Field::Title,
120 Field::Artist,
121 Field::AlbumArtist,
122 Field::Album,
123 Field::Genre,
124 Field::Format,
125 Field::Path,
126 Field::Year,
127 Field::Duration,
128 Field::BitDepth,
129 Field::SampleRate,
130 Field::TrackNumber,
131 Field::DiscNumber,
132 Field::PlayCount,
133 Field::LastPlayed,
134 Field::DateAdded,
135 Field::Favourite,
136 ];
137
138 pub fn kind(self) -> Kind {
139 match self {
140 Field::Title
141 | Field::Artist
142 | Field::AlbumArtist
143 | Field::Album
144 | Field::Genre
145 | Field::Format
146 | Field::Path => Kind::Text,
147 Field::Year
148 | Field::Duration
149 | Field::BitDepth
150 | Field::SampleRate
151 | Field::TrackNumber
152 | Field::DiscNumber
153 | Field::PlayCount => Kind::Number,
154 Field::LastPlayed | Field::DateAdded => Kind::Date,
155 Field::Favourite => Kind::Bool,
156 }
157 }
158
159 pub fn name(self) -> &'static str {
160 match self {
161 Field::Title => "title",
162 Field::Artist => "artist",
163 Field::AlbumArtist => "albumArtist",
164 Field::Album => "album",
165 Field::Genre => "genre",
166 Field::Format => "format",
167 Field::Path => "path",
168 Field::Year => "year",
169 Field::Duration => "duration",
170 Field::BitDepth => "bitDepth",
171 Field::SampleRate => "sampleRate",
172 Field::TrackNumber => "trackNumber",
173 Field::DiscNumber => "discNumber",
174 Field::PlayCount => "playCount",
175 Field::LastPlayed => "lastPlayed",
176 Field::DateAdded => "dateAdded",
177 Field::Favourite => "favourite",
178 }
179 }
180
181 fn parse(name: &str) -> Result<Self, String> {
182 Field::ALL
183 .into_iter()
184 .find(|f| f.name().eq_ignore_ascii_case(name))
185 .ok_or_else(|| {
186 let known: Vec<&str> = Field::ALL.iter().map(|f| f.name()).collect();
187 format!("unknown field '{name}'; known fields: {}", known.join(", "))
188 })
189 }
190}
191
192#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
193#[serde(rename_all = "camelCase")]
194pub enum Op {
195 Is,
196 IsNot,
197 Contains,
198 NotContains,
199 StartsWith,
200 EndsWith,
201 Gt,
202 Lt,
203 InTheRange,
205 Before,
207 After,
208 InTheLast,
210 NotInTheLast,
212}
213
214impl Op {
215 const ALL: [Op; 13] = [
216 Op::Is,
217 Op::IsNot,
218 Op::Contains,
219 Op::NotContains,
220 Op::StartsWith,
221 Op::EndsWith,
222 Op::Gt,
223 Op::Lt,
224 Op::InTheRange,
225 Op::Before,
226 Op::After,
227 Op::InTheLast,
228 Op::NotInTheLast,
229 ];
230
231 pub fn name(self) -> &'static str {
232 match self {
233 Op::Is => "is",
234 Op::IsNot => "isNot",
235 Op::Contains => "contains",
236 Op::NotContains => "notContains",
237 Op::StartsWith => "startsWith",
238 Op::EndsWith => "endsWith",
239 Op::Gt => "gt",
240 Op::Lt => "lt",
241 Op::InTheRange => "inTheRange",
242 Op::Before => "before",
243 Op::After => "after",
244 Op::InTheLast => "inTheLast",
245 Op::NotInTheLast => "notInTheLast",
246 }
247 }
248
249 fn parse(name: &str) -> Result<Self, String> {
250 Op::ALL
251 .into_iter()
252 .find(|o| o.name().eq_ignore_ascii_case(name))
253 .ok_or_else(|| {
254 let known: Vec<&str> = Op::ALL.iter().map(|o| o.name()).collect();
255 format!(
256 "unknown operator '{name}'; known operators: {}",
257 known.join(", ")
258 )
259 })
260 }
261
262 pub fn applies_to(self, kind: Kind) -> bool {
264 use Op::*;
265 match kind {
266 Kind::Text => matches!(
267 self,
268 Is | IsNot | Contains | NotContains | StartsWith | EndsWith
269 ),
270 Kind::Number => matches!(self, Is | IsNot | Gt | Lt | InTheRange),
271 Kind::Date => matches!(self, Before | After | InTheLast | NotInTheLast | InTheRange),
272 Kind::Bool => matches!(self, Is | IsNot),
273 }
274 }
275}
276
277#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
279#[serde(try_from = "Value", into = "Value")]
280pub struct Sort {
281 pub field: Option<Field>,
283 pub desc: bool,
284}
285
286impl TryFrom<Value> for Condition {
287 type Error = String;
288
289 fn try_from(value: Value) -> Result<Self, String> {
290 let Value::Object(map) = &value else {
291 return Err(format!("a condition must be an object, not {value}"));
292 };
293 if map.contains_key("rules") {
294 let matching = match map.get("match") {
295 None => Match::All,
296 Some(m) => serde_json::from_value(m.clone())
297 .map_err(|_| format!("'match' must be \"all\" or \"any\", not {m}"))?,
298 };
299 let rules = serde_json::from_value(map["rules"].clone()).map_err(|e| e.to_string())?;
300 return Ok(Condition::Group { matching, rules });
301 }
302 let text = |key: &str| {
303 map.get(key)
304 .and_then(Value::as_str)
305 .ok_or_else(|| format!("a condition needs '{key}' as a string: {value}"))
306 };
307 let field = Field::parse(text("field")?)?;
308 let op = Op::parse(text("op")?)?;
309 let value = map
310 .get("value")
311 .cloned()
312 .ok_or_else(|| format!("a condition needs a 'value': {value}"))?;
313 Ok(Condition::Rule(Rule { field, op, value }))
314 }
315}
316
317impl From<Condition> for Value {
318 fn from(c: Condition) -> Value {
319 match c {
320 Condition::Group { matching, rules } => {
321 serde_json::json!({ "match": matching, "rules": rules })
322 }
323 Condition::Rule(r) => {
324 serde_json::json!({ "field": r.field, "op": r.op, "value": r.value })
325 }
326 }
327 }
328}
329
330impl TryFrom<Value> for Sort {
331 type Error = String;
332
333 fn try_from(value: Value) -> Result<Self, String> {
334 let (name, desc) = match &value {
335 Value::String(s) => (s.as_str(), false),
336 Value::Object(map) => (
337 map.get("field")
338 .and_then(Value::as_str)
339 .ok_or_else(|| format!("a sort needs 'field' as a string: {value}"))?,
340 map.get("desc").and_then(Value::as_bool).unwrap_or(false),
341 ),
342 _ => return Err(format!("a sort must be an object or a field name: {value}")),
343 };
344 if name.eq_ignore_ascii_case("random") {
345 return Ok(Sort { field: None, desc });
346 }
347 Ok(Sort {
348 field: Some(Field::parse(name)?),
349 desc,
350 })
351 }
352}
353
354impl From<Sort> for Value {
355 fn from(s: Sort) -> Value {
356 let field = s.field.map_or("random", Field::name);
357 serde_json::json!({ "field": field, "desc": s.desc })
358 }
359}
360
361impl Rules {
362 pub fn parse(json: &str) -> Result<Self, String> {
365 let rules: Rules = serde_json::from_str(json).map_err(|e| e.to_string())?;
366 rules.check()?;
367 Ok(rules)
368 }
369
370 pub fn from_value(value: Value) -> Result<Self, String> {
371 let rules: Rules = serde_json::from_value(value).map_err(|e| e.to_string())?;
372 rules.check()?;
373 Ok(rules)
374 }
375
376 pub fn to_json(&self) -> String {
377 serde_json::to_string(self).expect("rules serialise")
378 }
379
380 pub fn is_random(&self) -> bool {
382 self.sort.iter().any(|s| s.field.is_none())
383 }
384
385 pub fn uses(&self, fields: &[Field]) -> bool {
387 fn any(rules: &[Condition], fields: &[Field]) -> bool {
388 rules.iter().any(|c| match c {
389 Condition::Group { rules, .. } => any(rules, fields),
390 Condition::Rule(r) => fields.contains(&r.field),
391 })
392 }
393 any(&self.rules, fields)
394 || self
395 .sort
396 .iter()
397 .any(|s| s.field.is_some_and(|f| fields.contains(&f)))
398 }
399
400 pub fn check(&self) -> Result<(), String> {
403 let mut count = 0;
404 check_group(&self.rules, 1, &mut count)
405 }
406}
407
408fn check_group(rules: &[Condition], depth: usize, count: &mut usize) -> Result<(), String> {
409 if depth > MAX_DEPTH {
410 return Err(format!("groups nest more than {MAX_DEPTH} deep"));
411 }
412 for condition in rules {
413 *count += 1;
414 if *count > MAX_CONDITIONS {
415 return Err(format!("more than {MAX_CONDITIONS} conditions"));
416 }
417 match condition {
418 Condition::Group { rules, .. } => check_group(rules, depth + 1, count)?,
419 Condition::Rule(rule) => {
420 rule.operand()?;
421 }
422 }
423 }
424 Ok(())
425}
426
427#[derive(Debug, Clone, PartialEq)]
429pub enum Operand {
430 Text(String),
431 Number(f64),
432 NumberRange(f64, f64),
433 Instant(i64),
435 InstantRange(i64, i64),
437 Days(f64),
438 Bool(bool),
439}
440
441impl Rule {
442 pub fn operand(&self) -> Result<Operand, String> {
443 let kind = self.field.kind();
444 let (field, op) = (self.field.name(), self.op.name());
445 if !self.op.applies_to(kind) {
446 let allowed: Vec<&str> = Op::ALL
447 .iter()
448 .filter(|o| o.applies_to(kind))
449 .map(|o| o.name())
450 .collect();
451 return Err(format!(
452 "'{field}' does not take '{op}'; it takes {}",
453 allowed.join(", ")
454 ));
455 }
456 let wrong = |wanted: &str| format!("'{field} {op}' wants {wanted}, not {}", self.value);
457 let pair = || match &self.value {
458 Value::Array(items) if items.len() == 2 => Ok((&items[0], &items[1])),
459 _ => Err(wrong("a pair, [low, high]")),
460 };
461 Ok(match (kind, self.op) {
462 (Kind::Text, _) => Operand::Text(
463 self.value
464 .as_str()
465 .ok_or_else(|| wrong("a string"))?
466 .to_owned(),
467 ),
468 (Kind::Bool, _) => {
469 Operand::Bool(self.value.as_bool().ok_or_else(|| wrong("true or false"))?)
470 }
471 (Kind::Number, Op::InTheRange) => {
472 let (lo, hi) = pair()?;
473 let num = |v: &Value| v.as_f64().ok_or_else(|| wrong("two numbers"));
474 Operand::NumberRange(num(lo)?, num(hi)?)
475 }
476 (Kind::Number, _) => {
477 Operand::Number(self.value.as_f64().ok_or_else(|| wrong("a number"))?)
478 }
479 (Kind::Date, Op::InTheLast | Op::NotInTheLast) => {
480 let days = self
481 .value
482 .as_f64()
483 .ok_or_else(|| wrong("a number of days"))?;
484 if days < 0.0 {
485 return Err(wrong("a positive number of days"));
486 }
487 Operand::Days(days)
488 }
489 (Kind::Date, Op::InTheRange) => {
490 let (lo, hi) = pair()?;
491 let day = |v: &Value| {
492 v.as_str()
493 .and_then(parse_day)
494 .ok_or_else(|| wrong("two dates, YYYY-MM-DD"))
495 };
496 Operand::InstantRange(day(lo)?, day(hi)? + 86_399)
497 }
498 (Kind::Date, _) => Operand::Instant(
499 self.value
500 .as_str()
501 .and_then(parse_day)
502 .ok_or_else(|| wrong("a date, YYYY-MM-DD"))?,
503 ),
504 })
505 }
506}
507
508fn parse_day(s: &str) -> Option<i64> {
510 let day = chrono::NaiveDate::parse_from_str(s.get(..10).unwrap_or(s), "%Y-%m-%d").ok()?;
511 Some(day.and_hms_opt(0, 0, 0)?.and_utc().timestamp())
512}
513
514#[derive(Debug, Clone, PartialEq)]
516pub struct Nsp {
517 pub name: Option<String>,
518 pub comment: Option<String>,
519 pub rules: Rules,
520}
521
522pub fn from_nsp(json: &str) -> Result<Nsp, String> {
531 let root: Value = serde_json::from_str(json).map_err(|e| format!("not JSON: {e}"))?;
532 let Value::Object(map) = &root else {
533 return Err("not a JSON object".into());
534 };
535 let text = |key: &str| map.get(key).and_then(Value::as_str).map(str::to_owned);
536 let (matching, rules) = nsp_group(&root)?.ok_or("neither 'all' nor 'any' at the top")?;
537
538 let desc = text("order").is_some_and(|o| o.eq_ignore_ascii_case("desc"));
539 let mut sort = Vec::new();
540 if let Some(keys) = text("sort") {
541 for key in keys.split(',').map(str::trim).filter(|k| !k.is_empty()) {
544 let (key, desc) = match key.as_bytes()[0] {
545 b'-' => (&key[1..], true),
546 b'+' => (&key[1..], false),
547 _ => (key, desc),
548 };
549 if key.eq_ignore_ascii_case("random") {
550 sort.push(Sort { field: None, desc });
551 } else {
552 sort.push(Sort {
553 field: Some(nsp_field(key)?),
554 desc,
555 });
556 }
557 }
558 }
559 let limit = match map.get("limit") {
560 None | Some(Value::Null) => None,
561 Some(v) => Some(
562 v.as_u64()
563 .and_then(|n| u32::try_from(n).ok())
564 .ok_or_else(|| format!("'limit' must be a whole number, not {v}"))?,
565 ),
566 };
567 let rules = Rules {
568 matching,
569 rules,
570 sort,
571 limit: limit.filter(|&n| n > 0),
572 };
573 rules.check()?;
574 Ok(Nsp {
575 name: text("name").filter(|n| !n.trim().is_empty()),
576 comment: text("comment").filter(|c| !c.trim().is_empty()),
577 rules,
578 })
579}
580
581fn nsp_group(value: &Value) -> Result<Option<(Match, Vec<Condition>)>, String> {
583 for (key, matching) in [("all", Match::All), ("any", Match::Any)] {
584 if let Some(list) = value.get(key) {
585 let list = list
586 .as_array()
587 .ok_or_else(|| format!("'{key}' must be a list"))?;
588 let rules = list.iter().map(nsp_condition).collect::<Result<_, _>>()?;
589 return Ok(Some((matching, rules)));
590 }
591 }
592 Ok(None)
593}
594
595fn nsp_condition(value: &Value) -> Result<Condition, String> {
596 if let Some((matching, rules)) = nsp_group(value)? {
597 return Ok(Condition::Group { matching, rules });
598 }
599 let single = |v: &Value| -> Option<(String, Value)> {
600 let map = v.as_object()?;
601 (map.len() == 1).then(|| map.iter().next().map(|(k, v)| (k.clone(), v.clone())))?
602 };
603 let (op_name, inner) = single(value).ok_or_else(|| format!("not a condition: {value}"))?;
604 let (field_name, raw) =
605 single(&inner).ok_or_else(|| format!("'{op_name}' wants one field: {inner}"))?;
606 let op = match op_name.to_ascii_lowercase().as_str() {
607 "inplaylist" | "notinplaylist" => {
608 return Err(format!("'{op_name}' (other playlists) is not supported"));
609 }
610 _ => Op::parse(&op_name)?,
611 };
612 let field = nsp_field(&field_name)?;
613 let value = nsp_value(field.kind(), op, raw);
614 let rule = Rule { field, op, value };
615 rule.operand()?;
616 Ok(Condition::Rule(rule))
617}
618
619fn nsp_field(name: &str) -> Result<Field, String> {
621 Ok(match name.to_ascii_lowercase().as_str() {
622 "title" => Field::Title,
623 "artist" => Field::Artist,
624 "albumartist" => Field::AlbumArtist,
625 "album" => Field::Album,
626 "genre" => Field::Genre,
627 "filetype" => Field::Format,
628 "filepath" => Field::Path,
629 "year" => Field::Year,
630 "duration" => Field::Duration,
631 "bitdepth" => Field::BitDepth,
632 "samplerate" => Field::SampleRate,
633 "tracknumber" => Field::TrackNumber,
634 "discnumber" => Field::DiscNumber,
635 "playcount" => Field::PlayCount,
636 "lastplayed" => Field::LastPlayed,
637 "dateadded" => Field::DateAdded,
638 "loved" => Field::Favourite,
639 _ => return Err(format!("field '{name}' is not supported")),
640 })
641}
642
643fn nsp_value(kind: Kind, op: Op, raw: Value) -> Value {
646 let number = |v: Value| match &v {
647 Value::String(s) => s
648 .trim()
649 .parse::<f64>()
650 .ok()
651 .and_then(serde_json::Number::from_f64)
652 .map_or(v, Value::Number),
653 _ => v,
654 };
655 match (kind, op, raw) {
656 (Kind::Number, Op::InTheRange, Value::Array(items)) => {
657 Value::Array(items.into_iter().map(number).collect())
658 }
659 (Kind::Number, _, v) | (Kind::Date, Op::InTheLast | Op::NotInTheLast, v) => number(v),
660 (Kind::Bool, _, Value::String(s)) => match s.trim().to_ascii_lowercase().as_str() {
661 "true" => Value::Bool(true),
662 "false" => Value::Bool(false),
663 _ => Value::String(s),
664 },
665 (_, _, v) => v,
666 }
667}
668
669#[cfg(test)]
670mod tests {
671 use super::*;
672
673 #[test]
674 fn rules_round_trip_through_json() {
675 let json = r#"{
676 "match": "all",
677 "rules": [
678 { "field": "playCount", "op": "gt", "value": 5 },
679 { "match": "any", "rules": [
680 { "field": "genre", "op": "contains", "value": "jazz" },
681 { "field": "favourite", "op": "is", "value": true }
682 ] }
683 ],
684 "sort": [{ "field": "lastPlayed", "desc": true }, "random"],
685 "limit": 100
686 }"#;
687 let rules = Rules::parse(json).unwrap();
688 assert_eq!(rules.rules.len(), 2);
689 assert_eq!(rules.sort[0].field, Some(Field::LastPlayed));
690 assert!(rules.is_random());
691 assert_eq!(Rules::parse(&rules.to_json()).unwrap(), rules);
692 }
693
694 #[test]
695 fn errors_name_what_is_wrong() {
696 let err = |json: &str| Rules::parse(json).unwrap_err();
697 assert!(
698 err(r#"{"rules":[{"field":"plays","op":"gt","value":1}]}"#)
699 .contains("unknown field 'plays'")
700 );
701 assert!(
702 err(r#"{"rules":[{"field":"title","op":"gt","value":1}]}"#)
703 .contains("does not take 'gt'")
704 );
705 assert!(
706 err(r#"{"rules":[{"field":"year","op":"is","value":"x"}]}"#).contains("wants a number")
707 );
708 assert!(
709 err(r#"{"rules":[{"field":"lastPlayed","op":"before","value":"soon"}]}"#)
710 .contains("YYYY-MM-DD")
711 );
712 assert!(
713 err(r#"{"rules":[{"field":"year","op":"inTheRange","value":[1990]}]}"#)
714 .contains("pair")
715 );
716 }
717
718 #[test]
719 fn nesting_is_bounded() {
720 let mut json = r#"{"field":"year","op":"gt","value":1}"#.to_string();
721 for _ in 0..=MAX_DEPTH {
722 json = format!(r#"{{"rules":[{json}]}}"#);
723 }
724 assert!(Rules::parse(&json).unwrap_err().contains("nest"));
725 }
726
727 #[test]
728 fn a_navidrome_file_reads_into_the_model() {
729 let nsp = from_nsp(
730 r#"{
731 "name": "80's Favourites",
732 "comment": "Loved, from the eighties",
733 "all": [
734 { "any": [ {"is": {"loved": true}}, {"gt": {"playCount": "10"}} ] },
735 { "inTheRange": { "year": [1981, 1990] } },
736 { "inTheLast": { "lastPlayed": 30 } },
737 { "contains": { "Genre": "synth" } }
738 ],
739 "sort": "-playcount,title",
740 "order": "asc",
741 "limit": 25
742 }"#,
743 )
744 .unwrap();
745 assert_eq!(nsp.name.as_deref(), Some("80's Favourites"));
746 let rules = nsp.rules;
747 assert_eq!(rules.matching, Match::All);
748 assert_eq!(rules.limit, Some(25));
749 assert_eq!(
750 rules.sort,
751 vec![
752 Sort {
753 field: Some(Field::PlayCount),
754 desc: true
755 },
756 Sort {
757 field: Some(Field::Title),
758 desc: false
759 },
760 ]
761 );
762 let Condition::Group {
763 matching,
764 rules: any,
765 } = &rules.rules[0]
766 else {
767 panic!("expected a group")
768 };
769 assert_eq!(*matching, Match::Any);
770 assert_eq!(
771 any[1],
772 Condition::Rule(Rule {
773 field: Field::PlayCount,
774 op: Op::Gt,
775 value: serde_json::json!(10.0)
776 }),
777 "a number written as a string reads as a number"
778 );
779 assert!(matches!(
780 &rules.rules[3],
781 Condition::Rule(Rule {
782 field: Field::Genre,
783 op: Op::Contains,
784 ..
785 })
786 ));
787 }
788
789 #[test]
790 fn a_navidrome_file_with_an_unsupported_field_is_refused() {
791 let err = from_nsp(r#"{"all":[{"gt":{"rating":3}}]}"#).unwrap_err();
792 assert!(err.contains("rating"), "{err}");
793 let err = from_nsp(r#"{"all":[{"inPlaylist":{"id":"x"}}]}"#).unwrap_err();
794 assert!(err.contains("inPlaylist"), "{err}");
795 assert!(from_nsp(r#"{"sort":"title"}"#).is_err());
796 }
797
798 #[test]
799 fn random_sort_in_a_navidrome_file() {
800 let nsp =
801 from_nsp(r#"{"any":[{"is":{"loved":"true"}}],"sort":"random","limit":50}"#).unwrap();
802 assert!(nsp.rules.is_random());
803 assert_eq!(nsp.name, None);
804 }
805}