lastfm_edit/
client.rs

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