lastfm_edit/
client.rs

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