lastfm_edit/
client.rs

1use crate::edit::{ExactScrobbleEdit, SingleEditResponse};
2use crate::parsing::LastFmParser;
3use crate::r#trait::LastFmEditClient;
4use crate::session::LastFmEditSession;
5use crate::{AlbumPage, EditResponse, LastFmError, Result, ScrobbleEdit, Track, TrackPage};
6use async_trait::async_trait;
7use http_client::{HttpClient, Request, Response};
8use http_types::{Method, Url};
9use scraper::{Html, Selector};
10use std::collections::HashMap;
11use std::fs;
12use std::path::Path;
13use std::sync::{Arc, Mutex};
14
15/// Main implementation for interacting with Last.fm's web interface.
16///
17/// This implementation handles authentication, session management, and provides methods for
18/// browsing user libraries and editing scrobble data through web scraping.
19///
20#[derive(Clone)]
21pub struct LastFmEditClientImpl {
22    client: Arc<dyn HttpClient + Send + Sync>,
23    session: Arc<Mutex<LastFmEditSession>>,
24    rate_limit_patterns: Vec<String>,
25    debug_save_responses: bool,
26    parser: LastFmParser,
27}
28
29impl LastFmEditClientImpl {
30    /// Create a new [`LastFmEditClient`] with the default Last.fm URL.
31    ///
32    /// **Note:** This creates an unauthenticated client. You must call [`login`](Self::login)
33    /// or [`restore_session`](Self::restore_session) before using most functionality.
34    ///
35    /// # Arguments
36    ///
37    /// * `client` - Any HTTP client implementation that implements [`HttpClient`]
38    ///
39    pub fn new(client: Box<dyn HttpClient + Send + Sync>) -> Self {
40        Self::with_base_url(client, "https://www.last.fm".to_string())
41    }
42
43    /// Create a new [`LastFmEditClient`] with a custom base URL.
44    ///
45    /// **Note:** This creates an unauthenticated client. You must call [`login`](Self::login)
46    /// or [`restore_session`](Self::restore_session) before using most functionality.
47    ///
48    /// This is useful for testing or if Last.fm changes their domain.
49    ///
50    /// # Arguments
51    ///
52    /// * `client` - Any HTTP client implementation
53    /// * `base_url` - The base URL for Last.fm (e.g., <https://www.last.fm>)
54    pub fn with_base_url(client: Box<dyn HttpClient + Send + Sync>, base_url: String) -> Self {
55        Self::with_rate_limit_patterns(
56            client,
57            base_url,
58            vec![
59                "you've tried to log in too many times".to_string(),
60                "you're requesting too many pages".to_string(),
61                "slow down".to_string(),
62                "too fast".to_string(),
63                "rate limit".to_string(),
64                "throttled".to_string(),
65                "temporarily blocked".to_string(),
66                "temporarily restricted".to_string(),
67                "captcha".to_string(),
68                "verify you're human".to_string(),
69                "prove you're not a robot".to_string(),
70                "security check".to_string(),
71                "service temporarily unavailable".to_string(),
72                "quota exceeded".to_string(),
73                "limit exceeded".to_string(),
74                "daily limit".to_string(),
75            ],
76        )
77    }
78
79    /// Create a new [`LastFmEditClient`] with custom rate limit detection patterns.
80    ///
81    /// # Arguments
82    ///
83    /// * `client` - Any HTTP client implementation
84    /// * `base_url` - The base URL for Last.fm
85    /// * `rate_limit_patterns` - Text patterns that indicate rate limiting in responses
86    pub fn with_rate_limit_patterns(
87        client: Box<dyn HttpClient + Send + Sync>,
88        base_url: String,
89        rate_limit_patterns: Vec<String>,
90    ) -> Self {
91        Self {
92            client: Arc::from(client),
93            session: Arc::new(Mutex::new(LastFmEditSession::new(
94                String::new(),
95                Vec::new(),
96                None,
97                base_url,
98            ))),
99            rate_limit_patterns,
100            debug_save_responses: std::env::var("LASTFM_DEBUG_SAVE_RESPONSES").is_ok(),
101            parser: LastFmParser::new(),
102        }
103    }
104
105    /// Create a new authenticated [`LastFmEditClient`] by logging in with username and password.
106    ///
107    /// This is a convenience method that combines client creation and login into one step.
108    ///
109    /// # Arguments
110    ///
111    /// * `client` - Any HTTP client implementation
112    /// * `username` - Last.fm username or email
113    /// * `password` - Last.fm password
114    ///
115    /// # Returns
116    ///
117    /// Returns an authenticated client on success, or [`LastFmError::Auth`] on failure.
118    ///
119    pub async fn login_with_credentials(
120        client: Box<dyn HttpClient + Send + Sync>,
121        username: &str,
122        password: &str,
123    ) -> Result<Self> {
124        let new_client = Self::new(client);
125        new_client.login(username, password).await?;
126        Ok(new_client)
127    }
128
129    /// Create a new [`LastFmEditClient`] by restoring a previously saved session.
130    ///
131    /// This allows you to resume a Last.fm session without requiring the user to log in again.
132    ///
133    /// # Arguments
134    ///
135    /// * `client` - Any HTTP client implementation
136    /// * `session` - Previously saved [`LastFmEditSession`]
137    ///
138    /// # Returns
139    ///
140    /// Returns a client with the restored session.
141    ///
142    pub fn from_session(
143        client: Box<dyn HttpClient + Send + Sync>,
144        session: LastFmEditSession,
145    ) -> Self {
146        Self {
147            client: Arc::from(client),
148            session: Arc::new(Mutex::new(session)),
149            rate_limit_patterns: vec![
150                "you've tried to log in too many times".to_string(),
151                "you're requesting too many pages".to_string(),
152                "slow down".to_string(),
153                "too fast".to_string(),
154                "rate limit".to_string(),
155                "throttled".to_string(),
156                "temporarily blocked".to_string(),
157                "temporarily restricted".to_string(),
158                "captcha".to_string(),
159                "verify you're human".to_string(),
160                "prove you're not a robot".to_string(),
161                "security check".to_string(),
162                "service temporarily unavailable".to_string(),
163                "quota exceeded".to_string(),
164                "limit exceeded".to_string(),
165                "daily limit".to_string(),
166            ],
167            debug_save_responses: std::env::var("LASTFM_DEBUG_SAVE_RESPONSES").is_ok(),
168            parser: LastFmParser::new(),
169        }
170    }
171
172    /// Extract the current session state for persistence.
173    ///
174    /// This allows you to save the authentication state and restore it later
175    /// without requiring the user to log in again.
176    ///
177    /// # Returns
178    ///
179    /// Returns a [`LastFmEditSession`] that can be serialized and saved.
180    ///
181    pub fn get_session(&self) -> LastFmEditSession {
182        self.session.lock().unwrap().clone()
183    }
184
185    /// Restore session state from a previously saved [`LastFmEditSession`].
186    ///
187    /// This allows you to restore authentication state without logging in again.
188    ///
189    /// # Arguments
190    ///
191    /// * `session` - Previously saved session state
192    ///
193    pub fn restore_session(&self, session: LastFmEditSession) {
194        *self.session.lock().unwrap() = session;
195    }
196
197    /// Authenticate with Last.fm using username and password.
198    ///
199    /// This method:
200    /// 1. Fetches the login page to extract CSRF tokens
201    /// 2. Submits the login form with credentials
202    /// 3. Validates the authentication by checking for session cookies
203    /// 4. Stores session data for subsequent requests
204    ///
205    /// # Arguments
206    ///
207    /// * `username` - Last.fm username or email
208    /// * `password` - Last.fm password
209    ///
210    /// # Returns
211    ///
212    /// Returns [`Ok(())`] on successful authentication, or [`LastFmError::Auth`] on failure.
213    ///
214    pub async fn login(&self, username: &str, password: &str) -> Result<()> {
215        // Get login page to extract CSRF token
216        let login_url = {
217            let session = self.session.lock().unwrap();
218            format!("{}/login", session.base_url)
219        };
220        let mut response = self.get(&login_url).await?;
221
222        // Extract any initial cookies from the login page
223        self.extract_cookies(&response);
224
225        let html = response
226            .body_string()
227            .await
228            .map_err(|e| LastFmError::Http(e.to_string()))?;
229
230        // Parse HTML synchronously to avoid holding parser state across await boundaries
231        let (csrf_token, next_field) = self.extract_login_form_data(&html)?;
232
233        // Submit login form
234        let mut form_data = HashMap::new();
235        form_data.insert("csrfmiddlewaretoken", csrf_token.as_str());
236        form_data.insert("username_or_email", username);
237        form_data.insert("password", password);
238
239        // Add 'next' field if present
240        if let Some(ref next_value) = next_field {
241            form_data.insert("next", next_value);
242        }
243
244        let mut request = Request::new(Method::Post, login_url.parse::<Url>().unwrap());
245        let _ = request.insert_header("Referer", &login_url);
246        {
247            let session = self.session.lock().unwrap();
248            let _ = request.insert_header("Origin", &session.base_url);
249        }
250        let _ = request.insert_header("Content-Type", "application/x-www-form-urlencoded");
251        let _ = request.insert_header(
252            "User-Agent",
253            "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36"
254        );
255        let _ = request.insert_header(
256            "Accept",
257            "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"
258        );
259        let _ = request.insert_header("Accept-Language", "en-US,en;q=0.9");
260        let _ = request.insert_header("Accept-Encoding", "gzip, deflate, br");
261        let _ = request.insert_header("DNT", "1");
262        let _ = request.insert_header("Connection", "keep-alive");
263        let _ = request.insert_header("Upgrade-Insecure-Requests", "1");
264        let _ = request.insert_header(
265            "sec-ch-ua",
266            "\"Not)A;Brand\";v=\"8\", \"Chromium\";v=\"138\", \"Google Chrome\";v=\"138\"",
267        );
268        let _ = request.insert_header("sec-ch-ua-mobile", "?0");
269        let _ = request.insert_header("sec-ch-ua-platform", "\"Linux\"");
270        let _ = request.insert_header("Sec-Fetch-Dest", "document");
271        let _ = request.insert_header("Sec-Fetch-Mode", "navigate");
272        let _ = request.insert_header("Sec-Fetch-Site", "same-origin");
273        let _ = request.insert_header("Sec-Fetch-User", "?1");
274
275        // Add any cookies we already have
276        {
277            let session = self.session.lock().unwrap();
278            if !session.cookies.is_empty() {
279                let cookie_header = session.cookies.join("; ");
280                let _ = request.insert_header("Cookie", &cookie_header);
281            }
282        }
283
284        // Convert form data to URL-encoded string
285        let form_string: String = form_data
286            .iter()
287            .map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v)))
288            .collect::<Vec<_>>()
289            .join("&");
290
291        request.set_body(form_string);
292
293        let mut response = self
294            .client
295            .send(request)
296            .await
297            .map_err(|e| LastFmError::Http(e.to_string()))?;
298
299        // Extract session cookies from login response
300        self.extract_cookies(&response);
301
302        log::debug!("Login response status: {}", response.status());
303
304        // If we get a 403, it might be rate limiting or auth failure
305        if response.status() == 403 {
306            // Get the response body to check if it's rate limiting
307            let response_html = response
308                .body_string()
309                .await
310                .map_err(|e| LastFmError::Http(e.to_string()))?;
311
312            // Look for rate limit indicators in the response
313            if self.is_rate_limit_response(&response_html) {
314                log::debug!("403 response appears to be rate limiting");
315                return Err(LastFmError::RateLimit { retry_after: 60 });
316            }
317            log::debug!("403 response appears to be authentication failure");
318
319            // Continue with the normal auth failure handling using the response_html
320            let login_error = self.parse_login_error(&response_html);
321            return Err(LastFmError::Auth(login_error));
322        }
323
324        // Check if we got a new sessionid that looks like a real Last.fm session
325        let has_real_session = {
326            let session = self.session.lock().unwrap();
327            session
328                .cookies
329                .iter()
330                .any(|cookie| cookie.starts_with("sessionid=.") && cookie.len() > 50)
331        };
332
333        if has_real_session && (response.status() == 302 || response.status() == 200) {
334            // We got a real session ID, login was successful
335            {
336                let mut session = self.session.lock().unwrap();
337                session.username = username.to_string();
338                session.csrf_token = Some(csrf_token);
339            }
340            log::debug!("Login successful - authenticated session established");
341            return Ok(());
342        }
343
344        // At this point, we didn't get a 403, so read the response body for other cases
345        let response_html = response
346            .body_string()
347            .await
348            .map_err(|e| LastFmError::Http(e.to_string()))?;
349
350        // Check if we were redirected away from login page (success) by parsing synchronously
351        let has_login_form = self.check_for_login_form(&response_html);
352
353        if !has_login_form && response.status() == 200 {
354            {
355                let mut session = self.session.lock().unwrap();
356                session.username = username.to_string();
357                session.csrf_token = Some(csrf_token);
358            }
359            Ok(())
360        } else {
361            // Parse error messages synchronously
362            let error_msg = self.parse_login_error(&response_html);
363            Err(LastFmError::Auth(error_msg))
364        }
365    }
366
367    /// Get the currently authenticated username.
368    ///
369    /// Returns an empty string if not logged in.
370    pub fn username(&self) -> String {
371        self.session.lock().unwrap().username.clone()
372    }
373
374    /// Check if the client is currently authenticated.
375    ///
376    /// Returns `true` if [`login`](Self::login) was successful and session is active.
377    pub fn is_logged_in(&self) -> bool {
378        self.session.lock().unwrap().is_valid()
379    }
380
381    /// Fetch recent scrobbles from the user's listening history
382    /// This gives us real scrobble data with timestamps for editing
383    pub async fn get_recent_scrobbles(&self, page: u32) -> Result<Vec<Track>> {
384        let url = {
385            let session = self.session.lock().unwrap();
386            format!(
387                "{}/user/{}/library?page={}",
388                session.base_url, session.username, page
389            )
390        };
391
392        log::debug!("Fetching recent scrobbles page {page}");
393        let mut response = self.get(&url).await?;
394        let content = response
395            .body_string()
396            .await
397            .map_err(|e| LastFmError::Http(e.to_string()))?;
398
399        log::debug!(
400            "Recent scrobbles response: {} status, {} chars",
401            response.status(),
402            content.len()
403        );
404
405        let document = Html::parse_document(&content);
406        self.parser.parse_recent_scrobbles(&document)
407    }
408
409    /// Get a page of tracks from the user's recent listening history.
410    pub async fn get_recent_tracks_page(&self, page: u32) -> Result<TrackPage> {
411        let tracks = self.get_recent_scrobbles(page).await?;
412
413        // For now, we'll create a basic TrackPage from the tracks
414        // In a real implementation, we might need to parse pagination info from the HTML
415        let has_next_page = !tracks.is_empty(); // Simple heuristic
416
417        Ok(TrackPage {
418            tracks,
419            page_number: page,
420            has_next_page,
421            total_pages: None, // Recent tracks don't have a definite total
422        })
423    }
424
425    /// Find the most recent scrobble for a specific track
426    /// This searches through recent listening history to find real scrobble data
427    pub async fn find_recent_scrobble_for_track(
428        &self,
429        track_name: &str,
430        artist_name: &str,
431        max_pages: u32,
432    ) -> Result<Option<Track>> {
433        log::debug!("Searching for recent scrobble: '{track_name}' by '{artist_name}'");
434
435        for page in 1..=max_pages {
436            let scrobbles = self.get_recent_scrobbles(page).await?;
437
438            for scrobble in scrobbles {
439                if scrobble.name == track_name && scrobble.artist == artist_name {
440                    log::debug!(
441                        "Found recent scrobble: '{}' with timestamp {:?}",
442                        scrobble.name,
443                        scrobble.timestamp
444                    );
445                    return Ok(Some(scrobble));
446                }
447            }
448        }
449
450        log::debug!(
451            "No recent scrobble found for '{track_name}' by '{artist_name}' in {max_pages} pages"
452        );
453        Ok(None)
454    }
455
456    pub async fn edit_scrobble(&self, edit: &ScrobbleEdit) -> Result<EditResponse> {
457        // Use the generalized discovery function to find all relevant scrobble instances
458        let discovered_edits = self.discover_scrobble_edit_variations(edit).await?;
459
460        if discovered_edits.is_empty() {
461            let context = match (&edit.track_name_original, &edit.album_name_original) {
462                (Some(track_name), _) => {
463                    format!("track '{}' by '{}'", track_name, edit.artist_name_original)
464                }
465                (None, Some(album_name)) => {
466                    format!("album '{}' by '{}'", album_name, edit.artist_name_original)
467                }
468                (None, None) => format!("artist '{}'", edit.artist_name_original),
469            };
470            return Err(LastFmError::Parse(format!(
471                "No scrobbles found for {context}. Make sure the names are correct and that you have scrobbled recently."
472            )));
473        }
474
475        log::info!(
476            "Discovered {} scrobble instances to edit",
477            discovered_edits.len()
478        );
479
480        let mut all_results = Vec::new();
481
482        // For each discovered scrobble instance, apply the user's desired changes and edit it
483        for (index, discovered_edit) in discovered_edits.iter().enumerate() {
484            log::debug!(
485                "Processing scrobble {}/{}: '{}' from '{}'",
486                index + 1,
487                discovered_edits.len(),
488                discovered_edit.track_name_original,
489                discovered_edit.album_name_original
490            );
491
492            // Apply the user's desired changes to the discovered exact edit
493            let mut modified_exact_edit = discovered_edit.clone();
494
495            // Apply user's changes or keep original values
496            if let Some(new_track_name) = &edit.track_name {
497                modified_exact_edit.track_name = new_track_name.clone();
498            }
499            if let Some(new_album_name) = &edit.album_name {
500                modified_exact_edit.album_name = new_album_name.clone();
501            }
502            modified_exact_edit.artist_name = edit.artist_name.clone();
503            if let Some(new_album_artist_name) = &edit.album_artist_name {
504                modified_exact_edit.album_artist_name = new_album_artist_name.clone();
505            }
506            modified_exact_edit.edit_all = edit.edit_all;
507
508            let album_info = format!(
509                "{} by {}",
510                modified_exact_edit.album_name_original,
511                modified_exact_edit.album_artist_name_original
512            );
513
514            let single_response = self.edit_scrobble_single(&modified_exact_edit, 3).await?;
515            let success = single_response.success();
516            let message = single_response.message();
517
518            all_results.push(SingleEditResponse {
519                success,
520                message,
521                album_info: Some(album_info),
522                exact_scrobble_edit: modified_exact_edit.clone(),
523            });
524
525            // Add delay between edits to be respectful to the server
526            if index < discovered_edits.len() - 1 {
527                tokio::time::sleep(std::time::Duration::from_millis(1000)).await;
528            }
529        }
530
531        Ok(EditResponse::from_results(all_results))
532    }
533
534    /// Edit a single scrobble with retry logic, returning a single-result EditResponse.
535    ///
536    /// This method takes a fully-specified `ExactScrobbleEdit` and performs a single
537    /// edit operation. Unlike `edit_scrobble`, this method does not perform enrichment
538    /// or multiple edits - it edits exactly one scrobble instance.
539    ///
540    /// # Arguments
541    /// * `exact_edit` - A fully-specified edit with all required fields populated
542    /// * `max_retries` - Maximum number of retry attempts for rate limiting
543    ///
544    pub async fn edit_scrobble_single(
545        &self,
546        exact_edit: &ExactScrobbleEdit,
547        max_retries: u32,
548    ) -> Result<EditResponse> {
549        let mut retries = 0;
550
551        loop {
552            match self.edit_scrobble_impl(exact_edit).await {
553                Ok(success) => {
554                    return Ok(EditResponse::single(
555                        success,
556                        None,
557                        None,
558                        exact_edit.clone(),
559                    ));
560                }
561                Err(LastFmError::RateLimit { retry_after }) => {
562                    if retries >= max_retries {
563                        log::warn!("Max retries ({max_retries}) exceeded for edit operation");
564                        return Ok(EditResponse::single(
565                            false,
566                            Some(format!("Rate limit exceeded after {max_retries} retries")),
567                            None,
568                            exact_edit.clone(),
569                        ));
570                    }
571
572                    let delay = std::cmp::min(retry_after, 2_u64.pow(retries + 1) * 5);
573                    log::info!(
574                        "Edit rate limited. Waiting {} seconds before retry {} of {}",
575                        delay,
576                        retries + 1,
577                        max_retries
578                    );
579                    tokio::time::sleep(std::time::Duration::from_secs(delay)).await;
580                    retries += 1;
581                }
582                Err(other_error) => {
583                    return Ok(EditResponse::single(
584                        false,
585                        Some(other_error.to_string()),
586                        None,
587                        exact_edit.clone(),
588                    ));
589                }
590            }
591        }
592    }
593
594    async fn edit_scrobble_impl(&self, exact_edit: &ExactScrobbleEdit) -> Result<bool> {
595        if !self.is_logged_in() {
596            return Err(LastFmError::Auth(
597                "Must be logged in to edit scrobbles".to_string(),
598            ));
599        }
600
601        let edit_url = {
602            let session = self.session.lock().unwrap();
603            format!(
604                "{}/user/{}/library/edit?edited-variation=library-track-scrobble",
605                session.base_url, session.username
606            )
607        };
608
609        log::debug!("Getting fresh CSRF token for edit");
610
611        // First request: Get the edit form to extract fresh CSRF token
612        let form_html = self.get_edit_form_html(&edit_url).await?;
613
614        // Parse HTML to get fresh CSRF token - do parsing synchronously
615        let form_document = Html::parse_document(&form_html);
616        let fresh_csrf_token = self.extract_csrf_token(&form_document)?;
617
618        log::debug!("Submitting edit with fresh token");
619
620        let mut form_data = HashMap::new();
621
622        // Add fresh CSRF token (required)
623        form_data.insert("csrfmiddlewaretoken", fresh_csrf_token.as_str());
624
625        // Include ALL form fields (using ExactScrobbleEdit which has all required fields)
626        form_data.insert("track_name_original", &exact_edit.track_name_original);
627        form_data.insert("track_name", &exact_edit.track_name);
628        form_data.insert("artist_name_original", &exact_edit.artist_name_original);
629        form_data.insert("artist_name", &exact_edit.artist_name);
630        form_data.insert("album_name_original", &exact_edit.album_name_original);
631        form_data.insert("album_name", &exact_edit.album_name);
632        form_data.insert(
633            "album_artist_name_original",
634            &exact_edit.album_artist_name_original,
635        );
636        form_data.insert("album_artist_name", &exact_edit.album_artist_name);
637
638        // Include timestamp (ExactScrobbleEdit always has a timestamp)
639        let timestamp_str = exact_edit.timestamp.to_string();
640        form_data.insert("timestamp", &timestamp_str);
641
642        // Edit flags
643        if exact_edit.edit_all {
644            form_data.insert("edit_all", "1");
645        }
646        form_data.insert("submit", "edit-scrobble");
647        form_data.insert("ajax", "1");
648
649        log::debug!(
650            "Editing scrobble: '{}' -> '{}'",
651            exact_edit.track_name_original,
652            exact_edit.track_name
653        );
654        {
655            let session = self.session.lock().unwrap();
656            log::trace!("Session cookies count: {}", session.cookies.len());
657        }
658
659        let mut request = Request::new(Method::Post, edit_url.parse::<Url>().unwrap());
660
661        // Add comprehensive headers matching your browser request
662        let _ = request.insert_header("Accept", "*/*");
663        let _ = request.insert_header("Accept-Language", "en-US,en;q=0.9");
664        let _ = request.insert_header(
665            "Content-Type",
666            "application/x-www-form-urlencoded;charset=UTF-8",
667        );
668        let _ = request.insert_header("Priority", "u=1, i");
669        let _ = request.insert_header("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36");
670        let _ = request.insert_header("X-Requested-With", "XMLHttpRequest");
671        let _ = request.insert_header("Sec-Fetch-Dest", "empty");
672        let _ = request.insert_header("Sec-Fetch-Mode", "cors");
673        let _ = request.insert_header("Sec-Fetch-Site", "same-origin");
674        let _ = request.insert_header(
675            "sec-ch-ua",
676            "\"Not)A;Brand\";v=\"8\", \"Chromium\";v=\"138\", \"Google Chrome\";v=\"138\"",
677        );
678        let _ = request.insert_header("sec-ch-ua-mobile", "?0");
679        let _ = request.insert_header("sec-ch-ua-platform", "\"Linux\"");
680
681        // Add session cookies
682        {
683            let session = self.session.lock().unwrap();
684            if !session.cookies.is_empty() {
685                let cookie_header = session.cookies.join("; ");
686                let _ = request.insert_header("Cookie", &cookie_header);
687            }
688        }
689
690        // Add referer header - use the current artist being edited
691        {
692            let session = self.session.lock().unwrap();
693            let _ = request.insert_header(
694                "Referer",
695                format!("{}/user/{}/library", session.base_url, session.username),
696            );
697        }
698
699        // Convert form data to URL-encoded string
700        let form_string: String = form_data
701            .iter()
702            .map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v)))
703            .collect::<Vec<_>>()
704            .join("&");
705
706        request.set_body(form_string);
707
708        let mut response = self
709            .client
710            .send(request)
711            .await
712            .map_err(|e| LastFmError::Http(e.to_string()))?;
713
714        log::debug!("Edit response status: {}", response.status());
715
716        let response_text = response
717            .body_string()
718            .await
719            .map_err(|e| LastFmError::Http(e.to_string()))?;
720
721        // Parse the HTML response to check for actual success/failure
722        let document = Html::parse_document(&response_text);
723
724        // Check for success indicator
725        let success_selector = Selector::parse(".alert-success").unwrap();
726        let error_selector = Selector::parse(".alert-danger, .alert-error, .error").unwrap();
727
728        let has_success_alert = document.select(&success_selector).next().is_some();
729        let has_error_alert = document.select(&error_selector).next().is_some();
730
731        // Also check if we can see the edited track in the response
732        // The response contains the track data in a table format within a script template
733        let mut actual_track_name = None;
734        let mut actual_album_name = None;
735
736        // Try direct selectors first
737        let track_name_selector = Selector::parse("td.chartlist-name a").unwrap();
738        let album_name_selector = Selector::parse("td.chartlist-album a").unwrap();
739
740        if let Some(track_element) = document.select(&track_name_selector).next() {
741            actual_track_name = Some(track_element.text().collect::<String>().trim().to_string());
742        }
743
744        if let Some(album_element) = document.select(&album_name_selector).next() {
745            actual_album_name = Some(album_element.text().collect::<String>().trim().to_string());
746        }
747
748        // If not found, try extracting from the raw response text using generic patterns
749        if actual_track_name.is_none() || actual_album_name.is_none() {
750            // Look for track name in href="/music/{artist}/_/{track}"
751            // Use regex to find track URLs
752            let track_pattern = regex::Regex::new(r#"href="/music/[^"]+/_/([^"]+)""#).unwrap();
753            if let Some(captures) = track_pattern.captures(&response_text) {
754                if let Some(track_match) = captures.get(1) {
755                    let raw_track = track_match.as_str();
756                    // URL decode the track name
757                    let decoded_track = urlencoding::decode(raw_track)
758                        .unwrap_or_else(|_| raw_track.into())
759                        .replace("+", " ");
760                    actual_track_name = Some(decoded_track);
761                }
762            }
763
764            // Look for album name in href="/music/{artist}/{album}"
765            // Find album links that are not track links (don't contain /_/)
766            let album_pattern =
767                regex::Regex::new(r#"href="/music/[^"]+/([^"/_]+)"[^>]*>[^<]*</a>"#).unwrap();
768            if let Some(captures) = album_pattern.captures(&response_text) {
769                if let Some(album_match) = captures.get(1) {
770                    let raw_album = album_match.as_str();
771                    // URL decode the album name
772                    let decoded_album = urlencoding::decode(raw_album)
773                        .unwrap_or_else(|_| raw_album.into())
774                        .replace("+", " ");
775                    actual_album_name = Some(decoded_album);
776                }
777            }
778        }
779
780        log::debug!(
781            "Response analysis: success_alert={}, error_alert={}, track='{}', album='{}'",
782            has_success_alert,
783            has_error_alert,
784            actual_track_name.as_deref().unwrap_or("not found"),
785            actual_album_name.as_deref().unwrap_or("not found")
786        );
787
788        // Determine if edit was truly successful
789        let final_success = response.status().is_success() && has_success_alert && !has_error_alert;
790
791        // Create detailed message
792        let _message = if has_error_alert {
793            // Extract error message
794            if let Some(error_element) = document.select(&error_selector).next() {
795                Some(format!(
796                    "Edit failed: {}",
797                    error_element.text().collect::<String>().trim()
798                ))
799            } else {
800                Some("Edit failed with unknown error".to_string())
801            }
802        } else if final_success {
803            Some(format!(
804                "Edit successful - Track: '{}', Album: '{}'",
805                actual_track_name.as_deref().unwrap_or("unknown"),
806                actual_album_name.as_deref().unwrap_or("unknown")
807            ))
808        } else {
809            Some(format!("Edit failed with status: {}", response.status()))
810        };
811
812        Ok(final_success)
813    }
814
815    /// Fetch raw HTML content for edit form page
816    /// This separates HTTP fetching from parsing to avoid Send/Sync issues
817    async fn get_edit_form_html(&self, edit_url: &str) -> Result<String> {
818        let mut form_response = self.get(edit_url).await?;
819        let form_html = form_response
820            .body_string()
821            .await
822            .map_err(|e| LastFmError::Http(e.to_string()))?;
823
824        log::debug!("Edit form response status: {}", form_response.status());
825        Ok(form_html)
826    }
827
828    /// Load prepopulated form values for editing a specific track
829    /// This extracts scrobble data directly from the track page forms
830    pub async fn load_edit_form_values_internal(
831        &self,
832        track_name: &str,
833        artist_name: &str,
834    ) -> Result<Vec<ExactScrobbleEdit>> {
835        log::debug!("Loading edit form values for '{track_name}' by '{artist_name}'");
836
837        // Get the specific track page to find scrobble forms
838        // Add +noredirect to avoid redirects as per lastfm-bulk-edit approach
839        // Use the correct URL format with underscore: artist/_/track
840        let base_track_url = {
841            let session = self.session.lock().unwrap();
842            format!(
843                "{}/user/{}/library/music/+noredirect/{}/_/{}",
844                session.base_url,
845                session.username,
846                urlencoding::encode(artist_name),
847                urlencoding::encode(track_name)
848            )
849        };
850
851        log::debug!("Fetching track page: {base_track_url}");
852
853        let mut response = self.get(&base_track_url).await?;
854        let html = response
855            .body_string()
856            .await
857            .map_err(|e| crate::LastFmError::Http(e.to_string()))?;
858
859        let document = Html::parse_document(&html);
860
861        // Handle pagination with a loop
862        let mut all_scrobble_edits = Vec::new();
863        let mut unique_albums = std::collections::HashSet::new();
864        let max_pages = 5;
865
866        // Start with the current page (page 1)
867        let page_edits = self.extract_scrobble_edits_from_page(
868            &document,
869            track_name,
870            artist_name,
871            &mut unique_albums,
872        )?;
873        all_scrobble_edits.extend(page_edits);
874
875        log::debug!(
876            "Page 1: found {} unique album variations",
877            all_scrobble_edits.len()
878        );
879
880        // Check for additional pages
881        let pagination_selector = Selector::parse(".pagination .pagination-next").unwrap();
882        let mut has_next_page = document.select(&pagination_selector).next().is_some();
883        let mut page = 2;
884
885        while has_next_page && page <= max_pages {
886            // For pagination, we need to remove +noredirect and add page parameter
887            let page_url = {
888                let session = self.session.lock().unwrap();
889                format!(
890                    "{}/user/{}/library/music/{}/_/{}?page={page}",
891                    session.base_url,
892                    session.username,
893                    urlencoding::encode(artist_name),
894                    urlencoding::encode(track_name)
895                )
896            };
897
898            log::debug!("Fetching page {page} for additional album variations");
899
900            let mut response = self.get(&page_url).await?;
901            let html = response
902                .body_string()
903                .await
904                .map_err(|e| crate::LastFmError::Http(e.to_string()))?;
905
906            let document = Html::parse_document(&html);
907
908            let page_edits = self.extract_scrobble_edits_from_page(
909                &document,
910                track_name,
911                artist_name,
912                &mut unique_albums,
913            )?;
914
915            let initial_count = all_scrobble_edits.len();
916            all_scrobble_edits.extend(page_edits);
917            let found_new_unique_albums = all_scrobble_edits.len() > initial_count;
918
919            // Check if there's another next page
920            has_next_page = document.select(&pagination_selector).next().is_some();
921
922            log::debug!(
923                "Page {page}: found {} total unique albums ({})",
924                all_scrobble_edits.len(),
925                if found_new_unique_albums {
926                    "new albums found"
927                } else {
928                    "no new unique albums"
929                }
930            );
931
932            // Continue to next page even if no new unique albums found on this page,
933            // as long as there are more pages available
934            page += 1;
935        }
936
937        if all_scrobble_edits.is_empty() {
938            return Err(crate::LastFmError::Parse(format!(
939                "No scrobble forms found for track '{track_name}' by '{artist_name}'"
940            )));
941        }
942
943        log::debug!(
944            "Final result: found {} unique album variations for '{track_name}' by '{artist_name}'",
945            all_scrobble_edits.len(),
946        );
947
948        Ok(all_scrobble_edits)
949    }
950
951    /// Extract scrobble edit data directly from track page forms
952    /// Based on the approach used in lastfm-bulk-edit
953    fn extract_scrobble_edits_from_page(
954        &self,
955        document: &Html,
956        expected_track: &str,
957        expected_artist: &str,
958        unique_albums: &mut std::collections::HashSet<(String, String)>,
959    ) -> Result<Vec<ExactScrobbleEdit>> {
960        let mut scrobble_edits = Vec::new();
961        // Look for the chartlist table that contains scrobbles
962        let table_selector =
963            Selector::parse("table.chartlist:not(.chartlist__placeholder)").unwrap();
964        let table = document.select(&table_selector).next().ok_or_else(|| {
965            crate::LastFmError::Parse("No chartlist table found on track page".to_string())
966        })?;
967
968        // Look for table rows that contain scrobble edit forms
969        let row_selector = Selector::parse("tr").unwrap();
970        for row in table.select(&row_selector) {
971            // Check if this row has a count bar link (means it's an aggregation, not individual scrobbles)
972            let count_bar_link_selector = Selector::parse(".chartlist-count-bar-link").unwrap();
973            if row.select(&count_bar_link_selector).next().is_some() {
974                log::debug!("Found count bar link, skipping aggregated row");
975                continue;
976            }
977
978            // Look for scrobble edit form in this row
979            let form_selector = Selector::parse("form[data-edit-scrobble]").unwrap();
980            if let Some(form) = row.select(&form_selector).next() {
981                // Extract all form values directly
982                let extract_form_value = |name: &str| -> Option<String> {
983                    let selector = Selector::parse(&format!("input[name='{name}']")).unwrap();
984                    form.select(&selector)
985                        .next()
986                        .and_then(|input| input.value().attr("value"))
987                        .map(|s| s.to_string())
988                };
989
990                // Get the track and artist from this form
991                let form_track = extract_form_value("track_name").unwrap_or_default();
992                let form_artist = extract_form_value("artist_name").unwrap_or_default();
993                let form_album = extract_form_value("album_name").unwrap_or_default();
994                let form_album_artist =
995                    extract_form_value("album_artist_name").unwrap_or_else(|| form_artist.clone());
996                let form_timestamp = extract_form_value("timestamp").unwrap_or_default();
997
998                log::debug!(
999                    "Found scrobble form - Track: '{form_track}', Artist: '{form_artist}', Album: '{form_album}', Timestamp: {form_timestamp}"
1000                );
1001
1002                // Check if this form matches the expected track and artist
1003                if form_track == expected_track && form_artist == expected_artist {
1004                    // Create a unique key for this album/album_artist combination
1005                    let album_key = (form_album.clone(), form_album_artist.clone());
1006                    if unique_albums.insert(album_key) {
1007                        // Parse timestamp - skip entries without valid timestamps for ExactScrobbleEdit
1008                        let timestamp = if form_timestamp.is_empty() {
1009                            None
1010                        } else {
1011                            form_timestamp.parse::<u64>().ok()
1012                        };
1013
1014                        if let Some(timestamp) = timestamp {
1015                            log::debug!(
1016                                "✅ Found unique album variation: '{form_album}' by '{form_album_artist}' for '{expected_track}' by '{expected_artist}'"
1017                            );
1018
1019                            // Create ExactScrobbleEdit with all fields specified
1020                            let scrobble_edit = ExactScrobbleEdit::new(
1021                                form_track.clone(),
1022                                form_album.clone(),
1023                                form_artist.clone(),
1024                                form_album_artist.clone(),
1025                                form_track,
1026                                form_album,
1027                                form_artist,
1028                                form_album_artist,
1029                                timestamp,
1030                                true,
1031                            );
1032                            scrobble_edits.push(scrobble_edit);
1033                        } else {
1034                            log::debug!(
1035                                "⚠️ Skipping album variation without valid timestamp: '{form_album}' by '{form_album_artist}'"
1036                            );
1037                        }
1038                    }
1039                }
1040            }
1041        }
1042
1043        Ok(scrobble_edits)
1044    }
1045
1046    /// Discover all scrobble edit variations based on the provided ScrobbleEdit template.
1047    ///
1048    /// This method analyzes what fields are specified in the input ScrobbleEdit and discovers
1049    /// all relevant scrobble instances that match the criteria:
1050    /// - If track_name_original is specified: discovers all album variations of that track
1051    /// - If only album_name_original is specified: discovers all tracks in that album
1052    /// - If neither is specified: discovers all tracks by that artist
1053    ///
1054    /// # Arguments
1055    /// * `edit` - A ScrobbleEdit template specifying what to discover
1056    ///
1057    /// # Returns
1058    /// A vector of ExactScrobbleEdit objects representing all discovered variations
1059    pub async fn discover_scrobble_edit_variations(
1060        &self,
1061        edit: &ScrobbleEdit,
1062    ) -> Result<Vec<ExactScrobbleEdit>> {
1063        // Use the incremental iterator and collect all results
1064        let mut discovery_iterator = self.discover_scrobbles(edit.clone());
1065        discovery_iterator.collect_all().await
1066    }
1067
1068    /// Get tracks from a specific album page
1069    /// This makes a single request to the album page and extracts track data
1070    pub async fn get_album_tracks(
1071        &self,
1072        album_name: &str,
1073        artist_name: &str,
1074    ) -> Result<Vec<Track>> {
1075        log::debug!("Getting tracks from album '{album_name}' by '{artist_name}'");
1076
1077        // Get the album page directly - this should contain track listings
1078        let album_url = {
1079            let session = self.session.lock().unwrap();
1080            format!(
1081                "{}/user/{}/library/music/{}/{}",
1082                session.base_url,
1083                session.username,
1084                urlencoding::encode(artist_name),
1085                urlencoding::encode(album_name)
1086            )
1087        };
1088
1089        log::debug!("Fetching album page: {album_url}");
1090
1091        let mut response = self.get(&album_url).await?;
1092        let html = response
1093            .body_string()
1094            .await
1095            .map_err(|e| LastFmError::Http(e.to_string()))?;
1096
1097        let document = Html::parse_document(&html);
1098
1099        // Use the shared track extraction function
1100        let tracks =
1101            self.parser
1102                .extract_tracks_from_document(&document, artist_name, Some(album_name))?;
1103
1104        log::debug!(
1105            "Successfully parsed {} tracks from album page",
1106            tracks.len()
1107        );
1108        Ok(tracks)
1109    }
1110
1111    /// Edit album metadata by updating scrobbles with new album name
1112    /// This edits ALL tracks from the album that are found in recent scrobbles
1113    pub async fn edit_album(
1114        &self,
1115        old_album_name: &str,
1116        new_album_name: &str,
1117        artist_name: &str,
1118    ) -> Result<EditResponse> {
1119        log::debug!("Editing album '{old_album_name}' -> '{new_album_name}' by '{artist_name}'");
1120
1121        let edit = ScrobbleEdit::for_album(old_album_name, artist_name, artist_name)
1122            .with_album_name(new_album_name);
1123
1124        self.edit_scrobble(&edit).await
1125    }
1126
1127    /// Edit artist metadata by updating scrobbles with new artist name
1128    /// This edits ALL tracks from the artist that are found in recent scrobbles
1129    pub async fn edit_artist(
1130        &self,
1131        old_artist_name: &str,
1132        new_artist_name: &str,
1133    ) -> Result<EditResponse> {
1134        log::debug!("Editing artist '{old_artist_name}' -> '{new_artist_name}'");
1135
1136        let edit = ScrobbleEdit::for_artist(old_artist_name, new_artist_name);
1137
1138        self.edit_scrobble(&edit).await
1139    }
1140
1141    /// Edit artist metadata for a specific track only
1142    /// This edits only the specified track if found in recent scrobbles
1143    pub async fn edit_artist_for_track(
1144        &self,
1145        track_name: &str,
1146        old_artist_name: &str,
1147        new_artist_name: &str,
1148    ) -> Result<EditResponse> {
1149        log::debug!("Editing artist for track '{track_name}' from '{old_artist_name}' -> '{new_artist_name}'");
1150
1151        let edit = ScrobbleEdit::from_track_and_artist(track_name, old_artist_name)
1152            .with_artist_name(new_artist_name);
1153
1154        self.edit_scrobble(&edit).await
1155    }
1156
1157    /// Edit artist metadata for all tracks in a specific album
1158    /// This edits ALL tracks from the specified album that are found in recent scrobbles
1159    pub async fn edit_artist_for_album(
1160        &self,
1161        album_name: &str,
1162        old_artist_name: &str,
1163        new_artist_name: &str,
1164    ) -> Result<EditResponse> {
1165        log::debug!("Editing artist for album '{album_name}' from '{old_artist_name}' -> '{new_artist_name}'");
1166
1167        let edit = ScrobbleEdit::for_album(album_name, old_artist_name, old_artist_name)
1168            .with_artist_name(new_artist_name);
1169
1170        self.edit_scrobble(&edit).await
1171    }
1172
1173    pub async fn get_artist_tracks_page(&self, artist: &str, page: u32) -> Result<TrackPage> {
1174        // Use AJAX endpoint for page content
1175        let url = {
1176            let session = self.session.lock().unwrap();
1177            format!(
1178                "{}/user/{}/library/music/{}/+tracks?page={}&ajax=true",
1179                session.base_url,
1180                session.username,
1181                artist.replace(" ", "+"),
1182                page
1183            )
1184        };
1185
1186        log::debug!("Fetching tracks page {page} for artist: {artist}");
1187        let mut response = self.get(&url).await?;
1188        let content = response
1189            .body_string()
1190            .await
1191            .map_err(|e| LastFmError::Http(e.to_string()))?;
1192
1193        log::debug!(
1194            "AJAX response: {} status, {} chars",
1195            response.status(),
1196            content.len()
1197        );
1198
1199        // Check if we got JSON or HTML
1200        if content.trim_start().starts_with("{") || content.trim_start().starts_with("[") {
1201            log::debug!("Parsing JSON response from AJAX endpoint");
1202            self.parse_json_tracks_page(&content, page, artist)
1203        } else {
1204            log::debug!("Parsing HTML response from AJAX endpoint");
1205            let document = Html::parse_document(&content);
1206            self.parser.parse_tracks_page(&document, page, artist, None)
1207        }
1208    }
1209
1210    /// Parse JSON tracks page (delegates to parser)
1211    fn parse_json_tracks_page(
1212        &self,
1213        _json_content: &str,
1214        page_number: u32,
1215        _artist: &str,
1216    ) -> Result<TrackPage> {
1217        // JSON parsing not yet implemented - fallback to empty page
1218        log::debug!("JSON parsing not implemented, returning empty page");
1219        Ok(TrackPage {
1220            tracks: Vec::new(),
1221            page_number,
1222            has_next_page: false,
1223            total_pages: Some(1),
1224        })
1225    }
1226
1227    /// Extract tracks from HTML document (delegates to parser)
1228    pub fn extract_tracks_from_document(
1229        &self,
1230        document: &Html,
1231        artist: &str,
1232        album: Option<&str>,
1233    ) -> Result<Vec<Track>> {
1234        self.parser
1235            .extract_tracks_from_document(document, artist, album)
1236    }
1237
1238    /// Parse tracks page (delegates to parser)
1239    pub fn parse_tracks_page(
1240        &self,
1241        document: &Html,
1242        page_number: u32,
1243        artist: &str,
1244        album: Option<&str>,
1245    ) -> Result<TrackPage> {
1246        self.parser
1247            .parse_tracks_page(document, page_number, artist, album)
1248    }
1249
1250    /// Parse recent scrobbles from HTML document (for testing)
1251    pub fn parse_recent_scrobbles(&self, document: &Html) -> Result<Vec<Track>> {
1252        self.parser.parse_recent_scrobbles(document)
1253    }
1254
1255    fn extract_csrf_token(&self, document: &Html) -> Result<String> {
1256        let csrf_selector = Selector::parse("input[name=\"csrfmiddlewaretoken\"]").unwrap();
1257
1258        document
1259            .select(&csrf_selector)
1260            .next()
1261            .and_then(|input| input.value().attr("value"))
1262            .map(|token| token.to_string())
1263            .ok_or(LastFmError::CsrfNotFound)
1264    }
1265
1266    /// Extract login form data (CSRF token and next field) - synchronous parsing helper
1267    fn extract_login_form_data(&self, html: &str) -> Result<(String, Option<String>)> {
1268        let document = Html::parse_document(html);
1269
1270        let csrf_token = self.extract_csrf_token(&document)?;
1271
1272        // Check if there's a 'next' field in the form
1273        let next_selector = Selector::parse("input[name=\"next\"]").unwrap();
1274        let next_field = document
1275            .select(&next_selector)
1276            .next()
1277            .and_then(|input| input.value().attr("value"))
1278            .map(|s| s.to_string());
1279
1280        Ok((csrf_token, next_field))
1281    }
1282
1283    /// Parse login error messages from HTML - synchronous parsing helper
1284    fn parse_login_error(&self, html: &str) -> String {
1285        let document = Html::parse_document(html);
1286
1287        let error_selector = Selector::parse(".alert-danger, .form-error, .error-message").unwrap();
1288
1289        let mut error_messages = Vec::new();
1290        for error in document.select(&error_selector) {
1291            let error_text = error.text().collect::<String>().trim().to_string();
1292            if !error_text.is_empty() {
1293                error_messages.push(error_text);
1294            }
1295        }
1296
1297        if error_messages.is_empty() {
1298            "Login failed - please check your credentials".to_string()
1299        } else {
1300            format!("Login failed: {}", error_messages.join("; "))
1301        }
1302    }
1303
1304    /// Check if HTML contains a login form - synchronous parsing helper
1305    fn check_for_login_form(&self, html: &str) -> bool {
1306        let document = Html::parse_document(html);
1307        let login_form_selector =
1308            Selector::parse("form[action*=\"login\"], input[name=\"username_or_email\"]").unwrap();
1309        document.select(&login_form_selector).next().is_some()
1310    }
1311
1312    /// Make an HTTP GET request with authentication and retry logic
1313    pub async fn get(&self, url: &str) -> Result<Response> {
1314        self.get_with_retry(url, 3).await
1315    }
1316
1317    /// Make an HTTP GET request with retry logic for rate limits
1318    async fn get_with_retry(&self, url: &str, max_retries: u32) -> Result<Response> {
1319        let mut retries = 0;
1320
1321        loop {
1322            match self.get_with_redirects(url, 0).await {
1323                Ok(mut response) => {
1324                    // Extract body and save debug info if enabled
1325                    let body = self.extract_response_body(url, &mut response).await?;
1326
1327                    // Check for rate limit patterns in successful responses
1328                    if response.status().is_success() && self.is_rate_limit_response(&body) {
1329                        log::debug!("Response body contains rate limit patterns");
1330                        if retries < max_retries {
1331                            let delay = 60 + (retries as u64 * 30); // Exponential backoff
1332                            log::info!("Rate limit detected in response body, retrying in {delay}s (attempt {}/{max_retries})", retries + 1);
1333                            tokio::time::sleep(std::time::Duration::from_secs(delay)).await;
1334                            retries += 1;
1335                            continue;
1336                        }
1337                        return Err(crate::LastFmError::RateLimit { retry_after: 60 });
1338                    }
1339
1340                    // Recreate response with the body we extracted
1341                    let mut new_response = http_types::Response::new(response.status());
1342                    for (name, values) in response.iter() {
1343                        for value in values {
1344                            let _ = new_response.insert_header(name.clone(), value.clone());
1345                        }
1346                    }
1347                    new_response.set_body(body);
1348
1349                    return Ok(new_response);
1350                }
1351                Err(crate::LastFmError::RateLimit { retry_after }) => {
1352                    if retries < max_retries {
1353                        let delay = retry_after + (retries as u64 * 30); // Exponential backoff
1354                        log::info!(
1355                            "Rate limit detected, retrying in {delay}s (attempt {}/{max_retries})",
1356                            retries + 1
1357                        );
1358                        tokio::time::sleep(std::time::Duration::from_secs(delay)).await;
1359                        retries += 1;
1360                    } else {
1361                        return Err(crate::LastFmError::RateLimit { retry_after });
1362                    }
1363                }
1364                Err(e) => return Err(e),
1365            }
1366        }
1367    }
1368
1369    async fn get_with_redirects(&self, url: &str, redirect_count: u32) -> Result<Response> {
1370        if redirect_count > 5 {
1371            return Err(LastFmError::Http("Too many redirects".to_string()));
1372        }
1373
1374        let mut request = Request::new(Method::Get, url.parse::<Url>().unwrap());
1375        let _ = request.insert_header("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36");
1376
1377        // Add session cookies for all authenticated requests
1378        {
1379            let session = self.session.lock().unwrap();
1380            if !session.cookies.is_empty() {
1381                let cookie_header = session.cookies.join("; ");
1382                let _ = request.insert_header("Cookie", &cookie_header);
1383            } else if url.contains("page=") {
1384                log::debug!("No cookies available for paginated request!");
1385            }
1386        }
1387
1388        // Add browser-like headers for all requests
1389        if url.contains("ajax=true") {
1390            // AJAX request headers
1391            let _ = request.insert_header("Accept", "*/*");
1392            let _ = request.insert_header("X-Requested-With", "XMLHttpRequest");
1393        } else {
1394            // Regular page request headers
1395            let _ = request.insert_header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7");
1396        }
1397        let _ = request.insert_header("Accept-Language", "en-US,en;q=0.9");
1398        let _ = request.insert_header("Accept-Encoding", "gzip, deflate, br");
1399        let _ = request.insert_header("DNT", "1");
1400        let _ = request.insert_header("Connection", "keep-alive");
1401        let _ = request.insert_header("Upgrade-Insecure-Requests", "1");
1402
1403        // Add referer for paginated requests
1404        if url.contains("page=") {
1405            let base_url = url.split('?').next().unwrap_or(url);
1406            let _ = request.insert_header("Referer", base_url);
1407        }
1408
1409        let response = self
1410            .client
1411            .send(request)
1412            .await
1413            .map_err(|e| LastFmError::Http(e.to_string()))?;
1414
1415        // Extract any new cookies from the response
1416        self.extract_cookies(&response);
1417
1418        // Handle redirects manually
1419        if response.status() == 302 || response.status() == 301 {
1420            if let Some(location) = response.header("location") {
1421                if let Some(redirect_url) = location.get(0) {
1422                    let redirect_url_str = redirect_url.as_str();
1423                    if url.contains("page=") {
1424                        log::debug!("Following redirect from {url} to {redirect_url_str}");
1425
1426                        // Check if this is a redirect to login - authentication issue
1427                        if redirect_url_str.contains("/login") {
1428                            log::debug!("Redirect to login page - authentication failed for paginated request");
1429                            return Err(LastFmError::Auth(
1430                                "Session expired or invalid for paginated request".to_string(),
1431                            ));
1432                        }
1433                    }
1434
1435                    // Handle relative URLs
1436                    let full_redirect_url = if redirect_url_str.starts_with('/') {
1437                        let base_url = self.session.lock().unwrap().base_url.clone();
1438                        format!("{base_url}{redirect_url_str}")
1439                    } else if redirect_url_str.starts_with("http") {
1440                        redirect_url_str.to_string()
1441                    } else {
1442                        // Relative to current path
1443                        let base_url = url
1444                            .rsplit('/')
1445                            .skip(1)
1446                            .collect::<Vec<_>>()
1447                            .into_iter()
1448                            .rev()
1449                            .collect::<Vec<_>>()
1450                            .join("/");
1451                        format!("{base_url}/{redirect_url_str}")
1452                    };
1453
1454                    // Make a new request to the redirect URL
1455                    return Box::pin(
1456                        self.get_with_redirects(&full_redirect_url, redirect_count + 1),
1457                    )
1458                    .await;
1459                }
1460            }
1461        }
1462
1463        // Handle explicit rate limit responses
1464        if response.status() == 429 {
1465            let retry_after = response
1466                .header("retry-after")
1467                .and_then(|h| h.get(0))
1468                .and_then(|v| v.as_str().parse::<u64>().ok())
1469                .unwrap_or(60);
1470            return Err(LastFmError::RateLimit { retry_after });
1471        }
1472
1473        // Check for 403 responses that might be rate limits
1474        if response.status() == 403 {
1475            log::debug!("Got 403 response, checking if it's a rate limit");
1476            // For now, treat 403s from authenticated endpoints as potential rate limits
1477            {
1478                let session = self.session.lock().unwrap();
1479                if !session.cookies.is_empty() {
1480                    log::debug!("403 on authenticated request - likely rate limit");
1481                    return Err(LastFmError::RateLimit { retry_after: 60 });
1482                }
1483            }
1484        }
1485
1486        Ok(response)
1487    }
1488
1489    /// Check if a response body indicates rate limiting
1490    fn is_rate_limit_response(&self, response_body: &str) -> bool {
1491        let body_lower = response_body.to_lowercase();
1492
1493        // Check against configured rate limit patterns
1494        for pattern in &self.rate_limit_patterns {
1495            if body_lower.contains(&pattern.to_lowercase()) {
1496                return true;
1497            }
1498        }
1499
1500        false
1501    }
1502
1503    fn extract_cookies(&self, response: &Response) {
1504        // Extract Set-Cookie headers and store them (avoiding duplicates)
1505        if let Some(cookie_headers) = response.header("set-cookie") {
1506            let mut new_cookies = 0;
1507            for cookie_header in cookie_headers {
1508                let cookie_str = cookie_header.as_str();
1509                // Extract just the cookie name=value part (before any semicolon)
1510                if let Some(cookie_value) = cookie_str.split(';').next() {
1511                    let cookie_name = cookie_value.split('=').next().unwrap_or("");
1512
1513                    // Remove any existing cookie with the same name
1514                    {
1515                        let mut session = self.session.lock().unwrap();
1516                        session
1517                            .cookies
1518                            .retain(|existing| !existing.starts_with(&format!("{cookie_name}=")));
1519                        session.cookies.push(cookie_value.to_string());
1520                    }
1521                    new_cookies += 1;
1522                }
1523            }
1524            if new_cookies > 0 {
1525                {
1526                    let session = self.session.lock().unwrap();
1527                    log::trace!(
1528                        "Extracted {} new cookies, total: {}",
1529                        new_cookies,
1530                        session.cookies.len()
1531                    );
1532                    log::trace!("Updated cookies: {:?}", &session.cookies);
1533
1534                    // Check if sessionid changed
1535                    for cookie in &session.cookies {
1536                        if cookie.starts_with("sessionid=") {
1537                            log::trace!("Current sessionid: {}", &cookie[10..50.min(cookie.len())]);
1538                            break;
1539                        }
1540                    }
1541                }
1542            }
1543        }
1544    }
1545
1546    /// Extract response body, optionally saving debug info
1547    async fn extract_response_body(&self, url: &str, response: &mut Response) -> Result<String> {
1548        let body = response
1549            .body_string()
1550            .await
1551            .map_err(|e| LastFmError::Http(e.to_string()))?;
1552
1553        if self.debug_save_responses {
1554            self.save_debug_response(url, response.status().into(), &body);
1555        }
1556
1557        Ok(body)
1558    }
1559
1560    /// Save response to debug directory (optional debug feature)
1561    fn save_debug_response(&self, url: &str, status_code: u16, body: &str) {
1562        if let Err(e) = self.try_save_debug_response(url, status_code, body) {
1563            log::warn!("Failed to save debug response: {e}");
1564        }
1565    }
1566
1567    /// Internal debug response saving implementation
1568    fn try_save_debug_response(&self, url: &str, status_code: u16, body: &str) -> Result<()> {
1569        // Create debug directory if it doesn't exist
1570        let debug_dir = Path::new("debug_responses");
1571        if !debug_dir.exists() {
1572            fs::create_dir_all(debug_dir)
1573                .map_err(|e| LastFmError::Http(format!("Failed to create debug directory: {e}")))?;
1574        }
1575
1576        // Extract the path part of the URL (after base_url)
1577        let url_path = {
1578            let session = self.session.lock().unwrap();
1579            if url.starts_with(&session.base_url) {
1580                &url[session.base_url.len()..]
1581            } else {
1582                url
1583            }
1584        };
1585
1586        // Create safe filename from URL path and add timestamp
1587        let now = chrono::Utc::now();
1588        let timestamp = now.format("%Y%m%d_%H%M%S_%3f");
1589        let safe_path = url_path.replace(['/', '?', '&', '=', '%', '+'], "_");
1590
1591        let filename = format!("{timestamp}_{safe_path}_status{status_code}.html");
1592        let file_path = debug_dir.join(filename);
1593
1594        // Write response to file
1595        fs::write(&file_path, body)
1596            .map_err(|e| LastFmError::Http(format!("Failed to write debug file: {e}")))?;
1597
1598        log::debug!(
1599            "Saved HTTP response to {file_path:?} (status: {status_code}, url: {url_path})"
1600        );
1601
1602        Ok(())
1603    }
1604
1605    pub async fn get_artist_albums_page(&self, artist: &str, page: u32) -> Result<AlbumPage> {
1606        // Use AJAX endpoint for page content
1607        let url = {
1608            let session = self.session.lock().unwrap();
1609            format!(
1610                "{}/user/{}/library/music/{}/+albums?page={}&ajax=true",
1611                session.base_url,
1612                session.username,
1613                artist.replace(" ", "+"),
1614                page
1615            )
1616        };
1617
1618        log::debug!("Fetching albums page {page} for artist: {artist}");
1619        let mut response = self.get(&url).await?;
1620        let content = response
1621            .body_string()
1622            .await
1623            .map_err(|e| LastFmError::Http(e.to_string()))?;
1624
1625        log::debug!(
1626            "AJAX response: {} status, {} chars",
1627            response.status(),
1628            content.len()
1629        );
1630
1631        // Check if we got JSON or HTML
1632        if content.trim_start().starts_with("{") || content.trim_start().starts_with("[") {
1633            log::debug!("Parsing JSON response from AJAX endpoint");
1634            self.parse_json_albums_page(&content, page, artist)
1635        } else {
1636            log::debug!("Parsing HTML response from AJAX endpoint");
1637            let document = Html::parse_document(&content);
1638            self.parser.parse_albums_page(&document, page, artist)
1639        }
1640    }
1641
1642    fn parse_json_albums_page(
1643        &self,
1644        _json_content: &str,
1645        page_number: u32,
1646        _artist: &str,
1647    ) -> Result<AlbumPage> {
1648        // JSON parsing not yet implemented - fallback to empty page
1649        log::debug!("JSON parsing not implemented, returning empty page");
1650        Ok(AlbumPage {
1651            albums: Vec::new(),
1652            page_number,
1653            has_next_page: false,
1654            total_pages: Some(1),
1655        })
1656    }
1657}
1658
1659#[async_trait(?Send)]
1660impl LastFmEditClient for LastFmEditClientImpl {
1661    async fn login(&self, username: &str, password: &str) -> Result<()> {
1662        self.login(username, password).await
1663    }
1664
1665    fn username(&self) -> String {
1666        self.username()
1667    }
1668
1669    fn is_logged_in(&self) -> bool {
1670        self.is_logged_in()
1671    }
1672
1673    async fn get_recent_scrobbles(&self, page: u32) -> Result<Vec<Track>> {
1674        self.get_recent_scrobbles(page).await
1675    }
1676
1677    async fn find_recent_scrobble_for_track(
1678        &self,
1679        track_name: &str,
1680        artist_name: &str,
1681        max_pages: u32,
1682    ) -> Result<Option<Track>> {
1683        self.find_recent_scrobble_for_track(track_name, artist_name, max_pages)
1684            .await
1685    }
1686
1687    async fn edit_scrobble(&self, edit: &ScrobbleEdit) -> Result<EditResponse> {
1688        self.edit_scrobble(edit).await
1689    }
1690
1691    async fn edit_scrobble_single(
1692        &self,
1693        exact_edit: &ExactScrobbleEdit,
1694        max_retries: u32,
1695    ) -> Result<EditResponse> {
1696        self.edit_scrobble_single(exact_edit, max_retries).await
1697    }
1698
1699    async fn discover_scrobble_edit_variations(
1700        &self,
1701        edit: &ScrobbleEdit,
1702    ) -> Result<Vec<ExactScrobbleEdit>> {
1703        self.discover_scrobble_edit_variations(edit).await
1704    }
1705
1706    async fn get_album_tracks(&self, album_name: &str, artist_name: &str) -> Result<Vec<Track>> {
1707        self.get_album_tracks(album_name, artist_name).await
1708    }
1709
1710    async fn edit_album(
1711        &self,
1712        old_album_name: &str,
1713        new_album_name: &str,
1714        artist_name: &str,
1715    ) -> Result<EditResponse> {
1716        self.edit_album(old_album_name, new_album_name, artist_name)
1717            .await
1718    }
1719
1720    async fn edit_artist(
1721        &self,
1722        old_artist_name: &str,
1723        new_artist_name: &str,
1724    ) -> Result<EditResponse> {
1725        self.edit_artist(old_artist_name, new_artist_name).await
1726    }
1727
1728    async fn edit_artist_for_track(
1729        &self,
1730        track_name: &str,
1731        old_artist_name: &str,
1732        new_artist_name: &str,
1733    ) -> Result<EditResponse> {
1734        self.edit_artist_for_track(track_name, old_artist_name, new_artist_name)
1735            .await
1736    }
1737
1738    async fn edit_artist_for_album(
1739        &self,
1740        album_name: &str,
1741        old_artist_name: &str,
1742        new_artist_name: &str,
1743    ) -> Result<EditResponse> {
1744        self.edit_artist_for_album(album_name, old_artist_name, new_artist_name)
1745            .await
1746    }
1747
1748    async fn get_artist_tracks_page(&self, artist: &str, page: u32) -> Result<TrackPage> {
1749        self.get_artist_tracks_page(artist, page).await
1750    }
1751
1752    async fn get_artist_albums_page(&self, artist: &str, page: u32) -> Result<AlbumPage> {
1753        self.get_artist_albums_page(artist, page).await
1754    }
1755
1756    async fn get_recent_tracks_page(&self, page: u32) -> Result<TrackPage> {
1757        self.get_recent_tracks_page(page).await
1758    }
1759
1760    fn get_session(&self) -> LastFmEditSession {
1761        self.get_session()
1762    }
1763
1764    fn restore_session(&self, session: LastFmEditSession) {
1765        self.restore_session(session)
1766    }
1767
1768    fn artist_tracks(&self, artist: &str) -> crate::ArtistTracksIterator {
1769        crate::ArtistTracksIterator::new(self.clone(), artist.to_string())
1770    }
1771
1772    fn artist_albums(&self, artist: &str) -> crate::ArtistAlbumsIterator {
1773        crate::ArtistAlbumsIterator::new(self.clone(), artist.to_string())
1774    }
1775
1776    fn album_tracks(&self, album_name: &str, artist_name: &str) -> crate::AlbumTracksIterator {
1777        crate::AlbumTracksIterator::new(
1778            self.clone(),
1779            album_name.to_string(),
1780            artist_name.to_string(),
1781        )
1782    }
1783
1784    fn recent_tracks(&self) -> crate::RecentTracksIterator {
1785        crate::RecentTracksIterator::new(self.clone())
1786    }
1787
1788    fn recent_tracks_from_page(&self, starting_page: u32) -> crate::RecentTracksIterator {
1789        crate::RecentTracksIterator::with_starting_page(self.clone(), starting_page)
1790    }
1791}