lastfm_edit/
client.rs

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