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