lastfm_edit/
client.rs

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