Skip to main content

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