lastfm_edit/
client.rs

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