lastfm_edit/
client.rs

1use crate::edit_analysis;
2use crate::headers;
3use crate::login::extract_cookies_from_response;
4use crate::parsing::LastFmParser;
5use crate::r#trait::LastFmEditClient;
6use crate::retry;
7use crate::types::{
8    AlbumPage, ClientConfig, ClientEvent, ClientEventReceiver, EditResponse, ExactScrobbleEdit,
9    LastFmEditSession, LastFmError, RateLimitConfig, RateLimitType, RequestInfo, RetryConfig,
10    ScrobbleEdit, SharedEventBroadcaster, SingleEditResponse, Track, TrackPage,
11};
12use crate::Result;
13use async_trait::async_trait;
14use http_client::{HttpClient, Request, Response};
15use http_types::{Method, Url};
16use scraper::{Html, Selector};
17use std::sync::{Arc, Mutex};
18
19#[derive(Clone)]
20pub struct LastFmEditClientImpl {
21    client: Arc<dyn HttpClient + Send + Sync>,
22    session: Arc<Mutex<LastFmEditSession>>,
23    parser: LastFmParser,
24    broadcaster: Arc<SharedEventBroadcaster>,
25    config: ClientConfig,
26}
27
28impl LastFmEditClientImpl {
29    pub fn from_session(
30        client: Box<dyn HttpClient + Send + Sync>,
31        session: LastFmEditSession,
32    ) -> Self {
33        Self::from_session_with_arc(Arc::from(client), session)
34    }
35
36    fn from_session_with_arc(
37        client: Arc<dyn HttpClient + Send + Sync>,
38        session: LastFmEditSession,
39    ) -> Self {
40        Self::from_session_with_broadcaster_arc(
41            client,
42            session,
43            Arc::new(SharedEventBroadcaster::new()),
44        )
45    }
46
47    pub fn from_session_with_rate_limit_patterns(
48        client: Box<dyn HttpClient + Send + Sync>,
49        session: LastFmEditSession,
50        rate_limit_patterns: Vec<String>,
51    ) -> Self {
52        let config = ClientConfig::default()
53            .with_rate_limit_config(RateLimitConfig::default().with_patterns(rate_limit_patterns));
54        Self::from_session_with_client_config(client, session, config)
55    }
56
57    pub async fn login_with_credentials(
58        client: Box<dyn HttpClient + Send + Sync>,
59        username: &str,
60        password: &str,
61    ) -> Result<Self> {
62        let client_arc: Arc<dyn HttpClient + Send + Sync> = Arc::from(client);
63        let login_manager =
64            crate::login::LoginManager::new(client_arc.clone(), "https://www.last.fm".to_string());
65        let session = login_manager.login(username, password).await?;
66        Ok(Self::from_session_with_arc(client_arc, session))
67    }
68
69    pub fn from_session_with_client_config(
70        client: Box<dyn HttpClient + Send + Sync>,
71        session: LastFmEditSession,
72        config: ClientConfig,
73    ) -> Self {
74        Self::from_session_with_client_config_arc(Arc::from(client), session, config)
75    }
76
77    pub async fn login_with_credentials_and_client_config(
78        client: Box<dyn HttpClient + Send + Sync>,
79        username: &str,
80        password: &str,
81        config: ClientConfig,
82    ) -> Result<Self> {
83        let client_arc: Arc<dyn HttpClient + Send + Sync> = Arc::from(client);
84        let login_manager =
85            crate::login::LoginManager::new(client_arc.clone(), "https://www.last.fm".to_string());
86        let session = login_manager.login(username, password).await?;
87        Ok(Self::from_session_with_client_config_arc(
88            client_arc, session, config,
89        ))
90    }
91
92    pub fn from_session_with_config(
93        client: Box<dyn HttpClient + Send + Sync>,
94        session: LastFmEditSession,
95        retry_config: RetryConfig,
96        rate_limit_config: RateLimitConfig,
97    ) -> Self {
98        Self::from_session_with_config_arc(
99            Arc::from(client),
100            session,
101            retry_config,
102            rate_limit_config,
103        )
104    }
105
106    pub async fn login_with_credentials_and_config(
107        client: Box<dyn HttpClient + Send + Sync>,
108        username: &str,
109        password: &str,
110        retry_config: RetryConfig,
111        rate_limit_config: RateLimitConfig,
112    ) -> Result<Self> {
113        let client_arc: Arc<dyn HttpClient + Send + Sync> = Arc::from(client);
114        let login_manager =
115            crate::login::LoginManager::new(client_arc.clone(), "https://www.last.fm".to_string());
116        let session = login_manager.login(username, password).await?;
117        Ok(Self::from_session_with_config_arc(
118            client_arc,
119            session,
120            retry_config,
121            rate_limit_config,
122        ))
123    }
124
125    fn from_session_with_broadcaster(
126        client: Box<dyn HttpClient + Send + Sync>,
127        session: LastFmEditSession,
128        broadcaster: Arc<SharedEventBroadcaster>,
129    ) -> Self {
130        Self::from_session_with_broadcaster_arc(Arc::from(client), session, broadcaster)
131    }
132
133    fn from_session_with_client_config_arc(
134        client: Arc<dyn HttpClient + Send + Sync>,
135        session: LastFmEditSession,
136        config: ClientConfig,
137    ) -> Self {
138        Self::from_session_with_client_config_and_broadcaster_arc(
139            client,
140            session,
141            config,
142            Arc::new(SharedEventBroadcaster::new()),
143        )
144    }
145
146    fn from_session_with_config_arc(
147        client: Arc<dyn HttpClient + Send + Sync>,
148        session: LastFmEditSession,
149        retry_config: RetryConfig,
150        rate_limit_config: RateLimitConfig,
151    ) -> Self {
152        let config = ClientConfig {
153            retry: retry_config,
154            rate_limit: rate_limit_config,
155        };
156        Self::from_session_with_client_config_arc(client, session, config)
157    }
158
159    fn from_session_with_broadcaster_arc(
160        client: Arc<dyn HttpClient + Send + Sync>,
161        session: LastFmEditSession,
162        broadcaster: Arc<SharedEventBroadcaster>,
163    ) -> Self {
164        Self::from_session_with_client_config_and_broadcaster_arc(
165            client,
166            session,
167            ClientConfig::default(),
168            broadcaster,
169        )
170    }
171
172    fn from_session_with_client_config_and_broadcaster_arc(
173        client: Arc<dyn HttpClient + Send + Sync>,
174        session: LastFmEditSession,
175        config: ClientConfig,
176        broadcaster: Arc<SharedEventBroadcaster>,
177    ) -> Self {
178        Self {
179            client,
180            session: Arc::new(Mutex::new(session)),
181            parser: LastFmParser::new(),
182            broadcaster,
183            config,
184        }
185    }
186
187    pub fn get_session(&self) -> LastFmEditSession {
188        self.session.lock().unwrap().clone()
189    }
190
191    pub fn restore_session(&self, session: LastFmEditSession) {
192        *self.session.lock().unwrap() = session;
193    }
194
195    pub fn with_shared_broadcaster(&self, client: Box<dyn HttpClient + Send + Sync>) -> Self {
196        let session = self.get_session();
197        Self::from_session_with_broadcaster(client, session, self.broadcaster.clone())
198    }
199
200    pub fn username(&self) -> String {
201        self.session.lock().unwrap().username.clone()
202    }
203
204    pub async fn validate_session(&self) -> bool {
205        let test_url = {
206            let session = self.session.lock().unwrap();
207            format!(
208                "{}/settings/subscription/automatic-edits/tracks",
209                session.base_url
210            )
211        };
212
213        let mut request = Request::new(Method::Get, test_url.parse::<Url>().unwrap());
214
215        {
216            let session = self.session.lock().unwrap();
217            headers::add_cookies(&mut request, &session.cookies);
218        }
219
220        headers::add_get_headers(&mut request, false, None);
221
222        match self.client.send(request).await {
223            Ok(response) => {
224                if response.status() == 302 || response.status() == 301 {
225                    if let Some(location) = response.header("location") {
226                        if let Some(redirect_url) = location.get(0) {
227                            let redirect_url_str = redirect_url.as_str();
228                            let is_valid = !redirect_url_str.contains("/login");
229
230                            return is_valid;
231                        }
232                    }
233                }
234                true
235            }
236            Err(_e) => false,
237        }
238    }
239
240    pub async fn delete_scrobble(
241        &self,
242        artist_name: &str,
243        track_name: &str,
244        timestamp: u64,
245    ) -> Result<bool> {
246        let config = RetryConfig {
247            max_retries: 3,
248            base_delay: 5,
249            max_delay: 300,
250            enabled: true,
251        };
252
253        let artist_name = artist_name.to_string();
254        let track_name = track_name.to_string();
255        let client = self.clone();
256
257        match retry::retry_with_backoff(
258            config,
259            "Delete scrobble",
260            || client.delete_scrobble_impl(&artist_name, &track_name, timestamp),
261            |delay, rate_limit_timestamp, operation_name| {
262                self.broadcast_event(ClientEvent::RateLimited {
263                    delay_seconds: delay,
264                    request: None,
265                    rate_limit_type: RateLimitType::ResponsePattern,
266                    rate_limit_timestamp,
267                });
268                log::debug!("{operation_name} rate limited, waiting {delay} seconds");
269            },
270            |total_duration, _operation_name| {
271                self.broadcast_event(ClientEvent::RateLimitEnded {
272                    request: crate::types::RequestInfo::from_url_and_method(
273                        &format!("delete_scrobble/{artist_name}/{track_name}/{timestamp}"),
274                        "POST",
275                    ),
276                    rate_limit_type: RateLimitType::ResponsePattern,
277                    total_rate_limit_duration_seconds: total_duration,
278                });
279            },
280        )
281        .await
282        {
283            Ok(retry_result) => Ok(retry_result.result),
284            Err(_) => Ok(false),
285        }
286    }
287
288    async fn delete_scrobble_impl(
289        &self,
290        artist_name: &str,
291        track_name: &str,
292        timestamp: u64,
293    ) -> Result<bool> {
294        let delete_url = {
295            let session = self.session.lock().unwrap();
296            format!(
297                "{}/user/{}/library/delete",
298                session.base_url, session.username
299            )
300        };
301
302        log::debug!("Getting fresh CSRF token for delete");
303        let library_url = {
304            let session = self.session.lock().unwrap();
305            format!("{}/user/{}/library", session.base_url, session.username)
306        };
307
308        let mut response = self.get(&library_url).await?;
309        let content = response
310            .body_string()
311            .await
312            .map_err(|e| LastFmError::Http(e.to_string()))?;
313
314        let document = Html::parse_document(&content);
315        let fresh_csrf_token = self.extract_csrf_token(&document)?;
316
317        log::debug!("Submitting delete request with fresh token");
318
319        let mut request = Request::new(Method::Post, delete_url.parse::<Url>().unwrap());
320
321        let referer_url = {
322            let session = self.session.lock().unwrap();
323            headers::add_cookies(&mut request, &session.cookies);
324            format!("{}/user/{}", session.base_url, session.username)
325        };
326
327        headers::add_edit_headers(&mut request, &referer_url);
328
329        let form_data = [
330            ("csrfmiddlewaretoken", fresh_csrf_token.as_str()),
331            ("artist_name", artist_name),
332            ("track_name", track_name),
333            ("timestamp", &timestamp.to_string()),
334            ("ajax", "1"),
335        ];
336
337        let form_string: String = form_data
338            .iter()
339            .map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v)))
340            .collect::<Vec<_>>()
341            .join("&");
342
343        request.set_body(form_string);
344
345        log::debug!(
346            "Deleting scrobble: '{track_name}' by '{artist_name}' with timestamp {timestamp}"
347        );
348
349        let request_info = RequestInfo::from_url_and_method(&delete_url, "POST");
350        let request_start = std::time::Instant::now();
351
352        self.broadcast_event(ClientEvent::RequestStarted {
353            request: request_info.clone(),
354        });
355
356        let mut response = self
357            .client
358            .send(request)
359            .await
360            .map_err(|e| LastFmError::Http(e.to_string()))?;
361
362        self.broadcast_event(ClientEvent::RequestCompleted {
363            request: request_info.clone(),
364            status_code: response.status().into(),
365            duration_ms: request_start.elapsed().as_millis() as u64,
366        });
367
368        log::debug!("Delete response status: {}", response.status());
369
370        let response_text = response
371            .body_string()
372            .await
373            .map_err(|e| LastFmError::Http(e.to_string()))?;
374
375        let success = response.status().is_success();
376
377        if success {
378            log::debug!("Successfully deleted scrobble");
379        } else {
380            log::debug!("Delete failed with response: {response_text}");
381        }
382
383        Ok(success)
384    }
385
386    pub fn subscribe(&self) -> ClientEventReceiver {
387        self.broadcaster.subscribe()
388    }
389
390    pub fn latest_event(&self) -> Option<ClientEvent> {
391        self.broadcaster.latest_event()
392    }
393
394    fn broadcast_event(&self, event: ClientEvent) {
395        self.broadcaster.broadcast_event(event);
396    }
397
398    pub async fn get_recent_scrobbles(&self, page: u32) -> Result<Vec<Track>> {
399        let url = {
400            let session = self.session.lock().unwrap();
401            format!(
402                "{}/user/{}/library?page={}",
403                session.base_url, session.username, page
404            )
405        };
406
407        log::debug!("Fetching recent scrobbles page {page}");
408        let mut response = self.get(&url).await?;
409        let content = response
410            .body_string()
411            .await
412            .map_err(|e| LastFmError::Http(e.to_string()))?;
413
414        log::debug!(
415            "Recent scrobbles response: {} status, {} chars",
416            response.status(),
417            content.len()
418        );
419
420        let document = Html::parse_document(&content);
421        self.parser.parse_recent_scrobbles(&document)
422    }
423
424    pub async fn get_recent_tracks_page(&self, page: u32) -> Result<TrackPage> {
425        let tracks = self.get_recent_scrobbles(page).await?;
426
427        let has_next_page = !tracks.is_empty();
428
429        Ok(TrackPage {
430            tracks,
431            page_number: page,
432            has_next_page,
433            total_pages: None,
434        })
435    }
436
437    pub async fn find_recent_scrobble_for_track(
438        &self,
439        track_name: &str,
440        artist_name: &str,
441        max_pages: u32,
442    ) -> Result<Option<Track>> {
443        log::debug!("Searching for recent scrobble: '{track_name}' by '{artist_name}'");
444
445        for page in 1..=max_pages {
446            let scrobbles = self.get_recent_scrobbles(page).await?;
447
448            for scrobble in scrobbles {
449                if scrobble.name == track_name && scrobble.artist == artist_name {
450                    log::debug!(
451                        "Found recent scrobble: '{}' with timestamp {:?}",
452                        scrobble.name,
453                        scrobble.timestamp
454                    );
455                    return Ok(Some(scrobble));
456                }
457            }
458        }
459
460        log::debug!(
461            "No recent scrobble found for '{track_name}' by '{artist_name}' in {max_pages} pages"
462        );
463        Ok(None)
464    }
465
466    pub async fn edit_scrobble(&self, edit: &ScrobbleEdit) -> Result<EditResponse> {
467        let discovered_edits = self.discover_scrobble_edit_variations(edit).await?;
468
469        if discovered_edits.is_empty() {
470            let context = match (&edit.track_name_original, &edit.album_name_original) {
471                (Some(track_name), _) => {
472                    format!("track '{}' by '{}'", track_name, edit.artist_name_original)
473                }
474                (None, Some(album_name)) => {
475                    format!("album '{}' by '{}'", album_name, edit.artist_name_original)
476                }
477                (None, None) => format!("artist '{}'", edit.artist_name_original),
478            };
479            return Err(LastFmError::Parse(format!(
480                "No scrobbles found for {context}. Make sure the names are correct and that you have scrobbled recently."
481            )));
482        }
483
484        log::info!(
485            "Discovered {} scrobble instances to edit",
486            discovered_edits.len()
487        );
488
489        let mut all_results = Vec::new();
490
491        for (index, discovered_edit) in discovered_edits.iter().enumerate() {
492            log::debug!(
493                "Processing scrobble {}/{}: '{}' from '{}'",
494                index + 1,
495                discovered_edits.len(),
496                discovered_edit.track_name_original,
497                discovered_edit.album_name_original
498            );
499
500            let mut modified_exact_edit = discovered_edit.clone();
501
502            if let Some(new_track_name) = &edit.track_name {
503                modified_exact_edit.track_name = new_track_name.clone();
504            }
505            if let Some(new_album_name) = &edit.album_name {
506                modified_exact_edit.album_name = new_album_name.clone();
507            }
508            modified_exact_edit.artist_name = edit.artist_name.clone();
509            if let Some(new_album_artist_name) = &edit.album_artist_name {
510                modified_exact_edit.album_artist_name = new_album_artist_name.clone();
511            }
512            modified_exact_edit.edit_all = edit.edit_all;
513
514            let album_info = format!(
515                "{} by {}",
516                modified_exact_edit.album_name_original,
517                modified_exact_edit.album_artist_name_original
518            );
519
520            let single_response = self.edit_scrobble_single(&modified_exact_edit, 3).await?;
521            let success = single_response.success();
522            let message = single_response.message();
523
524            all_results.push(SingleEditResponse {
525                success,
526                message,
527                album_info: Some(album_info),
528                exact_scrobble_edit: modified_exact_edit.clone(),
529            });
530
531            if index < discovered_edits.len() - 1 {
532                tokio::time::sleep(std::time::Duration::from_millis(1000)).await;
533            }
534        }
535
536        Ok(EditResponse::from_results(all_results))
537    }
538
539    pub async fn edit_scrobble_single(
540        &self,
541        exact_edit: &ExactScrobbleEdit,
542        max_retries: u32,
543    ) -> Result<EditResponse> {
544        let config = RetryConfig {
545            max_retries,
546            base_delay: 5,
547            max_delay: 300,
548            enabled: true,
549        };
550
551        let edit_clone = exact_edit.clone();
552        let client = self.clone();
553
554        match retry::retry_with_backoff(
555            config,
556            "Edit scrobble",
557            || client.edit_scrobble_impl(&edit_clone),
558            |delay, rate_limit_timestamp, operation_name| {
559                self.broadcast_event(ClientEvent::RateLimited {
560                    delay_seconds: delay,
561                    request: None, // No specific request context in retry callback
562                    rate_limit_type: RateLimitType::ResponsePattern,
563                    rate_limit_timestamp,
564                });
565                log::debug!("{operation_name} rate limited, waiting {delay} seconds");
566            },
567            |total_duration, _operation_name| {
568                self.broadcast_event(ClientEvent::RateLimitEnded {
569                    request: crate::types::RequestInfo::from_url_and_method(
570                        &format!(
571                            "edit_scrobble/{}/{}",
572                            edit_clone.artist_name, edit_clone.track_name
573                        ),
574                        "POST",
575                    ),
576                    rate_limit_type: RateLimitType::ResponsePattern,
577                    total_rate_limit_duration_seconds: total_duration,
578                });
579            },
580        )
581        .await
582        {
583            Ok(retry_result) => Ok(EditResponse::single(
584                retry_result.result,
585                None,
586                None,
587                exact_edit.clone(),
588            )),
589            Err(LastFmError::RateLimit { .. }) => Ok(EditResponse::single(
590                false,
591                Some(format!("Rate limit exceeded after {max_retries} retries")),
592                None,
593                exact_edit.clone(),
594            )),
595            Err(other_error) => Ok(EditResponse::single(
596                false,
597                Some(other_error.to_string()),
598                None,
599                exact_edit.clone(),
600            )),
601        }
602    }
603
604    async fn edit_scrobble_impl(&self, exact_edit: &ExactScrobbleEdit) -> Result<bool> {
605        let start_time = std::time::Instant::now();
606        let result = self.edit_scrobble_impl_internal(exact_edit).await;
607        let duration_ms = start_time.elapsed().as_millis() as u64;
608
609        match &result {
610            Ok(success) => {
611                self.broadcast_event(ClientEvent::EditAttempted {
612                    edit: exact_edit.clone(),
613                    success: *success,
614                    error_message: None,
615                    duration_ms,
616                });
617            }
618            Err(error) => {
619                self.broadcast_event(ClientEvent::EditAttempted {
620                    edit: exact_edit.clone(),
621                    success: false,
622                    error_message: Some(error.to_string()),
623                    duration_ms,
624                });
625            }
626        }
627
628        result
629    }
630
631    async fn edit_scrobble_impl_internal(&self, exact_edit: &ExactScrobbleEdit) -> Result<bool> {
632        let edit_url = {
633            let session = self.session.lock().unwrap();
634            format!(
635                "{}/user/{}/library/edit?edited-variation=library-track-scrobble",
636                session.base_url, session.username
637            )
638        };
639
640        log::debug!("Getting fresh CSRF token for edit");
641        let form_html = self.get_edit_form_html(&edit_url).await?;
642
643        let form_document = Html::parse_document(&form_html);
644        let fresh_csrf_token = self.extract_csrf_token(&form_document)?;
645
646        log::debug!("Submitting edit with fresh token");
647
648        let form_data = exact_edit.build_form_data(&fresh_csrf_token);
649
650        log::debug!(
651            "Editing scrobble: '{}' -> '{}'",
652            exact_edit.track_name_original,
653            exact_edit.track_name
654        );
655        {
656            let session = self.session.lock().unwrap();
657            log::trace!("Session cookies count: {}", session.cookies.len());
658        }
659
660        let mut request = Request::new(Method::Post, edit_url.parse::<Url>().unwrap());
661
662        let referer_url = {
663            let session = self.session.lock().unwrap();
664            headers::add_cookies(&mut request, &session.cookies);
665            format!("{}/user/{}/library", session.base_url, session.username)
666        };
667
668        headers::add_edit_headers(&mut request, &referer_url);
669
670        let form_string: String = form_data
671            .iter()
672            .map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v)))
673            .collect::<Vec<_>>()
674            .join("&");
675
676        request.set_body(form_string);
677
678        let request_info = RequestInfo::from_url_and_method(&edit_url, "POST");
679        let request_start = std::time::Instant::now();
680
681        self.broadcast_event(ClientEvent::RequestStarted {
682            request: request_info.clone(),
683        });
684
685        let mut response = self
686            .client
687            .send(request)
688            .await
689            .map_err(|e| LastFmError::Http(e.to_string()))?;
690
691        self.broadcast_event(ClientEvent::RequestCompleted {
692            request: request_info.clone(),
693            status_code: response.status().into(),
694            duration_ms: request_start.elapsed().as_millis() as u64,
695        });
696
697        log::debug!("Edit response status: {}", response.status());
698
699        let response_text = response
700            .body_string()
701            .await
702            .map_err(|e| LastFmError::Http(e.to_string()))?;
703
704        let analysis = edit_analysis::analyze_edit_response(&response_text, response.status());
705
706        Ok(analysis.success)
707    }
708
709    async fn get_edit_form_html(&self, edit_url: &str) -> Result<String> {
710        let mut form_response = self.get(edit_url).await?;
711        let form_html = form_response
712            .body_string()
713            .await
714            .map_err(|e| LastFmError::Http(e.to_string()))?;
715
716        log::debug!("Edit form response status: {}", form_response.status());
717        Ok(form_html)
718    }
719
720    pub async fn load_edit_form_values_internal(
721        &self,
722        track_name: &str,
723        artist_name: &str,
724    ) -> Result<Vec<ExactScrobbleEdit>> {
725        log::debug!("Loading edit form values for '{track_name}' by '{artist_name}'");
726
727        let base_track_url = {
728            let session = self.session.lock().unwrap();
729            format!(
730                "{}/user/{}/library/music/+noredirect/{}/_/{}",
731                session.base_url,
732                session.username,
733                urlencoding::encode(artist_name),
734                urlencoding::encode(track_name)
735            )
736        };
737
738        log::debug!("Fetching track page: {base_track_url}");
739
740        let mut response = self.get(&base_track_url).await?;
741        let html = response
742            .body_string()
743            .await
744            .map_err(|e| crate::LastFmError::Http(e.to_string()))?;
745
746        let document = Html::parse_document(&html);
747
748        let mut all_scrobble_edits = Vec::new();
749        let mut unique_albums = std::collections::HashSet::new();
750        let max_pages = 5;
751
752        let page_edits = self.extract_scrobble_edits_from_page(
753            &document,
754            track_name,
755            artist_name,
756            &mut unique_albums,
757        )?;
758        all_scrobble_edits.extend(page_edits);
759
760        log::debug!(
761            "Page 1: found {} unique album variations",
762            all_scrobble_edits.len()
763        );
764
765        let pagination_selector = Selector::parse(".pagination .pagination-next").unwrap();
766        let mut has_next_page = document.select(&pagination_selector).next().is_some();
767        let mut page = 2;
768
769        while has_next_page && page <= max_pages {
770            let page_url = {
771                let session = self.session.lock().unwrap();
772                format!(
773                    "{}/user/{}/library/music/{}/_/{}?page={page}",
774                    session.base_url,
775                    session.username,
776                    urlencoding::encode(artist_name),
777                    urlencoding::encode(track_name)
778                )
779            };
780
781            log::debug!("Fetching page {page} for additional album variations");
782
783            let mut response = self.get(&page_url).await?;
784            let html = response
785                .body_string()
786                .await
787                .map_err(|e| crate::LastFmError::Http(e.to_string()))?;
788
789            let document = Html::parse_document(&html);
790
791            let page_edits = self.extract_scrobble_edits_from_page(
792                &document,
793                track_name,
794                artist_name,
795                &mut unique_albums,
796            )?;
797
798            let initial_count = all_scrobble_edits.len();
799            all_scrobble_edits.extend(page_edits);
800            let found_new_unique_albums = all_scrobble_edits.len() > initial_count;
801
802            has_next_page = document.select(&pagination_selector).next().is_some();
803
804            log::debug!(
805                "Page {page}: found {} total unique albums ({})",
806                all_scrobble_edits.len(),
807                if found_new_unique_albums {
808                    "new albums found"
809                } else {
810                    "no new unique albums"
811                }
812            );
813
814            page += 1;
815        }
816
817        if all_scrobble_edits.is_empty() {
818            return Err(crate::LastFmError::Parse(format!(
819                "No scrobble forms found for track '{track_name}' by '{artist_name}'"
820            )));
821        }
822
823        log::debug!(
824            "Final result: found {} unique album variations for '{track_name}' by '{artist_name}'",
825            all_scrobble_edits.len(),
826        );
827
828        Ok(all_scrobble_edits)
829    }
830
831    fn extract_scrobble_edits_from_page(
832        &self,
833        document: &Html,
834        expected_track: &str,
835        expected_artist: &str,
836        unique_albums: &mut std::collections::HashSet<(String, String)>,
837    ) -> Result<Vec<ExactScrobbleEdit>> {
838        let mut scrobble_edits = Vec::new();
839        let table_selector =
840            Selector::parse("table.chartlist:not(.chartlist__placeholder)").unwrap();
841        let table = document.select(&table_selector).next().ok_or_else(|| {
842            crate::LastFmError::Parse("No chartlist table found on track page".to_string())
843        })?;
844
845        let row_selector = Selector::parse("tr").unwrap();
846        for row in table.select(&row_selector) {
847            let count_bar_link_selector = Selector::parse(".chartlist-count-bar-link").unwrap();
848            if row.select(&count_bar_link_selector).next().is_some() {
849                log::debug!("Found count bar link, skipping aggregated row");
850                continue;
851            }
852
853            let form_selector = Selector::parse("form[data-edit-scrobble]").unwrap();
854            if let Some(form) = row.select(&form_selector).next() {
855                let extract_form_value = |name: &str| -> Option<String> {
856                    let selector = Selector::parse(&format!("input[name='{name}']")).unwrap();
857                    form.select(&selector)
858                        .next()
859                        .and_then(|input| input.value().attr("value"))
860                        .map(|s| s.to_string())
861                };
862
863                let form_track = extract_form_value("track_name").unwrap_or_default();
864                let form_artist = extract_form_value("artist_name").unwrap_or_default();
865                let form_album = extract_form_value("album_name").unwrap_or_default();
866                let form_album_artist =
867                    extract_form_value("album_artist_name").unwrap_or_else(|| form_artist.clone());
868                let form_timestamp = extract_form_value("timestamp").unwrap_or_default();
869
870                if form_track == expected_track && form_artist == expected_artist {
871                    let album_key = (form_album.clone(), form_album_artist.clone());
872                    if unique_albums.insert(album_key) {
873                        let timestamp = if form_timestamp.is_empty() {
874                            None
875                        } else {
876                            form_timestamp.parse::<u64>().ok()
877                        };
878
879                        if let Some(timestamp) = timestamp {
880                            let scrobble_edit = ExactScrobbleEdit::new(
881                                form_track.clone(),
882                                form_album.clone(),
883                                form_artist.clone(),
884                                form_album_artist.clone(),
885                                form_track,
886                                form_album,
887                                form_artist,
888                                form_album_artist,
889                                timestamp,
890                                true,
891                            );
892                            scrobble_edits.push(scrobble_edit);
893                        } else {
894                            log::warn!(
895                                "⚠️ Skipping form without valid timestamp: '{form_album}' by '{form_album_artist}'"
896                            );
897                        }
898                    }
899                }
900            }
901        }
902
903        Ok(scrobble_edits)
904    }
905
906    pub async fn get_artist_tracks_page(&self, artist: &str, page: u32) -> Result<TrackPage> {
907        let url = {
908            let session = self.session.lock().unwrap();
909            format!(
910                "{}/user/{}/library/music/{}/+tracks?page={}&ajax=true",
911                session.base_url,
912                session.username,
913                artist.replace(" ", "+"),
914                page
915            )
916        };
917
918        log::debug!("Fetching tracks page {page} for artist: {artist}");
919        let mut response = self.get(&url).await?;
920        let content = response
921            .body_string()
922            .await
923            .map_err(|e| LastFmError::Http(e.to_string()))?;
924
925        log::debug!(
926            "AJAX response: {} status, {} chars",
927            response.status(),
928            content.len()
929        );
930
931        log::debug!("Parsing HTML response from AJAX endpoint");
932        let document = Html::parse_document(&content);
933        self.parser.parse_tracks_page(&document, page, artist, None)
934    }
935
936    pub fn extract_tracks_from_document(
937        &self,
938        document: &Html,
939        artist: &str,
940        album: Option<&str>,
941    ) -> Result<Vec<Track>> {
942        self.parser
943            .extract_tracks_from_document(document, artist, album)
944    }
945
946    pub fn parse_tracks_page(
947        &self,
948        document: &Html,
949        page_number: u32,
950        artist: &str,
951        album: Option<&str>,
952    ) -> Result<TrackPage> {
953        self.parser
954            .parse_tracks_page(document, page_number, artist, album)
955    }
956
957    pub fn parse_recent_scrobbles(&self, document: &Html) -> Result<Vec<Track>> {
958        self.parser.parse_recent_scrobbles(document)
959    }
960
961    fn extract_csrf_token(&self, document: &Html) -> Result<String> {
962        let csrf_selector = Selector::parse("input[name=\"csrfmiddlewaretoken\"]").unwrap();
963
964        document
965            .select(&csrf_selector)
966            .next()
967            .and_then(|input| input.value().attr("value"))
968            .map(|token| token.to_string())
969            .ok_or(LastFmError::CsrfNotFound)
970    }
971
972    pub async fn get(&self, url: &str) -> Result<Response> {
973        self.get_with_retry(url).await
974    }
975
976    async fn get_with_retry(&self, url: &str) -> Result<Response> {
977        let config = self.config.retry.clone();
978
979        let url_string = url.to_string();
980        let client = self.clone();
981
982        let retry_result = retry::retry_with_backoff(
983            config,
984            &format!("GET {url}"),
985            || async {
986                let mut response = client.get_with_redirects(&url_string, 0).await?;
987
988                let body = client
989                    .extract_response_body(&url_string, &mut response)
990                    .await?;
991
992                if response.status().is_success() && client.is_rate_limit_response(&body) {
993                    log::debug!("Response body contains rate limit patterns");
994                    return Err(LastFmError::RateLimit { retry_after: 60 });
995                }
996
997                let mut new_response = http_types::Response::new(response.status());
998                for (name, values) in response.iter() {
999                    for value in values {
1000                        let _ = new_response.insert_header(name.clone(), value.clone());
1001                    }
1002                }
1003                new_response.set_body(body);
1004
1005                Ok(new_response)
1006            },
1007            |delay, rate_limit_timestamp, operation_name| {
1008                self.broadcast_event(ClientEvent::RateLimited {
1009                    delay_seconds: delay,
1010                    request: None, // No specific request context in retry callback
1011                    rate_limit_type: RateLimitType::ResponsePattern,
1012                    rate_limit_timestamp,
1013                });
1014                log::debug!("{operation_name} rate limited, waiting {delay} seconds");
1015            },
1016            |total_duration, _operation_name| {
1017                self.broadcast_event(ClientEvent::RateLimitEnded {
1018                    request: crate::types::RequestInfo::from_url_and_method(&url_string, "GET"),
1019                    rate_limit_type: RateLimitType::ResponsePattern,
1020                    total_rate_limit_duration_seconds: total_duration,
1021                });
1022            },
1023        )
1024        .await?;
1025
1026        Ok(retry_result.result)
1027    }
1028
1029    async fn get_with_redirects(&self, url: &str, redirect_count: u32) -> Result<Response> {
1030        if redirect_count > 5 {
1031            return Err(LastFmError::Http("Too many redirects".to_string()));
1032        }
1033
1034        let mut request = Request::new(Method::Get, url.parse::<Url>().unwrap());
1035
1036        {
1037            let session = self.session.lock().unwrap();
1038            headers::add_cookies(&mut request, &session.cookies);
1039            if session.cookies.is_empty() && url.contains("page=") {
1040                log::debug!("No cookies available for paginated request!");
1041            }
1042        }
1043
1044        let is_ajax = url.contains("ajax=true");
1045        let referer_url = if url.contains("page=") {
1046            Some(url.split('?').next().unwrap_or(url))
1047        } else {
1048            None
1049        };
1050
1051        headers::add_get_headers(&mut request, is_ajax, referer_url);
1052
1053        let request_info = RequestInfo::from_url_and_method(url, "GET");
1054        let request_start = std::time::Instant::now();
1055
1056        self.broadcast_event(ClientEvent::RequestStarted {
1057            request: request_info.clone(),
1058        });
1059
1060        let response = self
1061            .client
1062            .send(request)
1063            .await
1064            .map_err(|e| LastFmError::Http(e.to_string()))?;
1065
1066        self.broadcast_event(ClientEvent::RequestCompleted {
1067            request: request_info.clone(),
1068            status_code: response.status().into(),
1069            duration_ms: request_start.elapsed().as_millis() as u64,
1070        });
1071
1072        self.extract_cookies(&response);
1073
1074        if response.status() == 302 || response.status() == 301 {
1075            if let Some(location) = response.header("location") {
1076                if let Some(redirect_url) = location.get(0) {
1077                    let redirect_url_str = redirect_url.as_str();
1078                    if url.contains("page=") {
1079                        log::debug!("Following redirect from {url} to {redirect_url_str}");
1080
1081                        if redirect_url_str.contains("/login") {
1082                            log::debug!("Redirect to login page - authentication failed for paginated request");
1083                            return Err(LastFmError::Auth(
1084                                "Session expired or invalid for paginated request".to_string(),
1085                            ));
1086                        }
1087                    }
1088
1089                    let full_redirect_url = if redirect_url_str.starts_with('/') {
1090                        let base_url = self.session.lock().unwrap().base_url.clone();
1091                        format!("{base_url}{redirect_url_str}")
1092                    } else if redirect_url_str.starts_with("http") {
1093                        redirect_url_str.to_string()
1094                    } else {
1095                        let base_url = url
1096                            .rsplit('/')
1097                            .skip(1)
1098                            .collect::<Vec<_>>()
1099                            .into_iter()
1100                            .rev()
1101                            .collect::<Vec<_>>()
1102                            .join("/");
1103                        format!("{base_url}/{redirect_url_str}")
1104                    };
1105
1106                    return Box::pin(
1107                        self.get_with_redirects(&full_redirect_url, redirect_count + 1),
1108                    )
1109                    .await;
1110                }
1111            }
1112        }
1113
1114        if self.config.rate_limit.detect_by_status && response.status() == 429 {
1115            let retry_after = response
1116                .header("retry-after")
1117                .and_then(|h| h.get(0))
1118                .and_then(|v| v.as_str().parse::<u64>().ok())
1119                .unwrap_or(60);
1120            self.broadcast_event(ClientEvent::RateLimited {
1121                delay_seconds: retry_after,
1122                request: Some(request_info.clone()),
1123                rate_limit_type: RateLimitType::Http429,
1124                rate_limit_timestamp: std::time::SystemTime::now()
1125                    .duration_since(std::time::UNIX_EPOCH)
1126                    .unwrap_or_default()
1127                    .as_secs(),
1128            });
1129            return Err(LastFmError::RateLimit { retry_after });
1130        }
1131
1132        if self.config.rate_limit.detect_by_status && response.status() == 403 {
1133            log::debug!("Got 403 response, checking if it's a rate limit");
1134            {
1135                let session = self.session.lock().unwrap();
1136                if !session.cookies.is_empty() {
1137                    log::debug!("403 on authenticated request - likely rate limit");
1138                    self.broadcast_event(ClientEvent::RateLimited {
1139                        delay_seconds: 60,
1140                        request: Some(request_info.clone()),
1141                        rate_limit_type: RateLimitType::Http403,
1142                        rate_limit_timestamp: std::time::SystemTime::now()
1143                            .duration_since(std::time::UNIX_EPOCH)
1144                            .unwrap_or_default()
1145                            .as_secs(),
1146                    });
1147                    return Err(LastFmError::RateLimit { retry_after: 60 });
1148                }
1149            }
1150        }
1151
1152        Ok(response)
1153    }
1154
1155    fn is_rate_limit_response(&self, response_body: &str) -> bool {
1156        let rate_limit_config = &self.config.rate_limit;
1157
1158        if !rate_limit_config.detect_by_patterns && rate_limit_config.custom_patterns.is_empty() {
1159            return false;
1160        }
1161
1162        let body_lower = response_body.to_lowercase();
1163
1164        for pattern in &rate_limit_config.custom_patterns {
1165            if body_lower.contains(&pattern.to_lowercase()) {
1166                return true;
1167            }
1168        }
1169
1170        if rate_limit_config.detect_by_patterns {
1171            for pattern in &rate_limit_config.patterns {
1172                if body_lower.contains(&pattern.to_lowercase()) {
1173                    return true;
1174                }
1175            }
1176        }
1177
1178        false
1179    }
1180
1181    fn extract_cookies(&self, response: &Response) {
1182        let mut session = self.session.lock().unwrap();
1183        extract_cookies_from_response(response, &mut session.cookies);
1184    }
1185
1186    async fn extract_response_body(&self, _url: &str, response: &mut Response) -> Result<String> {
1187        let body = response
1188            .body_string()
1189            .await
1190            .map_err(|e| LastFmError::Http(e.to_string()))?;
1191
1192        Ok(body)
1193    }
1194
1195    pub async fn get_artist_albums_page(&self, artist: &str, page: u32) -> Result<AlbumPage> {
1196        let url = {
1197            let session = self.session.lock().unwrap();
1198            format!(
1199                "{}/user/{}/library/music/{}/+albums?page={}&ajax=true",
1200                session.base_url,
1201                session.username,
1202                artist.replace(" ", "+"),
1203                page
1204            )
1205        };
1206
1207        log::debug!("Fetching albums page {page} for artist: {artist}");
1208        let mut response = self.get(&url).await?;
1209        let content = response
1210            .body_string()
1211            .await
1212            .map_err(|e| LastFmError::Http(e.to_string()))?;
1213
1214        log::debug!(
1215            "AJAX response: {} status, {} chars",
1216            response.status(),
1217            content.len()
1218        );
1219
1220        log::debug!("Parsing HTML response from AJAX endpoint");
1221        let document = Html::parse_document(&content);
1222        self.parser.parse_albums_page(&document, page, artist)
1223    }
1224
1225    pub async fn get_album_tracks_page(
1226        &self,
1227        album_name: &str,
1228        artist_name: &str,
1229        page: u32,
1230    ) -> Result<TrackPage> {
1231        let url = {
1232            let session = self.session.lock().unwrap();
1233            format!(
1234                "{}/user/{}/library/music/{}/{}?page={}&ajax=true",
1235                session.base_url,
1236                session.username,
1237                artist_name.replace(" ", "+"),
1238                album_name.replace(" ", "+"),
1239                page
1240            )
1241        };
1242
1243        log::debug!("Fetching tracks page {page} for album '{album_name}' by '{artist_name}'");
1244        let mut response = self.get(&url).await?;
1245        let content = response
1246            .body_string()
1247            .await
1248            .map_err(|e| LastFmError::Http(e.to_string()))?;
1249
1250        log::debug!(
1251            "AJAX response: {} status, {} chars",
1252            response.status(),
1253            content.len()
1254        );
1255
1256        log::debug!("Parsing HTML response from AJAX endpoint");
1257        let document = Html::parse_document(&content);
1258        self.parser
1259            .parse_tracks_page(&document, page, artist_name, Some(album_name))
1260    }
1261
1262    pub async fn search_tracks_page(&self, query: &str, page: u32) -> Result<TrackPage> {
1263        let url = {
1264            let session = self.session.lock().unwrap();
1265            format!(
1266                "{}/user/{}/library/tracks/search?page={}&query={}&ajax=1",
1267                session.base_url,
1268                session.username,
1269                page,
1270                urlencoding::encode(query)
1271            )
1272        };
1273
1274        log::debug!("Searching tracks for query '{query}' on page {page}");
1275        let mut response = self.get(&url).await?;
1276        let content = response
1277            .body_string()
1278            .await
1279            .map_err(|e| LastFmError::Http(e.to_string()))?;
1280
1281        log::debug!(
1282            "Track search response: {} status, {} chars",
1283            response.status(),
1284            content.len()
1285        );
1286
1287        let document = Html::parse_document(&content);
1288        let tracks = self.parser.parse_track_search_results(&document)?;
1289
1290        // For search results, we need to determine pagination differently
1291        // since we don't have the same pagination structure as regular library pages
1292        let (has_next_page, total_pages) = self.parser.parse_pagination(&document, page)?;
1293
1294        Ok(TrackPage {
1295            tracks,
1296            page_number: page,
1297            has_next_page,
1298            total_pages,
1299        })
1300    }
1301
1302    pub async fn search_albums_page(&self, query: &str, page: u32) -> Result<AlbumPage> {
1303        let url = {
1304            let session = self.session.lock().unwrap();
1305            format!(
1306                "{}/user/{}/library/albums/search?page={}&query={}&ajax=1",
1307                session.base_url,
1308                session.username,
1309                page,
1310                urlencoding::encode(query)
1311            )
1312        };
1313
1314        log::debug!("Searching albums for query '{query}' on page {page}");
1315        let mut response = self.get(&url).await?;
1316        let content = response
1317            .body_string()
1318            .await
1319            .map_err(|e| LastFmError::Http(e.to_string()))?;
1320
1321        log::debug!(
1322            "Album search response: {} status, {} chars",
1323            response.status(),
1324            content.len()
1325        );
1326
1327        let document = Html::parse_document(&content);
1328        let albums = self.parser.parse_album_search_results(&document)?;
1329
1330        // For search results, we need to determine pagination differently
1331        let (has_next_page, total_pages) = self.parser.parse_pagination(&document, page)?;
1332
1333        Ok(AlbumPage {
1334            albums,
1335            page_number: page,
1336            has_next_page,
1337            total_pages,
1338        })
1339    }
1340}
1341
1342#[async_trait(?Send)]
1343impl LastFmEditClient for LastFmEditClientImpl {
1344    fn username(&self) -> String {
1345        self.username()
1346    }
1347
1348    async fn get_recent_scrobbles(&self, page: u32) -> Result<Vec<Track>> {
1349        self.get_recent_scrobbles(page).await
1350    }
1351
1352    async fn find_recent_scrobble_for_track(
1353        &self,
1354        track_name: &str,
1355        artist_name: &str,
1356        max_pages: u32,
1357    ) -> Result<Option<Track>> {
1358        self.find_recent_scrobble_for_track(track_name, artist_name, max_pages)
1359            .await
1360    }
1361
1362    async fn edit_scrobble(&self, edit: &ScrobbleEdit) -> Result<EditResponse> {
1363        self.edit_scrobble(edit).await
1364    }
1365
1366    async fn edit_scrobble_single(
1367        &self,
1368        exact_edit: &ExactScrobbleEdit,
1369        max_retries: u32,
1370    ) -> Result<EditResponse> {
1371        self.edit_scrobble_single(exact_edit, max_retries).await
1372    }
1373
1374    fn get_session(&self) -> LastFmEditSession {
1375        self.get_session()
1376    }
1377
1378    fn restore_session(&self, session: LastFmEditSession) {
1379        self.restore_session(session)
1380    }
1381
1382    fn subscribe(&self) -> ClientEventReceiver {
1383        self.subscribe()
1384    }
1385
1386    fn latest_event(&self) -> Option<ClientEvent> {
1387        self.latest_event()
1388    }
1389
1390    fn discover_scrobbles(
1391        &self,
1392        edit: ScrobbleEdit,
1393    ) -> Box<dyn crate::AsyncDiscoveryIterator<crate::ExactScrobbleEdit>> {
1394        let track_name = edit.track_name_original.clone();
1395        let album_name = edit.album_name_original.clone();
1396
1397        match (&track_name, &album_name) {
1398            (Some(track_name), Some(album_name)) => Box::new(crate::ExactMatchDiscovery::new(
1399                self.clone(),
1400                edit,
1401                track_name.clone(),
1402                album_name.clone(),
1403            )),
1404
1405            (Some(track_name), None) => Box::new(crate::TrackVariationsDiscovery::new(
1406                self.clone(),
1407                edit,
1408                track_name.clone(),
1409            )),
1410
1411            (None, Some(album_name)) => Box::new(crate::AlbumTracksDiscovery::new(
1412                self.clone(),
1413                edit,
1414                album_name.clone(),
1415            )),
1416
1417            (None, None) => Box::new(crate::ArtistTracksDiscovery::new(self.clone(), edit)),
1418        }
1419    }
1420
1421    async fn get_artist_tracks_page(&self, artist: &str, page: u32) -> Result<TrackPage> {
1422        self.get_artist_tracks_page(artist, page).await
1423    }
1424
1425    async fn get_artist_albums_page(&self, artist: &str, page: u32) -> Result<AlbumPage> {
1426        self.get_artist_albums_page(artist, page).await
1427    }
1428
1429    async fn get_album_tracks_page(
1430        &self,
1431        album_name: &str,
1432        artist_name: &str,
1433        page: u32,
1434    ) -> Result<TrackPage> {
1435        self.get_album_tracks_page(album_name, artist_name, page)
1436            .await
1437    }
1438
1439    fn artist_tracks(&self, artist: &str) -> Box<dyn crate::AsyncPaginatedIterator<Track>> {
1440        Box::new(crate::ArtistTracksIterator::new(
1441            self.clone(),
1442            artist.to_string(),
1443        ))
1444    }
1445
1446    fn artist_albums(&self, artist: &str) -> Box<dyn crate::AsyncPaginatedIterator<crate::Album>> {
1447        Box::new(crate::ArtistAlbumsIterator::new(
1448            self.clone(),
1449            artist.to_string(),
1450        ))
1451    }
1452
1453    fn album_tracks(
1454        &self,
1455        album_name: &str,
1456        artist_name: &str,
1457    ) -> Box<dyn crate::AsyncPaginatedIterator<Track>> {
1458        Box::new(crate::AlbumTracksIterator::new(
1459            self.clone(),
1460            album_name.to_string(),
1461            artist_name.to_string(),
1462        ))
1463    }
1464
1465    fn recent_tracks(&self) -> Box<dyn crate::AsyncPaginatedIterator<Track>> {
1466        Box::new(crate::RecentTracksIterator::new(self.clone()))
1467    }
1468
1469    fn recent_tracks_from_page(
1470        &self,
1471        starting_page: u32,
1472    ) -> Box<dyn crate::AsyncPaginatedIterator<Track>> {
1473        Box::new(crate::RecentTracksIterator::with_starting_page(
1474            self.clone(),
1475            starting_page,
1476        ))
1477    }
1478
1479    fn search_tracks(&self, query: &str) -> Box<dyn crate::AsyncPaginatedIterator<Track>> {
1480        Box::new(crate::SearchTracksIterator::new(
1481            self.clone(),
1482            query.to_string(),
1483        ))
1484    }
1485
1486    fn search_albums(&self, query: &str) -> Box<dyn crate::AsyncPaginatedIterator<crate::Album>> {
1487        Box::new(crate::SearchAlbumsIterator::new(
1488            self.clone(),
1489            query.to_string(),
1490        ))
1491    }
1492
1493    async fn search_tracks_page(&self, query: &str, page: u32) -> Result<crate::TrackPage> {
1494        self.search_tracks_page(query, page).await
1495    }
1496
1497    async fn search_albums_page(&self, query: &str, page: u32) -> Result<crate::AlbumPage> {
1498        self.search_albums_page(query, page).await
1499    }
1500
1501    async fn validate_session(&self) -> bool {
1502        self.validate_session().await
1503    }
1504
1505    async fn delete_scrobble(
1506        &self,
1507        artist_name: &str,
1508        track_name: &str,
1509        timestamp: u64,
1510    ) -> Result<bool> {
1511        self.delete_scrobble(artist_name, track_name, timestamp)
1512            .await
1513    }
1514}