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