lastfm_edit/
client.rs

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