1use crate::scrub_action_provider::ScrubActionSuggestion;
2use chrono::{DateTime, Utc};
3use lastfm_edit::ClientEvent;
4use lastfm_edit::Track;
5use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
9pub enum ScrubberError {
10 Network(String),
12 Authentication(String),
14 Validation(String),
16 Storage(String),
18 Configuration(String),
20 RateLimit { retry_after_seconds: Option<u64> },
22 Processing(String),
24 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#[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#[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#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
82pub enum ProcessingType {
83 Track,
85 Artist,
87 Album,
89 Search,
91 Manual,
93 Batch,
95}
96
97impl ProcessingType {
98 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
113pub enum ProcessingResult {
114 NoChanges,
116 EditsApplied(u32),
118 EditsPending(u32),
120 RuleProposed,
122 EditsAppliedAndRuleProposed(u32),
124 EditsPendingAndRuleProposed(u32),
126 Failed(ScrubberError),
128 RequiresConfirmation,
130 DryRun,
132}
133
134impl ProcessingResult {
135 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#[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 Started(String),
192 Stopped(String),
194 Sleeping {
196 until_next_cycle_seconds: u64,
197 sleep_until_timestamp: DateTime<Utc>,
198 },
199 TrackProcessed {
201 track: Track,
202 suggestions: Vec<ScrubActionSuggestion>,
203 result: ProcessingResult,
204 },
205 RuleApplied {
207 track: Track,
208 suggestion: ScrubActionSuggestion,
209 description: String,
210 },
211 Error(ScrubberError),
213 Info(String),
215 CycleCompleted {
217 processed_count: usize,
218 applied_count: usize,
219 },
220 CycleStarted(String),
222 AnchorUpdated { anchor_timestamp: u64, track: Track },
224 TracksFound { count: usize, anchor_timestamp: u64 },
226 TrackEdited {
228 track: Track,
229 edit: LogEditInfo,
230 context: ProcessingContext,
231 },
232 TrackEditFailed {
234 track: Track,
235 edit: Option<LogEditInfo>,
236 context: ProcessingContext,
237 error: ScrubberError,
238 },
239 TrackSkipped {
241 track: Track,
242 context: ProcessingContext,
243 reason: String,
244 },
245 ClientEvent(ClientEvent),
247 PendingEditCreated {
249 pending_edit_id: String,
250 track: Track,
251 edit: LogEditInfo,
252 context: ProcessingContext,
253 },
254 ProcessingBatchStarted {
256 tracks: Vec<Track>,
257 processing_type: ProcessingType,
258 },
259 TrackProcessingStarted {
261 track: Track,
262 track_index: usize,
263 total_tracks: usize,
264 },
265 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 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 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 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 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 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 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 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 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}