1use crate::config::ReleaseFilterConfig;
2use lastfm_edit::{ScrobbleEdit, Track};
3use serde::{Deserialize, Serialize};
4
5#[must_use]
7pub fn create_no_op_edit(track: &Track) -> ScrobbleEdit {
8 ScrobbleEdit {
9 track_name_original: Some(track.name.clone()),
10 album_name_original: track.album.clone(),
11 artist_name_original: track.artist.clone(),
12 album_artist_name_original: track.album_artist.clone(),
13 track_name: Some(track.name.clone()),
14 album_name: track.album.clone(),
15 artist_name: track.artist.clone(),
16 album_artist_name: track
17 .album_artist
18 .clone()
19 .or_else(|| Some(track.artist.clone())),
20 timestamp: track.timestamp,
21 edit_all: true,
22 }
23}
24
25pub fn any_rules_apply(rules: &[RewriteRule], track: &Track) -> Result<bool, RewriteError> {
28 for rule in rules {
29 if rule.matches(track)? {
30 return Ok(true);
31 }
32 }
33 Ok(false)
34}
35
36pub fn any_rules_match(rules: &[RewriteRule], track: &Track) -> Result<bool, RewriteError> {
38 for rule in rules {
39 if rule.matches(track)? {
40 return Ok(true);
41 }
42 }
43 Ok(false)
44}
45
46pub fn apply_all_rules(
49 rules: &[RewriteRule],
50 edit: &mut ScrobbleEdit,
51) -> Result<bool, RewriteError> {
52 let mut any_changes = false;
53 for rule in rules {
54 if rule.matches_scrobble_edit(edit)? {
56 let changed = rule.apply(edit)?;
57 if changed {
58 any_changes = true;
59 let rule_name = rule.name.as_deref().unwrap_or("unnamed rule");
60 log::debug!(
61 "Applied rewrite rule '{}' to track '{}' by '{}'",
62 rule_name,
63 edit.track_name_original.as_deref().unwrap_or("unknown"),
64 &edit.artist_name_original
65 );
66 }
67 }
68 }
69 Ok(any_changes)
70}
71
72#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
135pub struct SdRule {
136 pub find: String,
138 pub replace: String,
140 pub flags: Option<String>,
142}
143
144impl SdRule {
145 #[must_use]
147 pub fn new(find: &str, replace: &str) -> Self {
148 Self {
149 find: find.to_string(),
150 replace: replace.to_string(),
151 flags: None,
152 }
153 }
154
155 #[must_use]
157 pub fn with_flags(mut self, flags: &str) -> Self {
158 self.flags = Some(flags.to_string());
159 self
160 }
161
162 pub fn apply(&self, input: &str) -> Result<String, RewriteError> {
165 let mut regex_builder = regex::RegexBuilder::new(&self.find);
168 regex_builder.multi_line(true);
169
170 if let Some(flags) = &self.flags {
172 for c in flags.chars() {
173 match c {
174 'c' => {
175 regex_builder.case_insensitive(false);
176 }
177 'i' => {
178 regex_builder.case_insensitive(true);
179 }
180 'm' => {}
181 'e' => {
182 regex_builder.multi_line(false);
183 }
184 's' => {
185 if !flags.contains('m') {
186 regex_builder.multi_line(false);
187 }
188 regex_builder.dot_matches_new_line(true);
189 }
190 _ => {}
191 }
192 }
193 }
194
195 let regex = regex_builder.build().map_err(RewriteError::RegexError)?;
196
197 if let Some(captures) = regex.captures(input) {
198 let mut result = self.replace.clone();
200
201 let escaped_dollar_placeholder = "\u{E000}ESCAPED_DOLLAR\u{E000}"; let escaped_lbrace_placeholder = "\u{E000}ESCAPED_LBRACE\u{E000}";
204 let escaped_rbrace_placeholder = "\u{E000}ESCAPED_RBRACE\u{E000}";
205 let escaped_backslash_placeholder = "\u{E000}ESCAPED_BACKSLASH\u{E000}";
206
207 result = result.replace(r"\\", escaped_backslash_placeholder);
209 result = result.replace(r"\$", escaped_dollar_placeholder);
210 result = result.replace("$$", escaped_dollar_placeholder);
211 result = result.replace(r"\{", escaped_lbrace_placeholder);
212 result = result.replace(r"\}", escaped_rbrace_placeholder);
213
214 for i in 0..captures.len() {
216 let placeholder = format!("${i}");
217 if let Some(capture) = captures.get(i) {
218 result = result.replace(&placeholder, capture.as_str());
219 }
220 }
221
222 for name in regex.capture_names().flatten() {
224 let placeholder = format!("${{{name}}}");
225 if let Some(capture) = captures.name(name) {
226 result = result.replace(&placeholder, capture.as_str());
227 }
228 }
229
230 result = result.replace(escaped_dollar_placeholder, "$");
232 result = result.replace(escaped_lbrace_placeholder, "{");
233 result = result.replace(escaped_rbrace_placeholder, "}");
234 result = result.replace(escaped_backslash_placeholder, "\\");
235
236 Ok(result)
237 } else {
238 Ok(input.to_string())
240 }
241 }
242
243 pub fn matches(&self, input: &str) -> Result<bool, RewriteError> {
245 let mut regex_builder = regex::RegexBuilder::new(&self.find);
246 regex_builder.multi_line(true);
247
248 if let Some(flags) = &self.flags {
250 for c in flags.chars() {
251 match c {
252 'c' => {
253 regex_builder.case_insensitive(false);
254 }
255 'i' => {
256 regex_builder.case_insensitive(true);
257 }
258 'm' => {}
259 'e' => {
260 regex_builder.multi_line(false);
261 }
262 's' => {
263 if !flags.contains('m') {
264 regex_builder.multi_line(false);
265 }
266 regex_builder.dot_matches_new_line(true);
267 }
268 _ => {}
269 }
270 }
271 }
272
273 let regex = regex_builder.build().map_err(RewriteError::RegexError)?;
274 Ok(regex.is_match(input))
275 }
276}
277
278#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
280pub struct RewriteRule {
281 pub name: Option<String>,
283 pub track_name: Option<SdRule>,
285 pub album_name: Option<SdRule>,
287 pub artist_name: Option<SdRule>,
289 pub album_artist_name: Option<SdRule>,
291 pub requires_confirmation: bool,
293 #[serde(default)]
296 pub requires_musicbrainz_confirmation: bool,
297 #[serde(default)]
300 pub musicbrainz_release_filters: Option<ReleaseFilterConfig>,
301}
302
303impl RewriteRule {
304 #[must_use]
306 pub const fn new() -> Self {
307 Self {
308 name: None,
309 track_name: None,
310 album_name: None,
311 artist_name: None,
312 album_artist_name: None,
313 requires_confirmation: false,
314 requires_musicbrainz_confirmation: false,
315 musicbrainz_release_filters: None,
316 }
317 }
318
319 #[must_use]
321 pub fn with_name<S: Into<String>>(mut self, name: S) -> Self {
322 self.name = Some(name.into());
323 self
324 }
325
326 #[must_use]
328 pub fn with_track_name(mut self, rule: SdRule) -> Self {
329 self.track_name = Some(rule);
330 self
331 }
332
333 #[must_use]
335 pub fn with_album_name(mut self, rule: SdRule) -> Self {
336 self.album_name = Some(rule);
337 self
338 }
339
340 #[must_use]
342 pub fn with_artist_name(mut self, rule: SdRule) -> Self {
343 self.artist_name = Some(rule);
344 self
345 }
346
347 #[must_use]
349 pub fn with_album_artist_name(mut self, rule: SdRule) -> Self {
350 self.album_artist_name = Some(rule);
351 self
352 }
353
354 #[must_use]
356 pub const fn with_confirmation_required(mut self, requires_confirmation: bool) -> Self {
357 self.requires_confirmation = requires_confirmation;
358 self
359 }
360
361 #[must_use]
363 pub const fn with_musicbrainz_confirmation_required(
364 mut self,
365 requires_musicbrainz_confirmation: bool,
366 ) -> Self {
367 self.requires_musicbrainz_confirmation = requires_musicbrainz_confirmation;
368 self
369 }
370
371 #[must_use]
374 pub fn with_musicbrainz_release_filters(mut self, filters: ReleaseFilterConfig) -> Self {
375 self.musicbrainz_release_filters = Some(filters);
376 self
377 }
378
379 pub fn matches(&self, track: &Track) -> Result<bool, RewriteError> {
387 let rule_name = self.name.as_deref().unwrap_or("unnamed");
388 let mut matched_fields = Vec::new();
389 let mut failed_fields = Vec::new();
390 let mut checked_fields = Vec::new();
391
392 if let Some(rule) = &self.track_name {
394 checked_fields.push("track_name");
395 if rule.matches(&track.name)? {
396 let track_name = &track.name;
397 matched_fields.push(format!("track_name('{track_name}')"));
398 } else {
399 let track_name = &track.name;
400 let pattern = &rule.find;
401 failed_fields.push(format!("track_name('{track_name}' ≠ pattern '{pattern}')"));
402 }
403 }
404
405 if let Some(rule) = &self.artist_name {
407 checked_fields.push("artist_name");
408 if rule.matches(&track.artist)? {
409 let artist_name = &track.artist;
410 matched_fields.push(format!("artist_name('{artist_name}')"));
411 } else {
412 let artist_name = &track.artist;
413 let pattern = &rule.find;
414 failed_fields.push(format!(
415 "artist_name('{artist_name}' ≠ pattern '{pattern}')"
416 ));
417 }
418 }
419
420 if let Some(rule) = &self.album_name {
422 checked_fields.push("album_name");
423 let album_name = track.album.as_deref().unwrap_or("");
424 if rule.matches(album_name)? {
425 matched_fields.push(format!("album_name('{album_name}')"));
426 } else {
427 failed_fields.push(format!(
428 "album_name('{album_name}' ≠ pattern '{}')",
429 rule.find
430 ));
431 }
432 }
433
434 if let Some(rule) = &self.album_artist_name {
436 checked_fields.push("album_artist_name");
437 if rule.matches("")? {
438 matched_fields.push("album_artist_name('')".to_string());
439 } else {
440 let pattern = &rule.find;
441 failed_fields.push(format!("album_artist_name('' ≠ pattern '{pattern}')"));
442 }
443 }
444
445 let rule_matches = failed_fields.is_empty();
446
447 if checked_fields.is_empty() {
449 let track_name = &track.name;
450 let track_artist = &track.artist;
451 log::debug!(
452 "Rule '{rule_name}' matches track '{track_name}' by '{track_artist}' (catch-all rule with no patterns)"
453 );
454 } else if rule_matches {
455 let track_name = &track.name;
456 let track_artist = &track.artist;
457 let matched = matched_fields.join(", ");
458 log::debug!(
459 "Rule '{rule_name}' matches track '{track_name}' by '{track_artist}' | Matched: [{matched}]"
460 );
461 } else {
462 let track_name = &track.name;
463 let track_artist = &track.artist;
464 let matched = if matched_fields.is_empty() {
465 "none".to_string()
466 } else {
467 matched_fields.join(", ")
468 };
469 let failed = failed_fields.join(", ");
470 log::debug!(
471 "Rule '{rule_name}' does not match track '{track_name}' by '{track_artist}' | Matched: [{matched}] | Failed: [{failed}]"
472 );
473 }
474
475 Ok(rule_matches)
476 }
477
478 fn check_field_match(
481 field_name: &str,
482 rule: &SdRule,
483 edit_field: Option<&str>,
484 matched_fields: &mut Vec<String>,
485 failed_fields: &mut Vec<String>,
486 ) -> Result<(), RewriteError> {
487 if let Some(field_value) = edit_field {
488 if rule.matches(field_value)? {
489 matched_fields.push(format!("{field_name}('{field_value}')"));
490 } else {
491 let pattern = &rule.find;
492 failed_fields.push(format!(
493 "{field_name}('{field_value}' ≠ pattern '{pattern}')"
494 ));
495 }
496 } else {
497 let pattern = &rule.find;
499 if pattern == ".*" {
500 matched_fields.push(format!("{field_name}(None - matched by .* pattern)"));
502 } else {
503 failed_fields.push(format!("{field_name}(None ≠ pattern '{pattern}' - cannot match pattern against None value)"));
505 }
506 }
507 Ok(())
508 }
509
510 pub fn matches_scrobble_edit(&self, edit: &ScrobbleEdit) -> Result<bool, RewriteError> {
522 let rule_name = self.name.as_deref().unwrap_or("unnamed");
523 let mut matched_fields = Vec::new();
524 let mut failed_fields = Vec::new();
525 let mut checked_fields = Vec::new();
526
527 if let Some(rule) = &self.track_name {
529 checked_fields.push("track_name");
530 Self::check_field_match(
531 "track_name",
532 rule,
533 edit.track_name.as_deref(),
534 &mut matched_fields,
535 &mut failed_fields,
536 )?;
537 }
538
539 if let Some(rule) = &self.artist_name {
541 checked_fields.push("artist_name");
542 Self::check_field_match(
544 "artist_name",
545 rule,
546 Some(&edit.artist_name),
547 &mut matched_fields,
548 &mut failed_fields,
549 )?;
550 }
551
552 if let Some(rule) = &self.album_name {
554 checked_fields.push("album_name");
555 Self::check_field_match(
556 "album_name",
557 rule,
558 edit.album_name.as_deref(),
559 &mut matched_fields,
560 &mut failed_fields,
561 )?;
562 }
563
564 if let Some(rule) = &self.album_artist_name {
566 checked_fields.push("album_artist_name");
567 Self::check_field_match(
568 "album_artist_name",
569 rule,
570 edit.album_artist_name.as_deref(),
571 &mut matched_fields,
572 &mut failed_fields,
573 )?;
574 }
575
576 let rule_matches = failed_fields.is_empty();
577
578 if checked_fields.is_empty() {
580 log::debug!(
581 "Rule '{rule_name}' matches ScrobbleEdit (catch-all rule with no patterns)"
582 );
583 } else if rule_matches {
584 let matched = matched_fields.join(", ");
585 log::debug!("Rule '{rule_name}' matches ScrobbleEdit | Matched: [{matched}]");
586 } else {
587 let matched = if matched_fields.is_empty() {
588 "none".to_string()
589 } else {
590 matched_fields.join(", ")
591 };
592 let failed = failed_fields.join(", ");
593 log::debug!("Rule '{rule_name}' does not match ScrobbleEdit | Matched: [{matched}] | Failed: [{failed}]");
594 }
595
596 Ok(rule_matches)
597 }
598
599 pub fn apply(&self, edit: &mut ScrobbleEdit) -> Result<bool, RewriteError> {
605 let mut has_changes = false;
606
607 if let Some(rule) = &self.track_name {
609 if let Some(current_value) = &edit.track_name {
610 let new_value = rule.apply(current_value)?;
611 if new_value != *current_value {
612 edit.track_name = Some(new_value);
613 has_changes = true;
614 }
615 }
616 }
617
618 if let Some(rule) = &self.artist_name {
620 let current_value = &edit.artist_name;
621 let new_value = rule.apply(current_value)?;
622 if new_value != *current_value {
623 edit.artist_name = new_value;
624 has_changes = true;
625 }
626 }
627
628 if let Some(rule) = &self.album_name {
630 if let Some(current_value) = &edit.album_name {
631 let new_value = rule.apply(current_value)?;
632 if new_value != *current_value {
633 edit.album_name = Some(new_value);
634 has_changes = true;
635 }
636 }
637 }
638
639 if let Some(rule) = &self.album_artist_name {
641 if let Some(current_value) = &edit.album_artist_name {
642 let new_value = rule.apply(current_value)?;
643 if new_value != *current_value {
644 edit.album_artist_name = Some(new_value);
645 has_changes = true;
646 }
647 }
648 }
649
650 Ok(has_changes)
651 }
652}
653
654impl Default for RewriteRule {
655 fn default() -> Self {
656 Self::new()
657 }
658}
659
660#[derive(Debug, thiserror::Error)]
662pub enum RewriteError {
663 #[error("Regex error: {0}")]
664 RegexError(#[from] regex::Error),
665 #[error("Invalid replacement capture: {0}")]
666 InvalidReplaceCapture(String),
667 #[error("Invalid UTF-8 in result: {0}")]
668 InvalidUtf8(#[from] std::string::FromUtf8Error),
669}
670
671#[must_use]
676pub fn load_comprehensive_default_rules() -> Vec<RewriteRule> {
677 use crate::default_rules::load_all_default_rules;
678
679 match load_all_default_rules() {
680 Ok(rules) => {
681 let rewrite_rules: Vec<RewriteRule> =
682 rules.into_iter().map(|rule| rule.into()).collect();
683 log::info!(
684 "Loaded {} comprehensive default rewrite rules (including special editions)",
685 rewrite_rules.len()
686 );
687 rewrite_rules
688 }
689 Err(e) => {
690 log::warn!(
691 "Failed to load comprehensive default rules: {e}, falling back to basic rules"
692 );
693 default_rules()
694 }
695 }
696}
697
698#[must_use]
700pub fn default_rules() -> Vec<RewriteRule> {
701 vec![
702 RewriteRule::new()
704 .with_track_name(SdRule::new(
705 r" - \d{4} [Rr]emaster| - [Rr]emaster \d{4}| - [Rr]emaster| \(\d{4} [Rr]emaster\)| \([Rr]emaster \d{4}\)| \([Rr]emaster\)",
706 ""
707 )),
708
709 RewriteRule::new()
711 .with_artist_name(SdRule::new(r" [Ff]t\. | [Ff]eaturing ", " feat. ")),
712
713 RewriteRule::new()
715 .with_track_name(SdRule::new(r"\s+", " "))
716 .with_artist_name(SdRule::new(r"\s+", " ")),
717
718 RewriteRule::new()
720 .with_track_name(SdRule::new(r"^\s+|\s+$", ""))
721 .with_artist_name(SdRule::new(r"^\s+|\s+$", "")),
722
723 RewriteRule::new()
725 .with_track_name(SdRule::new(r" \(Explicit\)$| - Explicit$", "")),
726 ]
727}