Skip to main content

scrobble_scrubber/
scrub_action_provider.rs

1use crate::persistence::{PendingEdit, PendingRewriteRule, RewriteRulesState};
2use crate::rewrite::{RewriteError, RewriteRule};
3use async_trait::async_trait;
4use lastfm_edit::{ScrobbleEdit, Track};
5use std::error::Error;
6use std::fmt;
7
8/// Generic error type for action providers
9#[derive(Debug)]
10pub struct ActionProviderError(pub String);
11
12impl fmt::Display for ActionProviderError {
13    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
14        write!(f, "Action provider error: {}", self.0)
15    }
16}
17
18impl Error for ActionProviderError {}
19
20impl From<RewriteError> for ActionProviderError {
21    fn from(err: RewriteError) -> Self {
22        Self(format!("Rewrite error: {err}"))
23    }
24}
25
26impl From<String> for ActionProviderError {
27    fn from(msg: String) -> Self {
28        Self(msg)
29    }
30}
31
32impl From<&str> for ActionProviderError {
33    fn from(msg: &str) -> Self {
34        Self(msg.to_string())
35    }
36}
37
38/// Represents a suggested action from an external source (LLM, API, etc.)
39#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
40pub enum ScrubActionSuggestion {
41    /// Suggest an immediate scrobble edit
42    Edit(ScrobbleEdit),
43    /// Propose a new rewrite rule
44    ProposeRule {
45        rule: RewriteRule,
46        motivation: String,
47    },
48    /// No action needed
49    NoAction,
50}
51
52/// Context wrapper for suggestions that includes confirmation requirements
53#[derive(Debug, Clone)]
54pub struct SuggestionWithContext {
55    pub suggestion: ScrubActionSuggestion,
56    pub requires_confirmation: bool,
57    pub provider_name: String,
58}
59
60impl SuggestionWithContext {
61    pub fn new(
62        suggestion: ScrubActionSuggestion,
63        requires_confirmation: bool,
64        provider_name: String,
65    ) -> Self {
66        Self {
67            suggestion,
68            requires_confirmation,
69            provider_name,
70        }
71    }
72
73    pub fn edit_with_confirmation(
74        edit: ScrobbleEdit,
75        requires_confirmation: bool,
76        provider_name: String,
77    ) -> Self {
78        Self::new(
79            ScrubActionSuggestion::Edit(edit),
80            requires_confirmation,
81            provider_name,
82        )
83    }
84
85    pub fn propose_rule_with_confirmation(
86        rule: RewriteRule,
87        motivation: String,
88        requires_confirmation: bool,
89        provider_name: String,
90    ) -> Self {
91        Self::new(
92            ScrubActionSuggestion::ProposeRule { rule, motivation },
93            requires_confirmation,
94            provider_name,
95        )
96    }
97
98    pub fn no_action(provider_name: String) -> Self {
99        Self::new(ScrubActionSuggestion::NoAction, false, provider_name)
100    }
101}
102
103/// Trait for external providers that can suggest scrobble actions
104#[async_trait]
105pub trait ScrubActionProvider: Send + Sync {
106    type Error: std::error::Error + Send + Sync + 'static;
107
108    /// Analyze multiple tracks and provide suggestions for improvements
109    /// Returns a vector of (track_index, suggestions) pairs
110    ///
111    /// Optional context parameters help avoid duplicate suggestions:
112    /// - pending_edits: tracks that already have pending edits awaiting approval
113    /// - pending_rules: rewrite rules that are already pending approval
114    async fn analyze_tracks(
115        &self,
116        tracks: &[Track],
117        pending_edits: Option<&[PendingEdit]>,
118        pending_rules: Option<&[PendingRewriteRule]>,
119    ) -> Result<Vec<(usize, Vec<SuggestionWithContext>)>, Self::Error>;
120
121    /// Get a human-readable name for this provider
122    fn provider_name(&self) -> &str;
123}
124
125/// Rewrite rules-based action provider
126pub struct RewriteRulesScrubActionProvider {
127    rules: Vec<RewriteRule>,
128}
129
130impl RewriteRulesScrubActionProvider {
131    #[must_use]
132    pub fn new(rules_state: &RewriteRulesState) -> Self {
133        Self {
134            rules: rules_state.rewrite_rules.clone(),
135        }
136    }
137
138    #[must_use]
139    pub const fn from_rules(rules: Vec<RewriteRule>) -> Self {
140        Self { rules }
141    }
142
143    // Apply rules sequentially to a track, gating on per-rule MusicBrainz confirmation when requested.
144    // Returns Some((final_edit, requires_confirmation)) if any changes applied, otherwise None.
145    async fn apply_rules_sequentially(
146        &self,
147        track: &Track,
148    ) -> Result<Option<(ScrobbleEdit, bool)>, ActionProviderError> {
149        let mut edit = crate::rewrite::create_no_op_edit(track);
150        let mut any_changes = false;
151        let mut requires_confirmation_applied = false;
152
153        for rule in &self.rules {
154            if !rule.matches_scrobble_edit(&edit)? {
155                continue;
156            }
157
158            let mut candidate = edit.clone();
159            let changed = rule.apply(&mut candidate)?;
160            if !changed {
161                continue;
162            }
163
164            if rule.requires_musicbrainz_confirmation {
165                let confirmed = Self::verify_with_musicbrainz_using_rule_filters(
166                    &candidate,
167                    track,
168                    rule.musicbrainz_release_filters.as_ref(),
169                )
170                .await?;
171                if !confirmed {
172                    log::info!(
173                        "Rewrite rule '{}' rejected by MusicBrainz confirmation for track '{} - {}' (album: {})",
174                        rule.name.as_deref().unwrap_or("Unnamed"),
175                        track.artist,
176                        track.name,
177                        track.album.as_deref().unwrap_or("none")
178                    );
179                    continue; // Skip this rule only
180                }
181            }
182
183            // Accept candidate
184            edit = candidate;
185            any_changes = true;
186            requires_confirmation_applied |= rule.requires_confirmation;
187        }
188
189        if any_changes {
190            Ok(Some((edit, requires_confirmation_applied)))
191        } else {
192            Ok(None)
193        }
194    }
195
196    // Verify that the candidate edit corresponds to a real MB match using rule-specific filters
197    async fn verify_with_musicbrainz_using_rule_filters(
198        candidate: &ScrobbleEdit,
199        track: &Track,
200        release_filters: Option<&crate::config::ReleaseFilterConfig>,
201    ) -> Result<bool, ActionProviderError> {
202        let artist = candidate.artist_name.clone();
203        let title = candidate
204            .track_name
205            .clone()
206            .unwrap_or_else(|| track.name.clone());
207        let album = candidate.album_name.clone();
208
209        // Use rule-specific filters if provided, otherwise use default MusicBrainz provider behavior
210        if let Some(filters) = release_filters {
211            // Use static method with custom filters for this specific verification
212            crate::musicbrainz_provider::MusicBrainzScrubActionProvider::verify_track_exists_with_filters(
213                &artist,
214                &title,
215                album.as_deref(),
216                filters,
217            )
218            .await
219            .map_err(|e| ActionProviderError(format!("MusicBrainz verification failed: {e}")))
220        } else {
221            // Use default provider behavior (no special filters)
222            let default_provider =
223                crate::musicbrainz_provider::MusicBrainzScrubActionProvider::new(0.8, 20);
224            default_provider
225                .verify_track_exists(&artist, &title, album.as_deref())
226                .await
227                .map_err(|e| ActionProviderError(format!("MusicBrainz verification failed: {e}")))
228        }
229    }
230}
231
232#[async_trait]
233impl ScrubActionProvider for RewriteRulesScrubActionProvider {
234    type Error = ActionProviderError;
235
236    async fn analyze_tracks(
237        &self,
238        tracks: &[Track],
239        _pending_edits: Option<&[crate::persistence::PendingEdit]>,
240        _pending_rules: Option<&[crate::persistence::PendingRewriteRule]>,
241    ) -> Result<Vec<(usize, Vec<SuggestionWithContext>)>, Self::Error> {
242        let mut results = Vec::new();
243
244        for (index, track) in tracks.iter().enumerate() {
245            log::trace!("RewriteRulesScrubActionProvider analyzing track {index}: '{track_name}' by '{track_artist}' against {rules_count} rules",
246                   track_name = track.name, track_artist = track.artist, rules_count = self.rules.len());
247
248            // Early continue if no rules apply
249            if !crate::rewrite::any_rules_apply(&self.rules, track)? {
250                log::trace!(
251                    "RewriteRulesScrubActionProvider track {index}: no rules apply, skipping"
252                );
253                continue;
254            }
255
256            // Apply rules with per-rule MB gating
257            if let Some((final_edit, requires_confirmation)) =
258                self.apply_rules_sequentially(track).await?
259            {
260                results.push((
261                    index,
262                    vec![SuggestionWithContext::edit_with_confirmation(
263                        final_edit,
264                        requires_confirmation,
265                        self.provider_name().to_string(),
266                    )],
267                ));
268            }
269        }
270
271        Ok(results)
272    }
273
274    fn provider_name(&self) -> &'static str {
275        "RewriteRules"
276    }
277}
278
279/// Combines multiple providers, trying each one in order until one returns a non-NoAction result
280pub struct OrScrubActionProvider {
281    providers: Vec<Box<dyn ScrubActionProvider<Error = ActionProviderError>>>,
282    provider_names: Vec<String>,
283}
284
285impl Default for OrScrubActionProvider {
286    fn default() -> Self {
287        Self::new()
288    }
289}
290
291impl OrScrubActionProvider {
292    #[must_use]
293    pub fn new() -> Self {
294        Self {
295            providers: Vec::new(),
296            provider_names: Vec::new(),
297        }
298    }
299
300    pub fn add_provider<P>(mut self, provider: P) -> Self
301    where
302        P: ScrubActionProvider + 'static,
303        P::Error: Into<ActionProviderError>,
304    {
305        let name = provider.provider_name().to_string();
306        self.provider_names.push(name);
307
308        // Wrap the provider to match our error type
309        let wrapped_provider = ErrorAdapter { inner: provider };
310        self.providers.push(Box::new(wrapped_provider));
311        self
312    }
313
314    #[must_use]
315    pub fn with_providers<P>(providers: Vec<P>) -> Self
316    where
317        P: ScrubActionProvider + 'static,
318        P::Error: Into<ActionProviderError>,
319    {
320        let mut or_provider = Self::new();
321        for provider in providers {
322            or_provider = or_provider.add_provider(provider);
323        }
324        or_provider
325    }
326}
327
328// Adapter to convert different error types to our unified error type
329struct ErrorAdapter<P> {
330    inner: P,
331}
332
333#[async_trait]
334impl<P> ScrubActionProvider for ErrorAdapter<P>
335where
336    P: ScrubActionProvider + Send + Sync,
337    P::Error: Into<ActionProviderError>,
338{
339    type Error = ActionProviderError;
340
341    async fn analyze_tracks(
342        &self,
343        tracks: &[Track],
344        pending_edits: Option<&[PendingEdit]>,
345        pending_rules: Option<&[PendingRewriteRule]>,
346    ) -> Result<Vec<(usize, Vec<SuggestionWithContext>)>, Self::Error> {
347        self.inner
348            .analyze_tracks(tracks, pending_edits, pending_rules)
349            .await
350            .map_err(std::convert::Into::into)
351    }
352
353    fn provider_name(&self) -> &str {
354        self.inner.provider_name()
355    }
356}
357
358#[async_trait]
359impl ScrubActionProvider for OrScrubActionProvider {
360    type Error = ActionProviderError;
361
362    async fn analyze_tracks(
363        &self,
364        tracks: &[Track],
365        pending_edits: Option<&[PendingEdit]>,
366        pending_rules: Option<&[PendingRewriteRule]>,
367    ) -> Result<Vec<(usize, Vec<SuggestionWithContext>)>, Self::Error> {
368        let mut combined_results: Vec<(usize, Vec<SuggestionWithContext>)> = Vec::new();
369
370        // Try each provider in sequence and combine results
371        for (provider_idx, provider) in self.providers.iter().enumerate() {
372            match provider
373                .analyze_tracks(tracks, pending_edits, pending_rules)
374                .await
375            {
376                Ok(provider_results) => {
377                    // Add these results to our combined results
378                    for (track_idx, suggestions) in provider_results {
379                        // Check if we already have suggestions for this track
380                        if let Some(existing) = combined_results
381                            .iter_mut()
382                            .find(|(idx, _)| *idx == track_idx)
383                        {
384                            // Add to existing suggestions
385                            existing.1.extend(suggestions);
386                        } else {
387                            // Add new entry
388                            combined_results.push((track_idx, suggestions));
389                        }
390                    }
391                }
392                Err(e) => {
393                    // Log error but continue to next provider
394                    log::warn!(
395                        "Error from provider '{}': {}",
396                        self.provider_names
397                            .get(provider_idx)
398                            .unwrap_or(&"unknown".to_string()),
399                        e
400                    );
401                }
402            }
403        }
404
405        Ok(combined_results)
406    }
407
408    fn provider_name(&self) -> &'static str {
409        "OrProvider"
410    }
411}