lastfm_edit/
client.rs

1use crate::{
2    Album, AlbumPage, ArtistAlbumsIterator, ArtistTracksIterator, AsyncPaginatedIterator,
3    EditResponse, LastFmError, RecentTracksIterator, Result, ScrobbleEdit, Track, TrackPage,
4};
5use http_client::{HttpClient, Request, Response};
6use http_types::{Method, Url};
7use scraper::{Html, Selector};
8use std::collections::HashMap;
9use std::fs;
10use std::path::Path;
11
12/// Main client for interacting with Last.fm's web interface.
13///
14/// This client handles authentication, session management, and provides methods for
15/// browsing user libraries and editing scrobble data through web scraping.
16///
17/// # Examples
18///
19/// ```rust,no_run
20/// use lastfm_edit::{LastFmClient, Result};
21///
22/// #[tokio::main]
23/// async fn main() -> Result<()> {
24///     // Create client with any HTTP implementation
25///     let http_client = http_client::native::NativeClient::new();
26///     let mut client = LastFmClient::new(Box::new(http_client));
27///
28///     // Login to Last.fm
29///     client.login("username", "password").await?;
30///
31///     // Check if authenticated
32///     assert!(client.is_logged_in());
33///
34///     Ok(())
35/// }
36/// ```
37pub struct LastFmClient {
38    client: Box<dyn HttpClient>,
39    username: String,
40    csrf_token: Option<String>,
41    base_url: String,
42    session_cookies: Vec<String>,
43    rate_limit_patterns: Vec<String>,
44    debug_save_responses: bool,
45}
46
47impl LastFmClient {
48    /// Create a new [`LastFmClient`] with the default Last.fm URL.
49    ///
50    /// # Arguments
51    ///
52    /// * `client` - Any HTTP client implementation that implements [`HttpClient`]
53    ///
54    /// # Examples
55    ///
56    /// ```rust,no_run
57    /// use lastfm_edit::LastFmClient;
58    ///
59    /// let http_client = http_client::native::NativeClient::new();
60    /// let client = LastFmClient::new(Box::new(http_client));
61    /// ```
62    pub fn new(client: Box<dyn HttpClient>) -> Self {
63        Self::with_base_url(client, "https://www.last.fm".to_string())
64    }
65
66    /// Create a new [`LastFmClient`] with a custom base URL.
67    ///
68    /// This is useful for testing or if Last.fm changes their domain.
69    ///
70    /// # Arguments
71    ///
72    /// * `client` - Any HTTP client implementation
73    /// * `base_url` - The base URL for Last.fm (e.g., "https://www.last.fm")
74    pub fn with_base_url(client: Box<dyn HttpClient>, base_url: String) -> Self {
75        Self::with_rate_limit_patterns(
76            client,
77            base_url,
78            vec![
79                "you've tried to log in too many times".to_string(),
80                "you're requesting too many pages".to_string(),
81                "slow down".to_string(),
82                "too fast".to_string(),
83                "rate limit".to_string(),
84                "throttled".to_string(),
85                "temporarily blocked".to_string(),
86                "temporarily restricted".to_string(),
87                "captcha".to_string(),
88                "verify you're human".to_string(),
89                "prove you're not a robot".to_string(),
90                "security check".to_string(),
91                "service temporarily unavailable".to_string(),
92                "quota exceeded".to_string(),
93                "limit exceeded".to_string(),
94                "daily limit".to_string(),
95            ],
96        )
97    }
98
99    /// Create a new [`LastFmClient`] with custom rate limit detection patterns.
100    ///
101    /// # Arguments
102    ///
103    /// * `client` - Any HTTP client implementation
104    /// * `base_url` - The base URL for Last.fm
105    /// * `rate_limit_patterns` - Text patterns that indicate rate limiting in responses
106    pub fn with_rate_limit_patterns(
107        client: Box<dyn HttpClient>,
108        base_url: String,
109        rate_limit_patterns: Vec<String>,
110    ) -> Self {
111        Self {
112            client,
113            username: String::new(),
114            csrf_token: None,
115            base_url,
116            session_cookies: Vec::new(),
117            rate_limit_patterns,
118            debug_save_responses: std::env::var("LASTFM_DEBUG_SAVE_RESPONSES").is_ok(),
119        }
120    }
121
122    /// Authenticate with Last.fm using username and password.
123    ///
124    /// This method:
125    /// 1. Fetches the login page to extract CSRF tokens
126    /// 2. Submits the login form with credentials
127    /// 3. Validates the authentication by checking for session cookies
128    /// 4. Stores session data for subsequent requests
129    ///
130    /// # Arguments
131    ///
132    /// * `username` - Last.fm username or email
133    /// * `password` - Last.fm password
134    ///
135    /// # Returns
136    ///
137    /// Returns [`Ok(())`] on successful authentication, or [`LastFmError::Auth`] on failure.
138    ///
139    /// # Examples
140    ///
141    /// ```rust,no_run
142    /// # use lastfm_edit::{LastFmClient, Result};
143    /// # tokio_test::block_on(async {
144    /// let mut client = LastFmClient::new(Box::new(http_client::native::NativeClient::new()));
145    /// client.login("username", "password").await?;
146    /// assert!(client.is_logged_in());
147    /// # Ok::<(), lastfm_edit::LastFmError>(())
148    /// # });
149    /// ```
150    pub async fn login(&mut self, username: &str, password: &str) -> Result<()> {
151        // Get login page to extract CSRF token
152        let login_url = format!("{}/login", self.base_url);
153        let mut response = self.get(&login_url).await?;
154
155        // Extract any initial cookies from the login page
156        self.extract_cookies(&response);
157
158        let html = response
159            .body_string()
160            .await
161            .map_err(|e| LastFmError::Http(e.to_string()))?;
162        let document = Html::parse_document(&html);
163
164        let csrf_token = self.extract_csrf_token(&document)?;
165
166        // Submit login form
167        let mut form_data = HashMap::new();
168        form_data.insert("csrfmiddlewaretoken", csrf_token.as_str());
169        form_data.insert("username_or_email", username);
170        form_data.insert("password", password);
171
172        // Check if there's a 'next' field in the form
173        let next_selector = Selector::parse("input[name=\"next\"]").unwrap();
174        if let Some(next_input) = document.select(&next_selector).next() {
175            if let Some(next_value) = next_input.value().attr("value") {
176                form_data.insert("next", next_value);
177            }
178        }
179
180        let mut request = Request::new(Method::Post, login_url.parse::<Url>().unwrap());
181        request.insert_header("Referer", &login_url);
182        request.insert_header("Origin", &self.base_url);
183        request.insert_header("Content-Type", "application/x-www-form-urlencoded");
184        request.insert_header(
185            "User-Agent",
186            "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36"
187        );
188        request.insert_header(
189            "Accept",
190            "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"
191        );
192        request.insert_header("Accept-Language", "en-US,en;q=0.9");
193        request.insert_header("Accept-Encoding", "gzip, deflate, br");
194        request.insert_header("DNT", "1");
195        request.insert_header("Connection", "keep-alive");
196        request.insert_header("Upgrade-Insecure-Requests", "1");
197        request.insert_header(
198            "sec-ch-ua",
199            "\"Not)A;Brand\";v=\"8\", \"Chromium\";v=\"138\", \"Google Chrome\";v=\"138\"",
200        );
201        request.insert_header("sec-ch-ua-mobile", "?0");
202        request.insert_header("sec-ch-ua-platform", "\"Linux\"");
203        request.insert_header("Sec-Fetch-Dest", "document");
204        request.insert_header("Sec-Fetch-Mode", "navigate");
205        request.insert_header("Sec-Fetch-Site", "same-origin");
206        request.insert_header("Sec-Fetch-User", "?1");
207
208        // Add any cookies we already have
209        if !self.session_cookies.is_empty() {
210            let cookie_header = self.session_cookies.join("; ");
211            request.insert_header("Cookie", &cookie_header);
212        }
213
214        // Convert form data to URL-encoded string
215        let form_string: String = form_data
216            .iter()
217            .map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v)))
218            .collect::<Vec<_>>()
219            .join("&");
220
221        request.set_body(form_string);
222
223        let mut response = self
224            .client
225            .send(request)
226            .await
227            .map_err(|e| LastFmError::Http(e.to_string()))?;
228
229        // Extract session cookies from login response
230        self.extract_cookies(&response);
231
232        log::debug!("Login response status: {}", response.status());
233
234        // If we get a 403, it might be rate limiting or auth failure
235        if response.status() == 403 {
236            // Get the response body to check if it's rate limiting
237            let response_html = response
238                .body_string()
239                .await
240                .map_err(|e| LastFmError::Http(e.to_string()))?;
241
242            // Look for rate limit indicators in the response
243            if self.is_rate_limit_response(&response_html) {
244                log::debug!("403 response appears to be rate limiting");
245                return Err(LastFmError::RateLimit { retry_after: 60 });
246            } else {
247                log::debug!("403 response appears to be authentication failure");
248
249                // Continue with the normal auth failure handling using the response_html
250                let success_doc = Html::parse_document(&response_html);
251                let login_form_selector =
252                    Selector::parse("form[action*=\"login\"], input[name=\"username_or_email\"]")
253                        .unwrap();
254                let has_login_form = success_doc.select(&login_form_selector).next().is_some();
255
256                if !has_login_form {
257                    return Err(LastFmError::Auth(
258                        "Login failed - 403 Forbidden. Check credentials.".to_string(),
259                    ));
260                } else {
261                    // Parse for specific error messages
262                    let error_selector =
263                        Selector::parse(".alert-danger, .form-error, .error-message").unwrap();
264                    let mut error_messages = Vec::new();
265                    for error in success_doc.select(&error_selector) {
266                        let error_text = error.text().collect::<String>().trim().to_string();
267                        if !error_text.is_empty() {
268                            error_messages.push(error_text);
269                        }
270                    }
271                    let error_msg = if error_messages.is_empty() {
272                        "Login failed - 403 Forbidden. Check credentials.".to_string()
273                    } else {
274                        format!("Login failed: {}", error_messages.join("; "))
275                    };
276                    return Err(LastFmError::Auth(error_msg));
277                }
278            }
279        }
280
281        // Check if we got a new sessionid that looks like a real Last.fm session
282        let has_real_session = self
283            .session_cookies
284            .iter()
285            .any(|cookie| cookie.starts_with("sessionid=.") && cookie.len() > 50);
286
287        if has_real_session && (response.status() == 302 || response.status() == 200) {
288            // We got a real session ID, login was successful
289            self.username = username.to_string();
290            self.csrf_token = Some(csrf_token);
291            log::debug!("Login successful - authenticated session established");
292            return Ok(());
293        }
294
295        // At this point, we didn't get a 403, so read the response body for other cases
296        let response_html = response
297            .body_string()
298            .await
299            .map_err(|e| LastFmError::Http(e.to_string()))?;
300
301        // Check if we were redirected away from login page (success) by looking for login forms in response
302        let success_doc = Html::parse_document(&response_html);
303        let login_form_selector =
304            Selector::parse("form[action*=\"login\"], input[name=\"username_or_email\"]").unwrap();
305        let has_login_form = success_doc.select(&login_form_selector).next().is_some();
306
307        if !has_login_form && response.status() == 200 {
308            self.username = username.to_string();
309            self.csrf_token = Some(csrf_token);
310            Ok(())
311        } else {
312            // Parse the login page for specific error messages
313            let error_doc = success_doc;
314            let error_selector =
315                Selector::parse(".alert-danger, .form-error, .error-message").unwrap();
316
317            let mut error_messages = Vec::new();
318            for error in error_doc.select(&error_selector) {
319                let error_text = error.text().collect::<String>().trim().to_string();
320                if !error_text.is_empty() {
321                    error_messages.push(error_text);
322                }
323            }
324
325            let error_msg = if error_messages.is_empty() {
326                "Login failed - please check your credentials".to_string()
327            } else {
328                format!("Login failed: {}", error_messages.join("; "))
329            };
330
331            Err(LastFmError::Auth(error_msg))
332        }
333    }
334
335    /// Get the currently authenticated username.
336    ///
337    /// Returns an empty string if not logged in.
338    pub fn username(&self) -> &str {
339        &self.username
340    }
341
342    /// Check if the client is currently authenticated.
343    ///
344    /// Returns `true` if [`login`](Self::login) was successful and session is active.
345    pub fn is_logged_in(&self) -> bool {
346        !self.username.is_empty() && self.csrf_token.is_some()
347    }
348
349    /// Create an iterator for browsing an artist's tracks from the user's library.
350    ///
351    /// # Arguments
352    ///
353    /// * `artist` - The artist name to browse
354    ///
355    /// # Returns
356    ///
357    /// Returns an [`ArtistTracksIterator`] that implements [`AsyncPaginatedIterator`].
358    ///
359    /// # Examples
360    ///
361    /// ```rust,no_run
362    /// # use lastfm_edit::{LastFmClient, AsyncPaginatedIterator};
363    /// # tokio_test::block_on(async {
364    /// let mut client = LastFmClient::new(Box::new(http_client::native::NativeClient::new()));
365    /// // client.login(...).await?;
366    ///
367    /// let mut tracks = client.artist_tracks("Radiohead");
368    /// while let Some(track) = tracks.next().await? {
369    ///     println!("{} - {}", track.artist, track.name);
370    /// }
371    /// # Ok::<(), Box<dyn std::error::Error>>(())
372    /// # });
373    /// ```
374    pub fn artist_tracks<'a>(&'a mut self, artist: &str) -> ArtistTracksIterator<'a> {
375        ArtistTracksIterator::new(self, artist.to_string())
376    }
377
378    /// Create an iterator for browsing an artist's albums from the user's library.
379    ///
380    /// # Arguments
381    ///
382    /// * `artist` - The artist name to browse
383    ///
384    /// # Returns
385    ///
386    /// Returns an [`ArtistAlbumsIterator`] that implements [`AsyncPaginatedIterator`].
387    pub fn artist_albums<'a>(&'a mut self, artist: &str) -> ArtistAlbumsIterator<'a> {
388        ArtistAlbumsIterator::new(self, artist.to_string())
389    }
390
391    /// Create an iterator for browsing the user's recent tracks.
392    ///
393    /// This provides access to the user's recent listening history with timestamps,
394    /// which is useful for finding tracks to edit.
395    ///
396    /// # Returns
397    ///
398    /// Returns a [`RecentTracksIterator`] that implements [`AsyncPaginatedIterator`].
399    ///
400    /// # Examples
401    ///
402    /// ```rust,no_run
403    /// # use lastfm_edit::{LastFmClient, AsyncPaginatedIterator};
404    /// # tokio_test::block_on(async {
405    /// let mut client = LastFmClient::new(Box::new(http_client::native::NativeClient::new()));
406    /// // client.login(...).await?;
407    ///
408    /// let mut recent = client.recent_tracks();
409    /// while let Some(track) = recent.next().await? {
410    ///     if let Some(timestamp) = track.timestamp {
411    ///         println!("{} - {} (played at {})", track.artist, track.name, timestamp);
412    ///     }
413    /// }
414    /// # Ok::<(), Box<dyn std::error::Error>>(())
415    /// # });
416    /// ```
417    pub fn recent_tracks<'a>(&'a mut self) -> RecentTracksIterator<'a> {
418        RecentTracksIterator::new(self)
419    }
420
421    /// Fetch recent scrobbles from the user's listening history
422    /// This gives us real scrobble data with timestamps for editing
423    pub async fn get_recent_scrobbles(&mut self, page: u32) -> Result<Vec<Track>> {
424        let url = format!(
425            "{}/user/{}/library?page={}",
426            self.base_url, self.username, page
427        );
428
429        log::debug!("Fetching recent scrobbles page {page}");
430        let mut response = self.get(&url).await?;
431        let content = response
432            .body_string()
433            .await
434            .map_err(|e| LastFmError::Http(e.to_string()))?;
435
436        log::debug!(
437            "Recent scrobbles response: {} status, {} chars",
438            response.status(),
439            content.len()
440        );
441
442        let document = Html::parse_document(&content);
443        self.parse_recent_scrobbles(&document)
444    }
445
446    /// Find the most recent scrobble for a specific track
447    /// This searches through recent listening history to find real scrobble data
448    pub async fn find_recent_scrobble_for_track(
449        &mut self,
450        track_name: &str,
451        artist_name: &str,
452        max_pages: u32,
453    ) -> Result<Option<Track>> {
454        log::debug!("Searching for recent scrobble: '{track_name}' by '{artist_name}'");
455
456        for page in 1..=max_pages {
457            let scrobbles = self.get_recent_scrobbles(page).await?;
458
459            for scrobble in scrobbles {
460                if scrobble.name == track_name && scrobble.artist == artist_name {
461                    log::debug!(
462                        "Found recent scrobble: '{}' with timestamp {:?}",
463                        scrobble.name,
464                        scrobble.timestamp
465                    );
466                    return Ok(Some(scrobble));
467                }
468            }
469
470            // Small delay between pages to be polite
471            tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
472        }
473
474        log::debug!(
475            "No recent scrobble found for '{track_name}' by '{artist_name}' in {max_pages} pages"
476        );
477        Ok(None)
478    }
479
480    pub async fn edit_scrobble(&mut self, edit: &ScrobbleEdit) -> Result<EditResponse> {
481        self.edit_scrobble_with_retry(edit, 3).await
482    }
483
484    pub async fn edit_scrobble_with_retry(
485        &mut self,
486        edit: &ScrobbleEdit,
487        max_retries: u32,
488    ) -> Result<EditResponse> {
489        let mut retries = 0;
490
491        loop {
492            match self.edit_scrobble_impl(edit).await {
493                Ok(result) => return Ok(result),
494                Err(LastFmError::RateLimit { retry_after }) => {
495                    if retries >= max_retries {
496                        log::warn!("Max retries ({max_retries}) exceeded for edit operation");
497                        return Err(LastFmError::RateLimit { retry_after });
498                    }
499
500                    let delay = std::cmp::min(retry_after, 2_u64.pow(retries + 1) * 5);
501                    log::info!(
502                        "Edit rate limited. Waiting {} seconds before retry {} of {}",
503                        delay,
504                        retries + 1,
505                        max_retries
506                    );
507                    tokio::time::sleep(tokio::time::Duration::from_secs(delay)).await;
508                    retries += 1;
509                }
510                Err(other_error) => return Err(other_error),
511            }
512        }
513    }
514
515    async fn edit_scrobble_impl(&mut self, edit: &ScrobbleEdit) -> Result<EditResponse> {
516        if !self.is_logged_in() {
517            return Err(LastFmError::Auth(
518                "Must be logged in to edit scrobbles".to_string(),
519            ));
520        }
521
522        let edit_url = format!(
523            "{}/user/{}/library/edit?edited-variation=library-track-scrobble",
524            self.base_url, self.username
525        );
526
527        log::debug!("Getting fresh CSRF token for edit");
528
529        // First request: Get the edit form to extract fresh CSRF token
530        let mut form_response = self.get(&edit_url).await?;
531        let form_html = form_response
532            .body_string()
533            .await
534            .map_err(|e| LastFmError::Http(e.to_string()))?;
535
536        log::debug!("Edit form response status: {}", form_response.status());
537
538        // Parse HTML to get fresh CSRF token
539        let form_document = Html::parse_document(&form_html);
540        let fresh_csrf_token = self.extract_csrf_token(&form_document)?;
541
542        log::debug!("Submitting edit with fresh token");
543
544        let mut form_data = HashMap::new();
545
546        // Add fresh CSRF token (required)
547        form_data.insert("csrfmiddlewaretoken", fresh_csrf_token.as_str());
548
549        // Include ALL form fields as they were extracted from the track page
550        form_data.insert("track_name_original", &edit.track_name_original);
551        form_data.insert("track_name", &edit.track_name);
552        form_data.insert("artist_name_original", &edit.artist_name_original);
553        form_data.insert("artist_name", &edit.artist_name);
554        form_data.insert("album_name_original", &edit.album_name_original);
555        form_data.insert("album_name", &edit.album_name);
556        form_data.insert(
557            "album_artist_name_original",
558            &edit.album_artist_name_original,
559        );
560        form_data.insert("album_artist_name", &edit.album_artist_name);
561
562        // ALWAYS include timestamp - Last.fm requires it even with edit_all=true
563        let timestamp_str = edit.timestamp.to_string();
564        form_data.insert("timestamp", &timestamp_str);
565
566        // Edit flags
567        if edit.edit_all {
568            form_data.insert("edit_all", "1");
569        }
570        form_data.insert("submit", "edit-scrobble");
571        form_data.insert("ajax", "1");
572
573        log::debug!(
574            "Editing scrobble: '{}' -> '{}'",
575            edit.track_name_original,
576            edit.track_name
577        );
578        log::trace!("Session cookies count: {}", self.session_cookies.len());
579
580        let mut request = Request::new(Method::Post, edit_url.parse::<Url>().unwrap());
581
582        // Add comprehensive headers matching your browser request
583        request.insert_header("Accept", "*/*");
584        request.insert_header("Accept-Language", "en-US,en;q=0.9");
585        request.insert_header(
586            "Content-Type",
587            "application/x-www-form-urlencoded;charset=UTF-8",
588        );
589        request.insert_header("Priority", "u=1, i");
590        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");
591        request.insert_header("X-Requested-With", "XMLHttpRequest");
592        request.insert_header("Sec-Fetch-Dest", "empty");
593        request.insert_header("Sec-Fetch-Mode", "cors");
594        request.insert_header("Sec-Fetch-Site", "same-origin");
595        request.insert_header(
596            "sec-ch-ua",
597            "\"Not)A;Brand\";v=\"8\", \"Chromium\";v=\"138\", \"Google Chrome\";v=\"138\"",
598        );
599        request.insert_header("sec-ch-ua-mobile", "?0");
600        request.insert_header("sec-ch-ua-platform", "\"Linux\"");
601
602        // Add session cookies
603        if !self.session_cookies.is_empty() {
604            let cookie_header = self.session_cookies.join("; ");
605            request.insert_header("Cookie", &cookie_header);
606        }
607
608        // Add referer header - use the current artist being edited
609        request.insert_header(
610            "Referer",
611            format!("{}/user/{}/library", self.base_url, self.username),
612        );
613
614        // Convert form data to URL-encoded string
615        let form_string: String = form_data
616            .iter()
617            .map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v)))
618            .collect::<Vec<_>>()
619            .join("&");
620
621        request.set_body(form_string);
622
623        let mut response = self
624            .client
625            .send(request)
626            .await
627            .map_err(|e| LastFmError::Http(e.to_string()))?;
628
629        log::debug!("Edit response status: {}", response.status());
630
631        let response_text = response
632            .body_string()
633            .await
634            .map_err(|e| LastFmError::Http(e.to_string()))?;
635
636        // Parse the HTML response to check for actual success/failure
637        let document = Html::parse_document(&response_text);
638
639        // Check for success indicator
640        let success_selector = Selector::parse(".alert-success").unwrap();
641        let error_selector = Selector::parse(".alert-danger, .alert-error, .error").unwrap();
642
643        let has_success_alert = document.select(&success_selector).next().is_some();
644        let has_error_alert = document.select(&error_selector).next().is_some();
645
646        // Also check if we can see the edited track in the response
647        // The response contains the track data in a table format within a script template
648        let mut actual_track_name = None;
649        let mut actual_album_name = None;
650
651        // Try direct selectors first
652        let track_name_selector = Selector::parse("td.chartlist-name a").unwrap();
653        let album_name_selector = Selector::parse("td.chartlist-album a").unwrap();
654
655        if let Some(track_element) = document.select(&track_name_selector).next() {
656            actual_track_name = Some(track_element.text().collect::<String>().trim().to_string());
657        }
658
659        if let Some(album_element) = document.select(&album_name_selector).next() {
660            actual_album_name = Some(album_element.text().collect::<String>().trim().to_string());
661        }
662
663        // If not found, try extracting from the raw response text using generic patterns
664        if actual_track_name.is_none() || actual_album_name.is_none() {
665            // Look for track name in href="/music/{artist}/_/{track}"
666            // Use regex to find track URLs
667            let track_pattern = regex::Regex::new(r#"href="/music/[^"]+/_/([^"]+)""#).unwrap();
668            if let Some(captures) = track_pattern.captures(&response_text) {
669                if let Some(track_match) = captures.get(1) {
670                    let raw_track = track_match.as_str();
671                    // URL decode the track name
672                    let decoded_track = urlencoding::decode(raw_track)
673                        .unwrap_or_else(|_| raw_track.into())
674                        .replace("+", " ");
675                    actual_track_name = Some(decoded_track);
676                }
677            }
678
679            // Look for album name in href="/music/{artist}/{album}"
680            // Find album links that are not track links (don't contain /_/)
681            let album_pattern =
682                regex::Regex::new(r#"href="/music/[^"]+/([^"/_]+)"[^>]*>[^<]*</a>"#).unwrap();
683            if let Some(captures) = album_pattern.captures(&response_text) {
684                if let Some(album_match) = captures.get(1) {
685                    let raw_album = album_match.as_str();
686                    // URL decode the album name
687                    let decoded_album = urlencoding::decode(raw_album)
688                        .unwrap_or_else(|_| raw_album.into())
689                        .replace("+", " ");
690                    actual_album_name = Some(decoded_album);
691                }
692            }
693        }
694
695        log::debug!(
696            "Response analysis: success_alert={}, error_alert={}, track='{}', album='{}'",
697            has_success_alert,
698            has_error_alert,
699            actual_track_name.as_deref().unwrap_or("not found"),
700            actual_album_name.as_deref().unwrap_or("not found")
701        );
702
703        // Determine if edit was truly successful
704        let final_success = response.status().is_success() && has_success_alert && !has_error_alert;
705
706        // Create detailed message
707        let message = if has_error_alert {
708            // Extract error message
709            if let Some(error_element) = document.select(&error_selector).next() {
710                Some(format!(
711                    "Edit failed: {}",
712                    error_element.text().collect::<String>().trim()
713                ))
714            } else {
715                Some("Edit failed with unknown error".to_string())
716            }
717        } else if final_success {
718            Some(format!(
719                "Edit successful - Track: '{}', Album: '{}'",
720                actual_track_name.as_deref().unwrap_or("unknown"),
721                actual_album_name.as_deref().unwrap_or("unknown")
722            ))
723        } else {
724            Some(format!("Edit failed with status: {}", response.status()))
725        };
726
727        Ok(EditResponse {
728            success: final_success,
729            message,
730        })
731    }
732
733    /// Load prepopulated form values for editing a specific track
734    /// This extracts scrobble data directly from the track page forms
735    pub async fn load_edit_form_values(
736        &mut self,
737        track_name: &str,
738        artist_name: &str,
739    ) -> Result<crate::ScrobbleEdit> {
740        log::debug!("Loading edit form values for '{track_name}' by '{artist_name}'");
741
742        // Get the specific track page to find scrobble forms
743        // Add +noredirect to avoid redirects as per lastfm-bulk-edit approach
744        // Use the correct URL format with underscore: artist/_/track
745        let track_url = format!(
746            "{}/user/{}/library/music/+noredirect/{}/_/{}",
747            self.base_url,
748            self.username,
749            urlencoding::encode(artist_name),
750            urlencoding::encode(track_name)
751        );
752
753        log::debug!("Fetching track page: {track_url}");
754
755        let mut response = self.get(&track_url).await?;
756        let html = response
757            .body_string()
758            .await
759            .map_err(|e| crate::LastFmError::Http(e.to_string()))?;
760
761        let document = Html::parse_document(&html);
762
763        // Extract scrobble data directly from the track page forms
764        self.extract_scrobble_data_from_track_page(&document, track_name, artist_name)
765    }
766
767    /// Extract scrobble edit data directly from track page forms
768    /// Based on the approach used in lastfm-bulk-edit
769    fn extract_scrobble_data_from_track_page(
770        &self,
771        document: &Html,
772        expected_track: &str,
773        expected_artist: &str,
774    ) -> Result<crate::ScrobbleEdit> {
775        // Look for the chartlist table that contains scrobbles
776        let table_selector =
777            Selector::parse("table.chartlist:not(.chartlist__placeholder)").unwrap();
778        let table = document.select(&table_selector).next().ok_or_else(|| {
779            crate::LastFmError::Parse("No chartlist table found on track page".to_string())
780        })?;
781
782        // Look for table rows that contain scrobble edit forms
783        let row_selector = Selector::parse("tr").unwrap();
784        for row in table.select(&row_selector) {
785            // Check if this row has a count bar link (means it's an aggregation, not individual scrobbles)
786            let count_bar_link_selector = Selector::parse(".chartlist-count-bar-link").unwrap();
787            if row.select(&count_bar_link_selector).next().is_some() {
788                log::debug!("Found count bar link, skipping aggregated row");
789                continue;
790            }
791
792            // Look for scrobble edit form in this row
793            let form_selector = Selector::parse("form[data-edit-scrobble]").unwrap();
794            if let Some(form) = row.select(&form_selector).next() {
795                // Extract all form values directly
796                let extract_form_value = |name: &str| -> Option<String> {
797                    let selector = Selector::parse(&format!("input[name='{name}']")).unwrap();
798                    form.select(&selector)
799                        .next()
800                        .and_then(|input| input.value().attr("value"))
801                        .map(|s| s.to_string())
802                };
803
804                // Get the track and artist from this form
805                let form_track = extract_form_value("track_name").unwrap_or_default();
806                let form_artist = extract_form_value("artist_name").unwrap_or_default();
807                let form_album = extract_form_value("album_name").unwrap_or_default();
808                let form_album_artist =
809                    extract_form_value("album_artist_name").unwrap_or_else(|| form_artist.clone());
810                let form_timestamp = extract_form_value("timestamp").unwrap_or_default();
811
812                log::debug!(
813                    "Found scrobble form - Track: '{form_track}', Artist: '{form_artist}', Album: '{form_album}', Timestamp: {form_timestamp}"
814                );
815
816                // Check if this form matches the expected track and artist
817                if form_track == expected_track && form_artist == expected_artist {
818                    let timestamp = form_timestamp.parse::<u64>().map_err(|_| {
819                        crate::LastFmError::Parse("Invalid timestamp in form".to_string())
820                    })?;
821
822                    log::debug!(
823                        "✅ Found matching scrobble form for '{expected_track}' by '{expected_artist}'"
824                    );
825
826                    // Create ScrobbleEdit with the extracted values
827                    return Ok(crate::ScrobbleEdit::new(
828                        form_track.clone(),
829                        form_album.clone(),
830                        form_artist.clone(),
831                        form_album_artist.clone(),
832                        form_track,
833                        form_album,
834                        form_artist,
835                        form_album_artist,
836                        timestamp,
837                        true,
838                    ));
839                }
840            }
841        }
842
843        Err(crate::LastFmError::Parse(format!(
844            "No scrobble form found for track '{expected_track}' by '{expected_artist}'"
845        )))
846    }
847
848    /// Get tracks from a specific album page
849    /// This makes a single request to the album page and extracts track data
850    pub async fn get_album_tracks(
851        &mut self,
852        album_name: &str,
853        artist_name: &str,
854    ) -> Result<Vec<Track>> {
855        log::debug!("Getting tracks from album '{album_name}' by '{artist_name}'");
856
857        // Get the album page directly - this should contain track listings
858        let album_url = format!(
859            "{}/user/{}/library/music/{}/{}",
860            self.base_url,
861            self.username,
862            urlencoding::encode(artist_name),
863            urlencoding::encode(album_name)
864        );
865
866        log::debug!("Fetching album page: {album_url}");
867
868        let mut response = self.get(&album_url).await?;
869        let html = response
870            .body_string()
871            .await
872            .map_err(|e| LastFmError::Http(e.to_string()))?;
873
874        let document = Html::parse_document(&html);
875
876        // Use the shared track extraction function
877        let tracks = self.extract_tracks_from_document(&document, artist_name)?;
878
879        log::debug!(
880            "Successfully parsed {} tracks from album page",
881            tracks.len()
882        );
883        Ok(tracks)
884    }
885
886    /// Edit album metadata by updating scrobbles with new album name
887    /// This edits ALL tracks from the album that are found in recent scrobbles
888    pub async fn edit_album(
889        &mut self,
890        old_album_name: &str,
891        new_album_name: &str,
892        artist_name: &str,
893    ) -> Result<EditResponse> {
894        log::debug!("Editing album '{old_album_name}' -> '{new_album_name}' by '{artist_name}'");
895
896        // Get all tracks from the album page
897        let tracks = self.get_album_tracks(old_album_name, artist_name).await?;
898
899        if tracks.is_empty() {
900            return Ok(EditResponse {
901                success: false,
902                message: Some(format!(
903                    "No tracks found for album '{old_album_name}' by '{artist_name}'. Make sure the album name matches exactly."
904                )),
905            });
906        }
907
908        log::info!(
909            "Found {} tracks in album '{}'",
910            tracks.len(),
911            old_album_name
912        );
913
914        let mut successful_edits = 0;
915        let mut failed_edits = 0;
916        let mut error_messages = Vec::new();
917        let mut skipped_tracks = 0;
918
919        // For each track, try to load and edit it
920        for (index, track) in tracks.iter().enumerate() {
921            log::debug!(
922                "Processing track {}/{}: '{}'",
923                index + 1,
924                tracks.len(),
925                track.name
926            );
927
928            match self.load_edit_form_values(&track.name, artist_name).await {
929                Ok(mut edit_data) => {
930                    // Update the album name
931                    edit_data.album_name = new_album_name.to_string();
932
933                    // Perform the edit
934                    match self.edit_scrobble(&edit_data).await {
935                        Ok(response) => {
936                            if response.success {
937                                successful_edits += 1;
938                                log::info!("✅ Successfully edited track '{}'", track.name);
939                            } else {
940                                failed_edits += 1;
941                                let error_msg = format!(
942                                    "Failed to edit track '{}': {}",
943                                    track.name,
944                                    response
945                                        .message
946                                        .unwrap_or_else(|| "Unknown error".to_string())
947                                );
948                                error_messages.push(error_msg);
949                                log::debug!("❌ {}", error_messages.last().unwrap());
950                            }
951                        }
952                        Err(e) => {
953                            failed_edits += 1;
954                            let error_msg = format!("Error editing track '{}': {}", track.name, e);
955                            error_messages.push(error_msg);
956                            log::info!("❌ {}", error_messages.last().unwrap());
957                        }
958                    }
959                }
960                Err(e) => {
961                    skipped_tracks += 1;
962                    log::debug!("Could not load edit form for track '{}': {e}", track.name);
963                    // Continue to next track - some tracks might not be in recent scrobbles
964                }
965            }
966
967            // Add delay between edits to be respectful to the server
968            if index < tracks.len() - 1 {
969                tokio::time::sleep(tokio::time::Duration::from_millis(1000)).await;
970            }
971        }
972
973        let total_processed = successful_edits + failed_edits;
974        let success = successful_edits > 0 && failed_edits == 0;
975
976        let message = if success {
977            Some(format!(
978                "Successfully renamed album '{old_album_name}' to '{new_album_name}' for all {successful_edits} editable tracks ({skipped_tracks} tracks were not in recent scrobbles)"
979            ))
980        } else if successful_edits > 0 {
981            Some(format!(
982                "Partially successful: {} of {} editable tracks renamed ({} skipped, {} failed). Errors: {}",
983                successful_edits,
984                total_processed,
985                skipped_tracks,
986                failed_edits,
987                error_messages.join("; ")
988            ))
989        } else if total_processed == 0 {
990            Some(format!(
991                "No editable tracks found for album '{}' by '{}'. All {} tracks were skipped because they're not in recent scrobbles.",
992                old_album_name, artist_name, tracks.len()
993            ))
994        } else {
995            Some(format!(
996                "Failed to rename any tracks. Errors: {}",
997                error_messages.join("; ")
998            ))
999        };
1000
1001        Ok(EditResponse { success, message })
1002    }
1003
1004    /// Edit artist metadata by updating scrobbles with new artist name
1005    /// This edits ALL tracks from the artist that are found in recent scrobbles
1006    pub async fn edit_artist(
1007        &mut self,
1008        old_artist_name: &str,
1009        new_artist_name: &str,
1010    ) -> Result<EditResponse> {
1011        log::debug!("Editing artist '{old_artist_name}' -> '{new_artist_name}'");
1012
1013        // Get all tracks from the artist using the iterator
1014        let mut tracks = Vec::new();
1015        let mut iterator = self.artist_tracks(old_artist_name);
1016
1017        // Collect tracks (limit to reasonable number to avoid infinite processing)
1018        while tracks.len() < 200 {
1019            match iterator.next().await {
1020                Ok(Some(track)) => tracks.push(track),
1021                Ok(None) => break,
1022                Err(e) => {
1023                    log::warn!("Error fetching artist tracks: {e}");
1024                    break;
1025                }
1026            }
1027        }
1028
1029        if tracks.is_empty() {
1030            return Ok(EditResponse {
1031                success: false,
1032                message: Some(format!(
1033                    "No tracks found for artist '{old_artist_name}'. Make sure the artist name matches exactly."
1034                )),
1035            });
1036        }
1037
1038        log::info!(
1039            "Found {} tracks for artist '{}'",
1040            tracks.len(),
1041            old_artist_name
1042        );
1043
1044        let mut successful_edits = 0;
1045        let mut failed_edits = 0;
1046        let mut error_messages = Vec::new();
1047        let mut skipped_tracks = 0;
1048
1049        // For each track, try to load and edit it
1050        for (index, track) in tracks.iter().enumerate() {
1051            log::debug!(
1052                "Processing track {}/{}: '{}'",
1053                index + 1,
1054                tracks.len(),
1055                track.name
1056            );
1057
1058            match self
1059                .load_edit_form_values(&track.name, old_artist_name)
1060                .await
1061            {
1062                Ok(mut edit_data) => {
1063                    // Update the artist name and album artist name
1064                    edit_data.artist_name = new_artist_name.to_string();
1065                    edit_data.album_artist_name = new_artist_name.to_string();
1066
1067                    // Perform the edit
1068                    match self.edit_scrobble(&edit_data).await {
1069                        Ok(response) => {
1070                            if response.success {
1071                                successful_edits += 1;
1072                                log::info!("✅ Successfully edited track '{}'", track.name);
1073                            } else {
1074                                failed_edits += 1;
1075                                let error_msg = format!(
1076                                    "Failed to edit track '{}': {}",
1077                                    track.name,
1078                                    response
1079                                        .message
1080                                        .unwrap_or_else(|| "Unknown error".to_string())
1081                                );
1082                                error_messages.push(error_msg);
1083                                log::debug!("❌ {}", error_messages.last().unwrap());
1084                            }
1085                        }
1086                        Err(e) => {
1087                            failed_edits += 1;
1088                            let error_msg = format!("Error editing track '{}': {}", track.name, e);
1089                            error_messages.push(error_msg);
1090                            log::info!("❌ {}", error_messages.last().unwrap());
1091                        }
1092                    }
1093                }
1094                Err(e) => {
1095                    skipped_tracks += 1;
1096                    log::debug!("Could not load edit form for track '{}': {e}", track.name);
1097                    // Continue to next track - some tracks might not be in recent scrobbles
1098                }
1099            }
1100
1101            // Add delay between edits to be respectful to the server
1102            if index < tracks.len() - 1 {
1103                tokio::time::sleep(tokio::time::Duration::from_millis(1000)).await;
1104            }
1105        }
1106
1107        let total_processed = successful_edits + failed_edits;
1108        let success = successful_edits > 0 && failed_edits == 0;
1109
1110        let message = if success {
1111            Some(format!(
1112                "Successfully renamed artist '{old_artist_name}' to '{new_artist_name}' for all {successful_edits} editable tracks ({skipped_tracks} tracks were not in recent scrobbles)"
1113            ))
1114        } else if successful_edits > 0 {
1115            Some(format!(
1116                "Partially successful: {} of {} editable tracks renamed ({} skipped, {} failed). Errors: {}",
1117                successful_edits,
1118                total_processed,
1119                skipped_tracks,
1120                failed_edits,
1121                error_messages.join("; ")
1122            ))
1123        } else if total_processed == 0 {
1124            Some(format!(
1125                "No editable tracks found for artist '{}'. All {} tracks were skipped because they're not in recent scrobbles.",
1126                old_artist_name, tracks.len()
1127            ))
1128        } else {
1129            Some(format!(
1130                "Failed to rename any tracks. Errors: {}",
1131                error_messages.join("; ")
1132            ))
1133        };
1134
1135        Ok(EditResponse { success, message })
1136    }
1137
1138    /// Edit artist metadata for a specific track only
1139    /// This edits only the specified track if found in recent scrobbles
1140    pub async fn edit_artist_for_track(
1141        &mut self,
1142        track_name: &str,
1143        old_artist_name: &str,
1144        new_artist_name: &str,
1145    ) -> Result<EditResponse> {
1146        log::debug!("Editing artist for track '{track_name}' from '{old_artist_name}' -> '{new_artist_name}'");
1147
1148        match self.load_edit_form_values(track_name, old_artist_name).await {
1149            Ok(mut edit_data) => {
1150                // Update the artist name and album artist name
1151                edit_data.artist_name = new_artist_name.to_string();
1152                edit_data.album_artist_name = new_artist_name.to_string();
1153
1154                log::info!("Updating artist for track '{track_name}' from '{old_artist_name}' to '{new_artist_name}'");
1155
1156                // Perform the edit
1157                match self.edit_scrobble(&edit_data).await {
1158                    Ok(response) => {
1159                        if response.success {
1160                            Ok(EditResponse {
1161                                success: true,
1162                                message: Some(format!(
1163                                    "Successfully renamed artist for track '{track_name}' from '{old_artist_name}' to '{new_artist_name}'"
1164                                )),
1165                            })
1166                        } else {
1167                            Ok(EditResponse {
1168                                success: false,
1169                                message: Some(format!(
1170                                    "Failed to rename artist for track '{track_name}': {}",
1171                                    response.message.unwrap_or_else(|| "Unknown error".to_string())
1172                                )),
1173                            })
1174                        }
1175                    }
1176                    Err(e) => Ok(EditResponse {
1177                        success: false,
1178                        message: Some(format!("Error editing track '{track_name}': {e}")),
1179                    }),
1180                }
1181            }
1182            Err(e) => Ok(EditResponse {
1183                success: false,
1184                message: Some(format!(
1185                    "Could not load edit form for track '{track_name}' by '{old_artist_name}': {e}. The track may not be in your recent scrobbles."
1186                )),
1187            }),
1188        }
1189    }
1190
1191    /// Edit artist metadata for all tracks in a specific album
1192    /// This edits ALL tracks from the specified album that are found in recent scrobbles
1193    pub async fn edit_artist_for_album(
1194        &mut self,
1195        album_name: &str,
1196        old_artist_name: &str,
1197        new_artist_name: &str,
1198    ) -> Result<EditResponse> {
1199        log::debug!("Editing artist for album '{album_name}' from '{old_artist_name}' -> '{new_artist_name}'");
1200
1201        // Get all tracks from the album page
1202        let tracks = self.get_album_tracks(album_name, old_artist_name).await?;
1203
1204        if tracks.is_empty() {
1205            return Ok(EditResponse {
1206                success: false,
1207                message: Some(format!(
1208                    "No tracks found for album '{album_name}' by '{old_artist_name}'. Make sure the album name matches exactly."
1209                )),
1210            });
1211        }
1212
1213        log::info!(
1214            "Found {} tracks in album '{}' by '{}'",
1215            tracks.len(),
1216            album_name,
1217            old_artist_name
1218        );
1219
1220        let mut successful_edits = 0;
1221        let mut failed_edits = 0;
1222        let mut error_messages = Vec::new();
1223        let mut skipped_tracks = 0;
1224
1225        // For each track, try to load and edit it
1226        for (index, track) in tracks.iter().enumerate() {
1227            log::debug!(
1228                "Processing track {}/{}: '{}'",
1229                index + 1,
1230                tracks.len(),
1231                track.name
1232            );
1233
1234            match self
1235                .load_edit_form_values(&track.name, old_artist_name)
1236                .await
1237            {
1238                Ok(mut edit_data) => {
1239                    // Update the artist name and album artist name
1240                    edit_data.artist_name = new_artist_name.to_string();
1241                    edit_data.album_artist_name = new_artist_name.to_string();
1242
1243                    // Perform the edit
1244                    match self.edit_scrobble(&edit_data).await {
1245                        Ok(response) => {
1246                            if response.success {
1247                                successful_edits += 1;
1248                                log::info!("✅ Successfully edited track '{}'", track.name);
1249                            } else {
1250                                failed_edits += 1;
1251                                let error_msg = format!(
1252                                    "Failed to edit track '{}': {}",
1253                                    track.name,
1254                                    response
1255                                        .message
1256                                        .unwrap_or_else(|| "Unknown error".to_string())
1257                                );
1258                                error_messages.push(error_msg);
1259                                log::debug!("❌ {}", error_messages.last().unwrap());
1260                            }
1261                        }
1262                        Err(e) => {
1263                            failed_edits += 1;
1264                            let error_msg = format!("Error editing track '{}': {}", track.name, e);
1265                            error_messages.push(error_msg);
1266                            log::info!("❌ {}", error_messages.last().unwrap());
1267                        }
1268                    }
1269                }
1270                Err(e) => {
1271                    skipped_tracks += 1;
1272                    log::debug!("Could not load edit form for track '{}': {e}", track.name);
1273                    // Continue to next track - some tracks might not be in recent scrobbles
1274                }
1275            }
1276
1277            // Add delay between edits to be respectful to the server
1278            if index < tracks.len() - 1 {
1279                tokio::time::sleep(tokio::time::Duration::from_millis(1000)).await;
1280            }
1281        }
1282
1283        let total_processed = successful_edits + failed_edits;
1284        let success = successful_edits > 0 && failed_edits == 0;
1285
1286        let message = if success {
1287            Some(format!(
1288                "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)"
1289            ))
1290        } else if successful_edits > 0 {
1291            Some(format!(
1292                "Partially successful: {} of {} editable tracks renamed ({} skipped, {} failed). Errors: {}",
1293                successful_edits,
1294                total_processed,
1295                skipped_tracks,
1296                failed_edits,
1297                error_messages.join("; ")
1298            ))
1299        } else if total_processed == 0 {
1300            Some(format!(
1301                "No editable tracks found for album '{album_name}' by '{old_artist_name}'. All {} tracks were skipped because they're not in recent scrobbles.",
1302                tracks.len()
1303            ))
1304        } else {
1305            Some(format!(
1306                "Failed to rename any tracks. Errors: {}",
1307                error_messages.join("; ")
1308            ))
1309        };
1310
1311        Ok(EditResponse { success, message })
1312    }
1313
1314    pub async fn get_artist_tracks_page(&mut self, artist: &str, page: u32) -> Result<TrackPage> {
1315        // Use AJAX endpoint for page content
1316        let url = format!(
1317            "{}/user/{}/library/music/{}/+tracks?page={}&ajax=true",
1318            self.base_url,
1319            self.username,
1320            artist.replace(" ", "+"),
1321            page
1322        );
1323
1324        log::debug!("Fetching tracks page {page} for artist: {artist}");
1325        let mut response = self.get(&url).await?;
1326        let content = response
1327            .body_string()
1328            .await
1329            .map_err(|e| LastFmError::Http(e.to_string()))?;
1330
1331        log::debug!(
1332            "AJAX response: {} status, {} chars",
1333            response.status(),
1334            content.len()
1335        );
1336
1337        // Check if we got JSON or HTML
1338        if content.trim_start().starts_with("{") || content.trim_start().starts_with("[") {
1339            log::debug!("Parsing JSON response from AJAX endpoint");
1340            self.parse_json_tracks_page(&content, page, artist)
1341        } else {
1342            log::debug!("Parsing HTML response from AJAX endpoint");
1343            let document = Html::parse_document(&content);
1344            self.parse_tracks_page(&document, page, artist)
1345        }
1346    }
1347
1348    fn parse_json_tracks_page(
1349        &self,
1350        _json_content: &str,
1351        page_number: u32,
1352        _artist: &str,
1353    ) -> Result<TrackPage> {
1354        // JSON parsing not yet implemented - fallback to empty page
1355        log::debug!("JSON parsing not implemented, returning empty page");
1356        Ok(TrackPage {
1357            tracks: Vec::new(),
1358            page_number,
1359            has_next_page: false,
1360            total_pages: Some(1),
1361        })
1362    }
1363
1364    /// Extract tracks from HTML document using multiple parsing strategies
1365    pub fn extract_tracks_from_document(
1366        &self,
1367        document: &Html,
1368        artist: &str,
1369    ) -> Result<Vec<Track>> {
1370        let mut tracks = Vec::new();
1371        let mut seen_tracks = std::collections::HashSet::new();
1372
1373        // Strategy 1: Try parsing track data from data-track-name attributes (AJAX response)
1374        let track_selector = Selector::parse("[data-track-name]").unwrap();
1375        let track_elements: Vec<_> = document.select(&track_selector).collect();
1376
1377        if !track_elements.is_empty() {
1378            log::debug!(
1379                "Found {} track elements with data-track-name",
1380                track_elements.len()
1381            );
1382
1383            for element in track_elements {
1384                if let Some(track_name) = element.value().attr("data-track-name") {
1385                    if seen_tracks.contains(track_name) {
1386                        continue;
1387                    }
1388                    seen_tracks.insert(track_name.to_string());
1389
1390                    // Find the play count for this track
1391                    if let Ok(playcount) = self.find_playcount_for_track(document, track_name) {
1392                        // Try to find timestamp for this track
1393                        let timestamp = self.find_timestamp_for_track(document, track_name);
1394
1395                        let track = Track {
1396                            name: track_name.to_string(),
1397                            artist: artist.to_string(),
1398                            playcount,
1399                            timestamp,
1400                        };
1401                        tracks.push(track);
1402                    }
1403
1404                    if tracks.len() >= 50 {
1405                        break; // Last.fm shows 50 tracks per page
1406                    }
1407                }
1408            }
1409        }
1410
1411        // Strategy 2: Parse tracks from hidden form inputs (for tracks like "Comes a Time - 2016")
1412        let form_input_selector = Selector::parse("input[name='track']").unwrap();
1413        let form_inputs: Vec<_> = document.select(&form_input_selector).collect();
1414
1415        if !form_inputs.is_empty() {
1416            log::debug!("Found {} form inputs with track names", form_inputs.len());
1417
1418            for input in form_inputs {
1419                if let Some(track_name) = input.value().attr("value") {
1420                    if seen_tracks.contains(track_name) {
1421                        continue;
1422                    }
1423                    seen_tracks.insert(track_name.to_string());
1424
1425                    // Try to find play count - may not always succeed for form-based tracks
1426                    let playcount = self
1427                        .find_playcount_for_track(document, track_name)
1428                        .unwrap_or(0);
1429                    let timestamp = self.find_timestamp_for_track(document, track_name);
1430
1431                    let track = Track {
1432                        name: track_name.to_string(),
1433                        artist: artist.to_string(),
1434                        playcount,
1435                        timestamp,
1436                    };
1437                    tracks.push(track);
1438
1439                    if tracks.len() >= 50 {
1440                        break;
1441                    }
1442                }
1443            }
1444        }
1445
1446        // Strategy 3: Fallback to table parsing method if we didn't find enough tracks
1447        if tracks.len() < 10 {
1448            log::debug!("Found {} tracks so far, trying table parsing", tracks.len());
1449
1450            let table_selector = Selector::parse("table.chartlist").unwrap();
1451            let row_selector = Selector::parse("tbody tr").unwrap();
1452
1453            if let Some(table) = document.select(&table_selector).next() {
1454                for row in table.select(&row_selector) {
1455                    if let Ok(mut track) = self.parse_track_row(&row) {
1456                        if !seen_tracks.contains(&track.name) {
1457                            track.artist = artist.to_string();
1458                            seen_tracks.insert(track.name.clone());
1459                            tracks.push(track);
1460                        }
1461                    }
1462                }
1463            }
1464        }
1465
1466        log::debug!("Successfully extracted {} unique tracks", tracks.len());
1467        Ok(tracks)
1468    }
1469
1470    pub fn parse_tracks_page(
1471        &self,
1472        document: &Html,
1473        page_number: u32,
1474        artist: &str,
1475    ) -> Result<TrackPage> {
1476        let tracks = self.extract_tracks_from_document(document, artist)?;
1477
1478        // Check for pagination
1479        let (has_next_page, total_pages) = self.parse_pagination(document, page_number)?;
1480
1481        Ok(TrackPage {
1482            tracks,
1483            page_number,
1484            has_next_page,
1485            total_pages,
1486        })
1487    }
1488
1489    fn find_timestamp_for_track(&self, document: &Html, track_name: &str) -> Option<u64> {
1490        // Look for timestamp in hidden form inputs for edit scrobble forms
1491        let form_selector = Selector::parse("form[data-edit-scrobble]").unwrap();
1492        let timestamp_selector = Selector::parse("input[name=\"timestamp\"]").unwrap();
1493
1494        for form in document.select(&form_selector) {
1495            // Check if this form is for our track
1496            let track_input_selector = Selector::parse("input[name=\"track_name\"]").unwrap();
1497            if let Some(track_input) = form.select(&track_input_selector).next() {
1498                if let Some(value) = track_input.value().attr("value") {
1499                    if value == track_name {
1500                        // Found the form for our track, get the timestamp
1501                        if let Some(timestamp_input) = form.select(&timestamp_selector).next() {
1502                            if let Some(timestamp_str) = timestamp_input.value().attr("value") {
1503                                if let Ok(timestamp) = timestamp_str.parse::<u64>() {
1504                                    return Some(timestamp);
1505                                }
1506                            }
1507                        }
1508                    }
1509                }
1510            }
1511        }
1512        None
1513    }
1514
1515    fn find_playcount_for_track(&self, document: &Html, track_name: &str) -> Result<u32> {
1516        // Look for chartlist-count-bar-value elements near the track
1517        let count_selector = Selector::parse(".chartlist-count-bar-value").unwrap();
1518        let link_selector = Selector::parse("a[href*=\"/music/\"]").unwrap();
1519
1520        // Find all track links that match our track name
1521        for link in document.select(&link_selector) {
1522            let link_text = link.text().collect::<String>().trim().to_string();
1523            if link_text == track_name {
1524                // Found the track link, now look for the play count in the same row
1525                if let Some(row) = self.find_ancestor_row(link) {
1526                    // Look for play count in this row
1527                    for count_elem in row.select(&count_selector) {
1528                        let count_text = count_elem.text().collect::<String>();
1529                        if let Some(number_part) = count_text.split_whitespace().next() {
1530                            if let Ok(count) = number_part.parse::<u32>() {
1531                                return Ok(count);
1532                            }
1533                        }
1534                    }
1535                }
1536            }
1537        }
1538
1539        // Default fallback
1540        Ok(1)
1541    }
1542
1543    fn find_ancestor_row<'a>(
1544        &self,
1545        element: scraper::ElementRef<'a>,
1546    ) -> Option<scraper::ElementRef<'a>> {
1547        let mut current = element;
1548
1549        // Traverse up the DOM to find the table row
1550        while let Some(parent) = current.parent() {
1551            if let Some(parent_elem) = scraper::ElementRef::wrap(parent) {
1552                if parent_elem.value().name() == "tr" {
1553                    return Some(parent_elem);
1554                }
1555                current = parent_elem;
1556            } else {
1557                break;
1558            }
1559        }
1560        None
1561    }
1562
1563    fn parse_track_row(&self, row: &scraper::ElementRef) -> Result<Track> {
1564        let name_selector = Selector::parse(".chartlist-name a").unwrap();
1565
1566        let name = row
1567            .select(&name_selector)
1568            .next()
1569            .map(|el| el.text().collect::<String>().trim().to_string())
1570            .ok_or_else(|| LastFmError::Parse("Missing track name".to_string()))?;
1571
1572        // Parse play count from .chartlist-count-bar-value
1573        // Format is like "59 scrobbles" or just "59"
1574        let playcount_selector = Selector::parse(".chartlist-count-bar-value").unwrap();
1575        let mut playcount = 1; // default fallback
1576
1577        if let Some(element) = row.select(&playcount_selector).next() {
1578            let text = element.text().collect::<String>().trim().to_string();
1579            // Extract just the number part (before "scrobbles" if present)
1580            if let Some(number_part) = text.split_whitespace().next() {
1581                if let Ok(count) = number_part.parse::<u32>() {
1582                    playcount = count;
1583                }
1584            }
1585        }
1586
1587        // On artist tracks pages, the artist is consistent (it's the artist we're looking at)
1588        // We could extract it from the page context, but for now let's keep it simple
1589        let artist = "".to_string(); // We'll fill this in when we call the method
1590
1591        Ok(Track {
1592            name,
1593            artist,
1594            playcount,
1595            timestamp: None, // Not available in table parsing mode
1596        })
1597    }
1598
1599    /// Parse recent scrobbles from the user's library page
1600    /// This extracts real scrobble data with timestamps for editing
1601    pub fn parse_recent_scrobbles(&self, document: &Html) -> Result<Vec<Track>> {
1602        let mut tracks = Vec::new();
1603
1604        // Recent scrobbles are typically in chartlist tables - there can be multiple
1605        let table_selector = Selector::parse("table.chartlist").unwrap();
1606        let row_selector = Selector::parse("tbody tr").unwrap();
1607
1608        let tables: Vec<_> = document.select(&table_selector).collect();
1609        log::debug!("Found {} chartlist tables", tables.len());
1610
1611        for table in tables {
1612            for row in table.select(&row_selector) {
1613                if let Ok(track) = self.parse_recent_scrobble_row(&row) {
1614                    tracks.push(track);
1615                }
1616            }
1617        }
1618
1619        if tracks.is_empty() {
1620            log::debug!("No tracks found in recent scrobbles");
1621        }
1622
1623        log::debug!("Parsed {} recent scrobbles", tracks.len());
1624        Ok(tracks)
1625    }
1626
1627    /// Parse a single row from the recent scrobbles table
1628    fn parse_recent_scrobble_row(&self, row: &scraper::ElementRef) -> Result<Track> {
1629        // Extract track name
1630        let name_selector = Selector::parse(".chartlist-name a").unwrap();
1631        let name = row
1632            .select(&name_selector)
1633            .next()
1634            .ok_or(LastFmError::Parse("Missing track name".to_string()))?
1635            .text()
1636            .collect::<String>()
1637            .trim()
1638            .to_string();
1639
1640        // Extract artist name
1641        let artist_selector = Selector::parse(".chartlist-artist a").unwrap();
1642        let artist = row
1643            .select(&artist_selector)
1644            .next()
1645            .ok_or(LastFmError::Parse("Missing artist name".to_string()))?
1646            .text()
1647            .collect::<String>()
1648            .trim()
1649            .to_string();
1650
1651        // Extract timestamp from data attributes or hidden inputs
1652        let timestamp = self.extract_scrobble_timestamp(row);
1653
1654        // For recent scrobbles, playcount is typically 1 since they're individual scrobbles
1655        let playcount = 1;
1656
1657        Ok(Track {
1658            name,
1659            artist,
1660            playcount,
1661            timestamp,
1662        })
1663    }
1664
1665    /// Extract timestamp from scrobble row elements
1666    fn extract_scrobble_timestamp(&self, row: &scraper::ElementRef) -> Option<u64> {
1667        // Look for timestamp in various places:
1668
1669        // 1. Check for data-timestamp attribute
1670        if let Some(timestamp_str) = row.value().attr("data-timestamp") {
1671            if let Ok(timestamp) = timestamp_str.parse::<u64>() {
1672                return Some(timestamp);
1673            }
1674        }
1675
1676        // 2. Look for hidden timestamp input
1677        let timestamp_input_selector = Selector::parse("input[name='timestamp']").unwrap();
1678        if let Some(input) = row.select(&timestamp_input_selector).next() {
1679            if let Some(value) = input.value().attr("value") {
1680                if let Ok(timestamp) = value.parse::<u64>() {
1681                    return Some(timestamp);
1682                }
1683            }
1684        }
1685
1686        // 3. Look for edit form with timestamp
1687        let edit_form_selector =
1688            Selector::parse("form[data-edit-scrobble] input[name='timestamp']").unwrap();
1689        if let Some(timestamp_input) = row.select(&edit_form_selector).next() {
1690            if let Some(value) = timestamp_input.value().attr("value") {
1691                if let Ok(timestamp) = value.parse::<u64>() {
1692                    return Some(timestamp);
1693                }
1694            }
1695        }
1696
1697        // 4. Look for time element with datetime attribute
1698        let time_selector = Selector::parse("time").unwrap();
1699        if let Some(time_elem) = row.select(&time_selector).next() {
1700            if let Some(datetime) = time_elem.value().attr("datetime") {
1701                // Parse ISO datetime to timestamp
1702                if let Ok(parsed_time) = chrono::DateTime::parse_from_rfc3339(datetime) {
1703                    return Some(parsed_time.timestamp() as u64);
1704                }
1705            }
1706        }
1707
1708        None
1709    }
1710
1711    fn parse_pagination(&self, document: &Html, current_page: u32) -> Result<(bool, Option<u32>)> {
1712        let pagination_selector = Selector::parse(".pagination-list").unwrap();
1713
1714        if let Some(pagination) = document.select(&pagination_selector).next() {
1715            // Try multiple possible selectors for next page link
1716            let next_selectors = [
1717                "a[aria-label=\"Next\"]",
1718                ".pagination-next a",
1719                "a:contains(\"Next\")",
1720                ".next a",
1721            ];
1722
1723            let mut has_next = false;
1724            for selector_str in &next_selectors {
1725                if let Ok(selector) = Selector::parse(selector_str) {
1726                    if pagination.select(&selector).next().is_some() {
1727                        has_next = true;
1728                        break;
1729                    }
1730                }
1731            }
1732
1733            // Alternative: check if there are more page numbers after current
1734            if !has_next {
1735                let page_link_selector = Selector::parse("a").unwrap();
1736                for link in pagination.select(&page_link_selector) {
1737                    if let Some(href) = link.value().attr("href") {
1738                        if href.contains(&format!("page={}", current_page + 1)) {
1739                            has_next = true;
1740                            break;
1741                        }
1742                    }
1743                }
1744            }
1745
1746            // Try to find total pages from pagination numbers
1747            let page_link_selector = Selector::parse("a").unwrap();
1748            let mut max_page = current_page;
1749
1750            for link in pagination.select(&page_link_selector) {
1751                if let Some(href) = link.value().attr("href") {
1752                    if let Some(page_param) = href.split("page=").nth(1) {
1753                        if let Some(page_num_str) = page_param.split('&').next() {
1754                            if let Ok(page_num) = page_num_str.parse::<u32>() {
1755                                max_page = max_page.max(page_num);
1756                            }
1757                        }
1758                    }
1759                }
1760
1761                // Also check link text for page numbers
1762                let link_text = link.text().collect::<String>().trim().to_string();
1763                if let Ok(page_num) = link_text.parse::<u32>() {
1764                    max_page = max_page.max(page_num);
1765                }
1766            }
1767
1768            Ok((
1769                has_next,
1770                if max_page > current_page {
1771                    Some(max_page)
1772                } else {
1773                    None
1774                },
1775            ))
1776        } else {
1777            // No pagination found, single page
1778            Ok((false, Some(1)))
1779        }
1780    }
1781
1782    fn extract_csrf_token(&self, document: &Html) -> Result<String> {
1783        let csrf_selector = Selector::parse("input[name=\"csrfmiddlewaretoken\"]").unwrap();
1784
1785        document
1786            .select(&csrf_selector)
1787            .next()
1788            .and_then(|input| input.value().attr("value"))
1789            .map(|token| token.to_string())
1790            .ok_or(LastFmError::CsrfNotFound)
1791    }
1792
1793    /// Make an HTTP GET request with authentication and retry logic
1794    pub async fn get(&mut self, url: &str) -> Result<Response> {
1795        self.get_with_retry(url, 3).await
1796    }
1797
1798    /// Make an HTTP GET request with retry logic for rate limits
1799    async fn get_with_retry(&mut self, url: &str, max_retries: u32) -> Result<Response> {
1800        let mut retries = 0;
1801
1802        loop {
1803            match self.get_with_redirects(url, 0).await {
1804                Ok(mut response) => {
1805                    // Extract body and save debug info if enabled
1806                    let body = self.extract_response_body(url, &mut response).await?;
1807
1808                    // Check for rate limit patterns in successful responses
1809                    if response.status().is_success() && self.is_rate_limit_response(&body) {
1810                        log::debug!("Response body contains rate limit patterns");
1811                        if retries < max_retries {
1812                            let delay = 60 + (retries as u64 * 30); // Exponential backoff
1813                            log::info!("Rate limit detected in response body, retrying in {delay}s (attempt {}/{max_retries})", retries + 1);
1814                            tokio::time::sleep(tokio::time::Duration::from_secs(delay)).await;
1815                            retries += 1;
1816                            continue;
1817                        } else {
1818                            return Err(crate::LastFmError::RateLimit { retry_after: 60 });
1819                        }
1820                    }
1821
1822                    // Recreate response with the body we extracted
1823                    let mut new_response = http_types::Response::new(response.status());
1824                    for (name, values) in response.iter() {
1825                        for value in values {
1826                            new_response.insert_header(name.clone(), value.clone());
1827                        }
1828                    }
1829                    new_response.set_body(body);
1830
1831                    return Ok(new_response);
1832                }
1833                Err(crate::LastFmError::RateLimit { retry_after }) => {
1834                    if retries < max_retries {
1835                        let delay = retry_after + (retries as u64 * 30); // Exponential backoff
1836                        log::info!(
1837                            "Rate limit detected, retrying in {delay}s (attempt {}/{max_retries})",
1838                            retries + 1
1839                        );
1840                        tokio::time::sleep(tokio::time::Duration::from_secs(delay)).await;
1841                        retries += 1;
1842                    } else {
1843                        return Err(crate::LastFmError::RateLimit { retry_after });
1844                    }
1845                }
1846                Err(e) => return Err(e),
1847            }
1848        }
1849    }
1850
1851    async fn get_with_redirects(&mut self, url: &str, redirect_count: u32) -> Result<Response> {
1852        if redirect_count > 5 {
1853            return Err(LastFmError::Http("Too many redirects".to_string()));
1854        }
1855
1856        let mut request = Request::new(Method::Get, url.parse::<Url>().unwrap());
1857        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");
1858
1859        // Add session cookies for all authenticated requests
1860        if !self.session_cookies.is_empty() {
1861            let cookie_header = self.session_cookies.join("; ");
1862            request.insert_header("Cookie", &cookie_header);
1863        } else if url.contains("page=") {
1864            log::debug!("No cookies available for paginated request!");
1865        }
1866
1867        // Add browser-like headers for all requests
1868        if url.contains("ajax=true") {
1869            // AJAX request headers
1870            request.insert_header("Accept", "*/*");
1871            request.insert_header("X-Requested-With", "XMLHttpRequest");
1872        } else {
1873            // Regular page request headers
1874            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");
1875        }
1876        request.insert_header("Accept-Language", "en-US,en;q=0.9");
1877        request.insert_header("Accept-Encoding", "gzip, deflate, br");
1878        request.insert_header("DNT", "1");
1879        request.insert_header("Connection", "keep-alive");
1880        request.insert_header("Upgrade-Insecure-Requests", "1");
1881
1882        // Add referer for paginated requests
1883        if url.contains("page=") {
1884            let base_url = url.split('?').next().unwrap_or(url);
1885            request.insert_header("Referer", base_url);
1886        }
1887
1888        let response = self
1889            .client
1890            .send(request)
1891            .await
1892            .map_err(|e| LastFmError::Http(e.to_string()))?;
1893
1894        // Extract any new cookies from the response
1895        self.extract_cookies(&response);
1896
1897        // Handle redirects manually
1898        if response.status() == 302 || response.status() == 301 {
1899            if let Some(location) = response.header("location") {
1900                if let Some(redirect_url) = location.get(0) {
1901                    let redirect_url_str = redirect_url.as_str();
1902                    if url.contains("page=") {
1903                        log::debug!("Following redirect from {url} to {redirect_url_str}");
1904
1905                        // Check if this is a redirect to login - authentication issue
1906                        if redirect_url_str.contains("/login") {
1907                            log::debug!("Redirect to login page - authentication failed for paginated request");
1908                            return Err(LastFmError::Auth(
1909                                "Session expired or invalid for paginated request".to_string(),
1910                            ));
1911                        }
1912                    }
1913
1914                    // Handle relative URLs
1915                    let full_redirect_url = if redirect_url_str.starts_with('/') {
1916                        format!("{}{redirect_url_str}", self.base_url)
1917                    } else if redirect_url_str.starts_with("http") {
1918                        redirect_url_str.to_string()
1919                    } else {
1920                        // Relative to current path
1921                        let base_url = url
1922                            .rsplit('/')
1923                            .skip(1)
1924                            .collect::<Vec<_>>()
1925                            .into_iter()
1926                            .rev()
1927                            .collect::<Vec<_>>()
1928                            .join("/");
1929                        format!("{base_url}/{redirect_url_str}")
1930                    };
1931
1932                    // Make a new request to the redirect URL
1933                    return Box::pin(
1934                        self.get_with_redirects(&full_redirect_url, redirect_count + 1),
1935                    )
1936                    .await;
1937                }
1938            }
1939        }
1940
1941        // Handle explicit rate limit responses
1942        if response.status() == 429 {
1943            let retry_after = response
1944                .header("retry-after")
1945                .and_then(|h| h.get(0))
1946                .and_then(|v| v.as_str().parse::<u64>().ok())
1947                .unwrap_or(60);
1948            return Err(LastFmError::RateLimit { retry_after });
1949        }
1950
1951        // Check for 403 responses that might be rate limits
1952        if response.status() == 403 {
1953            log::debug!("Got 403 response, checking if it's a rate limit");
1954            // For now, treat 403s from authenticated endpoints as potential rate limits
1955            if !self.session_cookies.is_empty() {
1956                log::debug!("403 on authenticated request - likely rate limit");
1957                return Err(LastFmError::RateLimit { retry_after: 60 });
1958            }
1959        }
1960
1961        Ok(response)
1962    }
1963
1964    /// Check if a response body indicates rate limiting
1965    fn is_rate_limit_response(&self, response_body: &str) -> bool {
1966        let body_lower = response_body.to_lowercase();
1967
1968        // Check against configured rate limit patterns
1969        for pattern in &self.rate_limit_patterns {
1970            if body_lower.contains(&pattern.to_lowercase()) {
1971                return true;
1972            }
1973        }
1974
1975        false
1976    }
1977
1978    fn extract_cookies(&mut self, response: &Response) {
1979        // Extract Set-Cookie headers and store them (avoiding duplicates)
1980        if let Some(cookie_headers) = response.header("set-cookie") {
1981            let mut new_cookies = 0;
1982            for cookie_header in cookie_headers {
1983                let cookie_str = cookie_header.as_str();
1984                // Extract just the cookie name=value part (before any semicolon)
1985                if let Some(cookie_value) = cookie_str.split(';').next() {
1986                    let cookie_name = cookie_value.split('=').next().unwrap_or("");
1987
1988                    // Remove any existing cookie with the same name
1989                    self.session_cookies
1990                        .retain(|existing| !existing.starts_with(&format!("{cookie_name}=")));
1991
1992                    self.session_cookies.push(cookie_value.to_string());
1993                    new_cookies += 1;
1994                }
1995            }
1996            if new_cookies > 0 {
1997                log::trace!(
1998                    "Extracted {} new cookies, total: {}",
1999                    new_cookies,
2000                    self.session_cookies.len()
2001                );
2002                log::trace!("Updated cookies: {:?}", &self.session_cookies);
2003
2004                // Check if sessionid changed
2005                for cookie in &self.session_cookies {
2006                    if cookie.starts_with("sessionid=") {
2007                        log::trace!("Current sessionid: {}", &cookie[10..50.min(cookie.len())]);
2008                        break;
2009                    }
2010                }
2011            }
2012        }
2013    }
2014
2015    /// Extract response body, optionally saving debug info
2016    async fn extract_response_body(&self, url: &str, response: &mut Response) -> Result<String> {
2017        let body = response
2018            .body_string()
2019            .await
2020            .map_err(|e| LastFmError::Http(e.to_string()))?;
2021
2022        if self.debug_save_responses {
2023            self.save_debug_response(url, response.status().into(), &body);
2024        }
2025
2026        Ok(body)
2027    }
2028
2029    /// Save response to debug directory (optional debug feature)
2030    fn save_debug_response(&self, url: &str, status_code: u16, body: &str) {
2031        if let Err(e) = self.try_save_debug_response(url, status_code, body) {
2032            log::warn!("Failed to save debug response: {e}");
2033        }
2034    }
2035
2036    /// Internal debug response saving implementation
2037    fn try_save_debug_response(&self, url: &str, status_code: u16, body: &str) -> Result<()> {
2038        // Create debug directory if it doesn't exist
2039        let debug_dir = Path::new("debug_responses");
2040        if !debug_dir.exists() {
2041            fs::create_dir_all(debug_dir)
2042                .map_err(|e| LastFmError::Http(format!("Failed to create debug directory: {e}")))?;
2043        }
2044
2045        // Extract the path part of the URL (after base_url)
2046        let url_path = if url.starts_with(&self.base_url) {
2047            &url[self.base_url.len()..]
2048        } else {
2049            url
2050        };
2051
2052        // Create safe filename from URL path and add timestamp
2053        let now = chrono::Utc::now();
2054        let timestamp = now.format("%Y%m%d_%H%M%S_%3f");
2055        let safe_path = url_path.replace(['/', '?', '&', '=', '%', '+'], "_");
2056
2057        let filename = format!("{timestamp}_{safe_path}_status{status_code}.html");
2058        let file_path = debug_dir.join(filename);
2059
2060        // Write response to file
2061        fs::write(&file_path, body)
2062            .map_err(|e| LastFmError::Http(format!("Failed to write debug file: {e}")))?;
2063
2064        log::debug!(
2065            "Saved HTTP response to {file_path:?} (status: {status_code}, url: {url_path})"
2066        );
2067
2068        Ok(())
2069    }
2070
2071    pub async fn get_artist_albums_page(&mut self, artist: &str, page: u32) -> Result<AlbumPage> {
2072        // Use AJAX endpoint for page content
2073        let url = format!(
2074            "{}/user/{}/library/music/{}/+albums?page={}&ajax=true",
2075            self.base_url,
2076            self.username,
2077            artist.replace(" ", "+"),
2078            page
2079        );
2080
2081        log::debug!("Fetching albums page {page} for artist: {artist}");
2082        let mut response = self.get(&url).await?;
2083        let content = response
2084            .body_string()
2085            .await
2086            .map_err(|e| LastFmError::Http(e.to_string()))?;
2087
2088        log::debug!(
2089            "AJAX response: {} status, {} chars",
2090            response.status(),
2091            content.len()
2092        );
2093
2094        // Check if we got JSON or HTML
2095        if content.trim_start().starts_with("{") || content.trim_start().starts_with("[") {
2096            log::debug!("Parsing JSON response from AJAX endpoint");
2097            self.parse_json_albums_page(&content, page, artist)
2098        } else {
2099            log::debug!("Parsing HTML response from AJAX endpoint");
2100            let document = Html::parse_document(&content);
2101            self.parse_albums_page(&document, page, artist)
2102        }
2103    }
2104
2105    fn parse_json_albums_page(
2106        &self,
2107        _json_content: &str,
2108        page_number: u32,
2109        _artist: &str,
2110    ) -> Result<AlbumPage> {
2111        // JSON parsing not yet implemented - fallback to empty page
2112        log::debug!("JSON parsing not implemented, returning empty page");
2113        Ok(AlbumPage {
2114            albums: Vec::new(),
2115            page_number,
2116            has_next_page: false,
2117            total_pages: Some(1),
2118        })
2119    }
2120
2121    fn parse_albums_page(
2122        &self,
2123        document: &Html,
2124        page_number: u32,
2125        artist: &str,
2126    ) -> Result<AlbumPage> {
2127        let mut albums = Vec::new();
2128
2129        // Try parsing album data from data attributes (AJAX response)
2130        let album_selector = Selector::parse("[data-album-name]").unwrap();
2131        let album_elements: Vec<_> = document.select(&album_selector).collect();
2132
2133        if !album_elements.is_empty() {
2134            log::debug!(
2135                "Found {} album elements with data-album-name",
2136                album_elements.len()
2137            );
2138
2139            // Use a set to track unique albums
2140            let mut seen_albums = std::collections::HashSet::new();
2141
2142            for element in album_elements {
2143                if let Some(album_name) = element.value().attr("data-album-name") {
2144                    // Skip if we've already processed this album
2145                    if seen_albums.contains(album_name) {
2146                        continue;
2147                    }
2148                    seen_albums.insert(album_name.to_string());
2149
2150                    // Find the play count for this album
2151                    let playcount = self.find_playcount_for_album(document, album_name)?;
2152
2153                    // Try to find timestamp for this album
2154                    let timestamp = self.find_timestamp_for_album(document, album_name);
2155
2156                    let album = Album {
2157                        name: album_name.to_string(),
2158                        artist: artist.to_string(),
2159                        playcount,
2160                        timestamp,
2161                    };
2162                    albums.push(album);
2163
2164                    if albums.len() >= 50 {
2165                        break; // Last.fm shows 50 albums per page
2166                    }
2167                }
2168            }
2169
2170            log::debug!("Successfully parsed {} unique albums", albums.len());
2171        } else {
2172            // Fallback to table parsing method
2173            log::debug!("No data-album-name elements found, trying table parsing");
2174
2175            let table_selector = Selector::parse("table.chartlist").unwrap();
2176            let row_selector = Selector::parse("tbody tr").unwrap();
2177
2178            if let Some(table) = document.select(&table_selector).next() {
2179                for row in table.select(&row_selector) {
2180                    if let Ok(mut album) = self.parse_album_row(&row) {
2181                        album.artist = artist.to_string();
2182                        albums.push(album);
2183                    }
2184                }
2185            } else {
2186                log::debug!("No table.chartlist found either");
2187            }
2188        }
2189
2190        // Check for pagination
2191        let (has_next_page, total_pages) = self.parse_pagination(document, page_number)?;
2192
2193        Ok(AlbumPage {
2194            albums,
2195            page_number,
2196            has_next_page,
2197            total_pages,
2198        })
2199    }
2200
2201    fn find_timestamp_for_album(&self, document: &Html, album_name: &str) -> Option<u64> {
2202        // Look for timestamp in hidden form inputs for edit scrobble forms
2203        let form_selector = Selector::parse("form[data-edit-scrobble]").unwrap();
2204        let timestamp_selector = Selector::parse("input[name=\"timestamp\"]").unwrap();
2205
2206        for form in document.select(&form_selector) {
2207            // Check if this form is for our album
2208            let album_input_selector = Selector::parse("input[name=\"album_name\"]").unwrap();
2209            if let Some(album_input) = form.select(&album_input_selector).next() {
2210                if let Some(value) = album_input.value().attr("value") {
2211                    if value == album_name {
2212                        // Found the form for our album, get the timestamp
2213                        if let Some(timestamp_input) = form.select(&timestamp_selector).next() {
2214                            if let Some(timestamp_str) = timestamp_input.value().attr("value") {
2215                                if let Ok(timestamp) = timestamp_str.parse::<u64>() {
2216                                    return Some(timestamp);
2217                                }
2218                            }
2219                        }
2220                    }
2221                }
2222            }
2223        }
2224        None
2225    }
2226
2227    fn find_playcount_for_album(&self, document: &Html, album_name: &str) -> Result<u32> {
2228        // Look for chartlist-count-bar-value elements near the album
2229        let count_selector = Selector::parse(".chartlist-count-bar-value").unwrap();
2230        let link_selector = Selector::parse("a[href*=\"/music/\"]").unwrap();
2231
2232        // Find all album links that match our album name
2233        for link in document.select(&link_selector) {
2234            let link_text = link.text().collect::<String>().trim().to_string();
2235            if link_text == album_name {
2236                // Found the album link, now look for the play count in the same row
2237                if let Some(row) = self.find_ancestor_row(link) {
2238                    // Look for play count in this row
2239                    for count_elem in row.select(&count_selector) {
2240                        let count_text = count_elem.text().collect::<String>();
2241                        if let Some(number_part) = count_text.split_whitespace().next() {
2242                            if let Ok(count) = number_part.parse::<u32>() {
2243                                return Ok(count);
2244                            }
2245                        }
2246                    }
2247                }
2248            }
2249        }
2250
2251        // Default fallback
2252        Ok(1)
2253    }
2254
2255    fn parse_album_row(&self, row: &scraper::ElementRef) -> Result<Album> {
2256        let name_selector = Selector::parse(".chartlist-name a").unwrap();
2257
2258        let name = row
2259            .select(&name_selector)
2260            .next()
2261            .map(|el| el.text().collect::<String>().trim().to_string())
2262            .ok_or_else(|| LastFmError::Parse("Missing album name".to_string()))?;
2263
2264        // Parse play count from .chartlist-count-bar-value
2265        let playcount_selector = Selector::parse(".chartlist-count-bar-value").unwrap();
2266        let mut playcount = 1; // default fallback
2267
2268        if let Some(element) = row.select(&playcount_selector).next() {
2269            let text = element.text().collect::<String>().trim().to_string();
2270            if let Some(number_part) = text.split_whitespace().next() {
2271                if let Ok(count) = number_part.parse::<u32>() {
2272                    playcount = count;
2273                }
2274            }
2275        }
2276
2277        let artist = "".to_string(); // We'll fill this in when we call the method
2278
2279        Ok(Album {
2280            name,
2281            artist,
2282            playcount,
2283            timestamp: None, // Not available in table parsing mode
2284        })
2285    }
2286}