Skip to main content

scrobble_scrubber/
events.rs

1use crate::scrub_action_provider::ScrubActionSuggestion;
2use chrono::{DateTime, Utc};
3use lastfm_edit::ClientEvent;
4use lastfm_edit::Track;
5use serde::{Deserialize, Serialize};
6
7/// Structured error types for better error handling and categorization
8#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
9pub enum ScrubberError {
10    /// Network-related errors (API calls, connectivity)
11    Network(String),
12    /// Authentication errors (login, session)
13    Authentication(String),
14    /// Data validation errors (invalid track data, malformed input)
15    Validation(String),
16    /// Storage/persistence errors (database, file system)
17    Storage(String),
18    /// Configuration errors (invalid settings, missing config)
19    Configuration(String),
20    /// Rate limiting errors from API
21    RateLimit { retry_after_seconds: Option<u64> },
22    /// Processing errors (rule application, suggestion generation)
23    Processing(String),
24    /// Unknown or unexpected errors
25    Unknown(String),
26}
27
28impl std::fmt::Display for ScrubberError {
29    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30        match self {
31            ScrubberError::Network(msg) => write!(f, "Network error: {msg}"),
32            ScrubberError::Authentication(msg) => write!(f, "Authentication error: {msg}"),
33            ScrubberError::Validation(msg) => write!(f, "Validation error: {msg}"),
34            ScrubberError::Storage(msg) => write!(f, "Storage error: {msg}"),
35            ScrubberError::Configuration(msg) => write!(f, "Configuration error: {msg}"),
36            ScrubberError::RateLimit {
37                retry_after_seconds,
38            } => {
39                if let Some(seconds) = retry_after_seconds {
40                    write!(f, "Rate limited: retry after {seconds} seconds")
41                } else {
42                    write!(f, "Rate limited")
43                }
44            }
45            ScrubberError::Processing(msg) => write!(f, "Processing error: {msg}"),
46            ScrubberError::Unknown(msg) => write!(f, "Unknown error: {msg}"),
47        }
48    }
49}
50
51impl std::error::Error for ScrubberError {}
52
53// Re-export for backwards compatibility with existing code
54// pub use crate::json_logger::{EditInfo, TrackInfo}; // Removed - no longer needed
55
56// Keep ProcessingContext for backwards compatibility with existing event consumers
57#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
58pub struct ProcessingContext {
59    pub run_id: String,
60    pub batch_id: Option<String>,
61    pub track_index: Option<usize>,
62    pub batch_size: Option<usize>,
63    pub is_artist_processing: bool,
64}
65
66// LogTrackInfo was removed - now using Track from lastfm-edit directly
67
68#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
69pub struct LogEditInfo {
70    pub original_track_name: Option<String>,
71    pub original_artist_name: Option<String>,
72    pub original_album_name: Option<String>,
73    pub original_album_artist_name: Option<String>,
74    pub new_track_name: Option<String>,
75    pub new_artist_name: Option<String>,
76    pub new_album_name: Option<String>,
77    pub new_album_artist_name: Option<String>,
78}
79
80/// Types of processing that can be performed by the scrubber
81#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
82pub enum ProcessingType {
83    /// Processing recent tracks from user's listening history
84    Track,
85    /// Processing all tracks by a specific artist
86    Artist,
87    /// Processing all tracks from a specific album
88    Album,
89    /// Processing tracks from search query results
90    Search,
91    /// Manual processing triggered by user
92    Manual,
93    /// Batch processing of multiple items
94    Batch,
95}
96
97impl ProcessingType {
98    /// Get a human-readable display name for the processing type
99    pub fn display_name(&self) -> &'static str {
100        match self {
101            ProcessingType::Track => "Track Processing",
102            ProcessingType::Artist => "Artist Processing",
103            ProcessingType::Album => "Album Processing",
104            ProcessingType::Search => "Search Processing",
105            ProcessingType::Manual => "Manual Processing",
106            ProcessingType::Batch => "Batch Processing",
107        }
108    }
109}
110
111/// Results of processing a track
112#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
113pub enum ProcessingResult {
114    /// No changes were needed or applied
115    NoChanges,
116    /// Edits were applied directly
117    EditsApplied(u32),
118    /// Edits were created but require confirmation
119    EditsPending(u32),
120    /// A new rewrite rule was proposed
121    RuleProposed,
122    /// Both edits were applied and a rule was proposed
123    EditsAppliedAndRuleProposed(u32),
124    /// Both edits are pending and a rule was proposed
125    EditsPendingAndRuleProposed(u32),
126    /// Processing failed with an error
127    Failed(ScrubberError),
128    /// Track was skipped because edit requires confirmation
129    RequiresConfirmation,
130    /// Track was skipped due to dry run mode
131    DryRun,
132}
133
134impl ProcessingResult {
135    /// Get a human-readable summary of the processing result
136    pub fn summary(&self) -> String {
137        self.to_string()
138    }
139}
140
141impl std::fmt::Display for ProcessingResult {
142    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
143        match self {
144            ProcessingResult::NoChanges => write!(f, "no changes"),
145            ProcessingResult::EditsApplied(count) => {
146                if *count == 1 {
147                    write!(f, "1 edit applied")
148                } else {
149                    write!(f, "{count} edits applied")
150                }
151            }
152            ProcessingResult::EditsPending(count) => {
153                if *count == 1 {
154                    write!(f, "1 edit pending")
155                } else {
156                    write!(f, "{count} edits pending")
157                }
158            }
159            ProcessingResult::RuleProposed => write!(f, "proposed rule"),
160            ProcessingResult::EditsAppliedAndRuleProposed(count) => {
161                if *count == 1 {
162                    write!(f, "1 edit applied, proposed rule")
163                } else {
164                    write!(f, "{count} edits applied, proposed rule")
165                }
166            }
167            ProcessingResult::EditsPendingAndRuleProposed(count) => {
168                if *count == 1 {
169                    write!(f, "1 edit pending, proposed rule")
170                } else {
171                    write!(f, "{count} edits pending, proposed rule")
172                }
173            }
174            ProcessingResult::Failed(error) => write!(f, "failed: {error}"),
175            ProcessingResult::RequiresConfirmation => write!(f, "requires confirmation"),
176            ProcessingResult::DryRun => write!(f, "dry run - would apply edit"),
177        }
178    }
179}
180
181/// Events emitted by the ScrobbleScrubber during operation
182#[derive(Clone, Debug, Serialize, Deserialize)]
183pub struct ScrubberEvent {
184    pub timestamp: DateTime<Utc>,
185    pub event_type: ScrubberEventType,
186}
187
188#[derive(Clone, Debug, Serialize, Deserialize)]
189pub enum ScrubberEventType {
190    /// Scrubber has started running
191    Started(String),
192    /// Scrubber has stopped
193    Stopped(String),
194    /// Scrubber is sleeping until next cycle
195    Sleeping {
196        until_next_cycle_seconds: u64,
197        sleep_until_timestamp: DateTime<Utc>,
198    },
199    /// A track has been processed with suggestions applied
200    TrackProcessed {
201        track: Track,
202        suggestions: Vec<ScrubActionSuggestion>,
203        result: ProcessingResult,
204    },
205    /// A rule was applied to a track
206    RuleApplied {
207        track: Track,
208        suggestion: ScrubActionSuggestion,
209        description: String,
210    },
211    /// An error occurred during processing
212    Error(ScrubberError),
213    /// General informational message
214    Info(String),
215    /// Processing cycle completed
216    CycleCompleted {
217        processed_count: usize,
218        applied_count: usize,
219    },
220    /// Processing cycle started
221    CycleStarted(String),
222    /// Processing anchor timestamp was updated
223    AnchorUpdated { anchor_timestamp: u64, track: Track },
224    /// Tracks were found that need processing
225    TracksFound { count: usize, anchor_timestamp: u64 },
226    /// Track edit was successful (legacy - use ClientEvent::EditAttempted instead)
227    TrackEdited {
228        track: Track,
229        edit: LogEditInfo,
230        context: ProcessingContext,
231    },
232    /// Track edit failed (legacy - use ClientEvent::EditAttempted instead)
233    TrackEditFailed {
234        track: Track,
235        edit: Option<LogEditInfo>,
236        context: ProcessingContext,
237        error: ScrubberError,
238    },
239    /// Track was skipped (dry run, requires confirmation, etc.)
240    TrackSkipped {
241        track: Track,
242        context: ProcessingContext,
243        reason: String,
244    },
245    /// Client event forwarded from lastfm-edit client
246    ClientEvent(ClientEvent),
247    /// A pending edit was created requiring confirmation
248    PendingEditCreated {
249        pending_edit_id: String,
250        track: Track,
251        edit: LogEditInfo,
252        context: ProcessingContext,
253    },
254    /// Processing batch started with full track list for progress UI
255    ProcessingBatchStarted {
256        tracks: Vec<Track>,
257        processing_type: ProcessingType,
258    },
259    /// Individual track processing started (for progress UI)
260    TrackProcessingStarted {
261        track: Track,
262        track_index: usize,
263        total_tracks: usize,
264    },
265    /// Individual track processing completed (for progress UI)
266    TrackProcessingCompleted {
267        track: Track,
268        track_index: usize,
269        total_tracks: usize,
270        success: bool,
271        result: ProcessingResult,
272    },
273}
274
275impl ScrubberEvent {
276    pub fn new(event_type: ScrubberEventType) -> Self {
277        Self {
278            timestamp: Utc::now(),
279            event_type,
280        }
281    }
282
283    pub fn started(message: String) -> Self {
284        Self::new(ScrubberEventType::Started(message))
285    }
286
287    pub fn stopped(message: String) -> Self {
288        Self::new(ScrubberEventType::Stopped(message))
289    }
290
291    pub fn sleeping(until_next_cycle_seconds: u64) -> Self {
292        let sleep_until_timestamp =
293            Utc::now() + chrono::Duration::seconds(until_next_cycle_seconds as i64);
294        Self::new(ScrubberEventType::Sleeping {
295            until_next_cycle_seconds,
296            sleep_until_timestamp,
297        })
298    }
299
300    pub fn track_processed(
301        track: Track,
302        suggestions: Vec<ScrubActionSuggestion>,
303        result: ProcessingResult,
304    ) -> Self {
305        Self::new(ScrubberEventType::TrackProcessed {
306            track,
307            suggestions,
308            result,
309        })
310    }
311
312    pub fn track_processed_with_result(track_name: &str, artist_name: &str, result: &str) -> Self {
313        // Create a minimal track for backwards compatibility
314        let track = Track {
315            name: track_name.to_string(),
316            artist: artist_name.to_string(),
317            album: None,
318            album_artist: None,
319            timestamp: None,
320            playcount: 0,
321        };
322
323        // Parse the string result into ProcessingResult enum for backwards compatibility
324        let processing_result = if result.contains("no changes") {
325            ProcessingResult::NoChanges
326        } else if result.contains("dry run") {
327            ProcessingResult::DryRun
328        } else if result.contains("requires confirmation") {
329            ProcessingResult::RequiresConfirmation
330        } else if result.contains("failed") {
331            ProcessingResult::Failed(ScrubberError::Unknown(result.to_string()))
332        } else if result.contains("edit applied") || result.contains("edits applied") {
333            // Try to extract count from "X edit(s) applied"
334            let count = result
335                .chars()
336                .take_while(|c| c.is_ascii_digit())
337                .collect::<String>()
338                .parse::<u32>()
339                .unwrap_or(1);
340            ProcessingResult::EditsApplied(count)
341        } else if result.contains("edit pending") || result.contains("edits pending") {
342            // Try to extract count from "X edit(s) pending"
343            let count = result
344                .chars()
345                .take_while(|c| c.is_ascii_digit())
346                .collect::<String>()
347                .parse::<u32>()
348                .unwrap_or(1);
349            ProcessingResult::EditsPending(count)
350        } else if result.contains("proposed rule") {
351            ProcessingResult::RuleProposed
352        } else {
353            // Fallback for unknown string format
354            ProcessingResult::Failed(ScrubberError::Unknown(format!(
355                "Unknown result format: {result}"
356            )))
357        };
358
359        Self::new(ScrubberEventType::TrackProcessed {
360            track,
361            suggestions: vec![],
362            result: processing_result,
363        })
364    }
365
366    pub fn rule_applied(
367        track: Track,
368        suggestion: ScrubActionSuggestion,
369        description: String,
370    ) -> Self {
371        Self::new(ScrubberEventType::RuleApplied {
372            track,
373            suggestion,
374            description,
375        })
376    }
377
378    pub fn error(error: ScrubberError) -> Self {
379        Self::new(ScrubberEventType::Error(error))
380    }
381
382    /// Helper function for backwards compatibility with string errors
383    pub fn error_from_string(message: String) -> Self {
384        Self::new(ScrubberEventType::Error(ScrubberError::Unknown(message)))
385    }
386
387    pub fn info(message: String) -> Self {
388        Self::new(ScrubberEventType::Info(message))
389    }
390
391    pub fn cycle_started(message: String) -> Self {
392        Self::new(ScrubberEventType::CycleStarted(message))
393    }
394
395    pub fn cycle_completed(processed_count: usize, applied_count: usize) -> Self {
396        Self::new(ScrubberEventType::CycleCompleted {
397            processed_count,
398            applied_count,
399        })
400    }
401
402    pub fn anchor_updated(anchor_timestamp: u64, track: Track) -> Self {
403        Self::new(ScrubberEventType::AnchorUpdated {
404            anchor_timestamp,
405            track,
406        })
407    }
408
409    // Helper for backwards compatibility with old scrubber calls
410    pub fn anchor_updated_from_names(
411        anchor_timestamp: u64,
412        track_name: &str,
413        artist_name: &str,
414    ) -> Self {
415        let track = Track {
416            name: track_name.to_string(),
417            artist: artist_name.to_string(),
418            album: None,
419            album_artist: None,
420            timestamp: Some(anchor_timestamp),
421            playcount: 0,
422        };
423        Self::anchor_updated(anchor_timestamp, track)
424    }
425
426    pub fn tracks_found(count: usize, anchor_timestamp: u64) -> Self {
427        Self::new(ScrubberEventType::TracksFound {
428            count,
429            anchor_timestamp,
430        })
431    }
432
433    pub fn track_edited(track: &Track, edit: &LogEditInfo, context: ProcessingContext) -> Self {
434        Self::new(ScrubberEventType::TrackEdited {
435            track: track.clone(),
436            edit: edit.clone(),
437            context,
438        })
439    }
440
441    pub fn track_edit_failed(
442        track: &Track,
443        edit: Option<&LogEditInfo>,
444        context: ProcessingContext,
445        error: ScrubberError,
446    ) -> Self {
447        Self::new(ScrubberEventType::TrackEditFailed {
448            track: track.clone(),
449            edit: edit.cloned(),
450            context,
451            error,
452        })
453    }
454
455    /// Helper function for backwards compatibility with string errors
456    pub fn track_edit_failed_from_string(
457        track: &Track,
458        edit: Option<&LogEditInfo>,
459        context: ProcessingContext,
460        error: String,
461    ) -> Self {
462        Self::new(ScrubberEventType::TrackEditFailed {
463            track: track.clone(),
464            edit: edit.cloned(),
465            context,
466            error: ScrubberError::Unknown(error),
467        })
468    }
469
470    pub fn track_skipped(track: &Track, context: ProcessingContext, reason: String) -> Self {
471        Self::new(ScrubberEventType::TrackSkipped {
472            track: track.clone(),
473            context,
474            reason,
475        })
476    }
477
478    pub fn client_event(client_event: ClientEvent) -> Self {
479        Self::new(ScrubberEventType::ClientEvent(client_event))
480    }
481
482    pub fn pending_edit_created(
483        pending_edit_id: String,
484        track: &Track,
485        edit: &LogEditInfo,
486        context: ProcessingContext,
487    ) -> Self {
488        Self::new(ScrubberEventType::PendingEditCreated {
489            pending_edit_id,
490            track: track.clone(),
491            edit: edit.clone(),
492            context,
493        })
494    }
495
496    pub fn processing_batch_started(tracks: Vec<Track>, processing_type: ProcessingType) -> Self {
497        Self::new(ScrubberEventType::ProcessingBatchStarted {
498            tracks,
499            processing_type,
500        })
501    }
502
503    pub fn track_processing_started(track: Track, track_index: usize, total_tracks: usize) -> Self {
504        Self::new(ScrubberEventType::TrackProcessingStarted {
505            track,
506            track_index,
507            total_tracks,
508        })
509    }
510
511    pub fn track_processing_completed(
512        track: Track,
513        track_index: usize,
514        total_tracks: usize,
515        success: bool,
516        result: ProcessingResult,
517    ) -> Self {
518        Self::new(ScrubberEventType::TrackProcessingCompleted {
519            track,
520            track_index,
521            total_tracks,
522            success,
523            result,
524        })
525    }
526}