lastfm_edit/
client.rs

1use crate::edit::{ExactScrobbleEdit, SingleEditResponse};
2use crate::edit_analysis;
3use crate::events::{
4    ClientEvent, ClientEventReceiver, RateLimitType, RequestInfo, SharedEventBroadcaster,
5};
6use crate::headers;
7use crate::login::extract_cookies_from_response;
8use crate::parsing::LastFmParser;
9use crate::r#trait::LastFmEditClient;
10use crate::retry::{self, RetryConfig};
11use crate::session::LastFmEditSession;
12use crate::{AlbumPage, EditResponse, LastFmError, Result, ScrobbleEdit, Track, TrackPage};
13use async_trait::async_trait;
14use http_client::{HttpClient, Request, Response};
15use http_types::{Method, Url};
16use scraper::{Html, Selector};
17use std::sync::{Arc, Mutex};
18
19/// Main implementation for interacting with Last.fm's web interface.
20///
21/// This implementation provides methods for browsing user libraries and editing scrobble data
22/// through web scraping. It requires a valid authenticated session to function.
23///
24#[derive(Clone)]
25pub struct LastFmEditClientImpl {
26    client: Arc<dyn HttpClient + Send + Sync>,
27    session: Arc<Mutex<LastFmEditSession>>,
28    rate_limit_patterns: Vec<String>,
29    parser: LastFmParser,
30    broadcaster: Arc<SharedEventBroadcaster>,
31}
32
33impl LastFmEditClientImpl {
34    /// Create a new [`LastFmEditClient`] from an authenticated session.
35    ///
36    /// This is the primary constructor for creating a client. You must obtain a valid
37    /// session first using the [`login`](crate::login::LoginManager::login) function.
38    ///
39    /// # Arguments
40    ///
41    /// * `client` - Any HTTP client implementation that implements [`HttpClient`]
42    /// * `session` - A valid authenticated session
43    ///
44    pub fn from_session(
45        client: Box<dyn HttpClient + Send + Sync>,
46        session: LastFmEditSession,
47    ) -> Self {
48        Self::from_session_with_arc(Arc::from(client), session)
49    }
50
51    /// Create a new [`LastFmEditClient`] from an authenticated session with Arc client.
52    ///
53    /// Internal helper method to avoid Arc/Box conversion issues.
54    fn from_session_with_arc(
55        client: Arc<dyn HttpClient + Send + Sync>,
56        session: LastFmEditSession,
57    ) -> Self {
58        Self::from_session_with_broadcaster_arc(
59            client,
60            session,
61            Arc::new(SharedEventBroadcaster::new()),
62        )
63    }
64
65    /// Create a new [`LastFmEditClient`] from an authenticated session with custom rate limit patterns.
66    ///
67    /// This is useful for testing or customizing rate limit detection.
68    ///
69    /// # Arguments
70    ///
71    /// * `client` - Any HTTP client implementation
72    /// * `session` - A valid authenticated session
73    /// * `rate_limit_patterns` - Text patterns that indicate rate limiting in responses
74    pub fn from_session_with_rate_limit_patterns(
75        client: Box<dyn HttpClient + Send + Sync>,
76        session: LastFmEditSession,
77        rate_limit_patterns: Vec<String>,
78    ) -> Self {
79        Self {
80            client: Arc::from(client),
81            session: Arc::new(Mutex::new(session)),
82            rate_limit_patterns,
83            parser: LastFmParser::new(),
84            broadcaster: Arc::new(SharedEventBroadcaster::new()),
85        }
86    }
87
88    /// Create a new authenticated [`LastFmEditClient`] by logging in with username and password.
89    ///
90    /// This is a convenience method that combines login and client creation into one step.
91    ///
92    /// # Arguments
93    ///
94    /// * `client` - Any HTTP client implementation
95    /// * `username` - Last.fm username or email
96    /// * `password` - Last.fm password
97    ///
98    /// # Returns
99    ///
100    /// Returns an authenticated client on success, or [`LastFmError::Auth`] on failure.
101    ///
102    pub async fn login_with_credentials(
103        client: Box<dyn HttpClient + Send + Sync>,
104        username: &str,
105        password: &str,
106    ) -> Result<Self> {
107        let client_arc: Arc<dyn HttpClient + Send + Sync> = Arc::from(client);
108        let login_manager =
109            crate::login::LoginManager::new(client_arc.clone(), "https://www.last.fm".to_string());
110        let session = login_manager.login(username, password).await?;
111        Ok(Self::from_session_with_arc(client_arc, session))
112    }
113
114    /// Create a new [`LastFmEditClient`] from a session with a shared broadcaster.
115    ///
116    /// This allows you to create multiple clients that share the same event broadcasting system.
117    /// When any client encounters rate limiting, all clients sharing the broadcaster will see the event.
118    ///
119    /// # Arguments
120    ///
121    /// * `client` - Any HTTP client implementation
122    /// * `session` - A valid authenticated session
123    /// * `broadcaster` - Shared broadcaster from another client
124    ///
125    /// # Returns
126    ///
127    /// Returns a client with the session and shared broadcaster.
128    fn from_session_with_broadcaster(
129        client: Box<dyn HttpClient + Send + Sync>,
130        session: LastFmEditSession,
131        broadcaster: Arc<SharedEventBroadcaster>,
132    ) -> Self {
133        Self::from_session_with_broadcaster_arc(Arc::from(client), session, broadcaster)
134    }
135
136    /// Internal helper for creating client with Arc and broadcaster
137    fn from_session_with_broadcaster_arc(
138        client: Arc<dyn HttpClient + Send + Sync>,
139        session: LastFmEditSession,
140        broadcaster: Arc<SharedEventBroadcaster>,
141    ) -> Self {
142        Self {
143            client,
144            session: Arc::new(Mutex::new(session)),
145            rate_limit_patterns: vec![
146                "you've tried to log in too many times".to_string(),
147                "you're requesting too many pages".to_string(),
148                "slow down".to_string(),
149                "too fast".to_string(),
150                "rate limit".to_string(),
151                "throttled".to_string(),
152                "temporarily blocked".to_string(),
153                "temporarily restricted".to_string(),
154                "captcha".to_string(),
155                "verify you're human".to_string(),
156                "prove you're not a robot".to_string(),
157                "security check".to_string(),
158                "service temporarily unavailable".to_string(),
159                "quota exceeded".to_string(),
160                "limit exceeded".to_string(),
161                "daily limit".to_string(),
162            ],
163            parser: LastFmParser::new(),
164            broadcaster,
165        }
166    }
167
168    /// Extract the current session state for persistence.
169    pub fn get_session(&self) -> LastFmEditSession {
170        self.session.lock().unwrap().clone()
171    }
172
173    /// Restore session state from a previously saved session.
174    pub fn restore_session(&self, session: LastFmEditSession) {
175        *self.session.lock().unwrap() = session;
176    }
177
178    /// Create a new client that shares the same session and event broadcaster.
179    ///
180    /// This is useful when you want multiple HTTP client instances but want them to
181    /// share the same authentication state and event broadcasting system.
182    ///
183    /// # Arguments
184    ///
185    /// * `client` - A new HTTP client implementation
186    ///
187    /// # Returns
188    ///
189    /// Returns a new client that shares the session and broadcaster with this client.
190    ///
191    pub fn with_shared_broadcaster(&self, client: Box<dyn HttpClient + Send + Sync>) -> Self {
192        let session = self.get_session();
193        Self::from_session_with_broadcaster(client, session, self.broadcaster.clone())
194    }
195
196    /// Get the currently authenticated username.
197    ///
198    /// Returns an empty string if not logged in.
199    pub fn username(&self) -> String {
200        self.session.lock().unwrap().username.clone()
201    }
202
203    pub async fn validate_session(&self) -> bool {
204        let test_url = {
205            let session = self.session.lock().unwrap();
206            format!(
207                "{}/settings/subscription/automatic-edits/tracks",
208                session.base_url
209            )
210        };
211
212        let mut request = Request::new(Method::Get, test_url.parse::<Url>().unwrap());
213
214        {
215            let session = self.session.lock().unwrap();
216            headers::add_cookies(&mut request, &session.cookies);
217        }
218
219        headers::add_get_headers(&mut request, false, None);
220
221        match self.client.send(request).await {
222            Ok(response) => {
223                // Check if we got redirected to login
224                if response.status() == 302 || response.status() == 301 {
225                    if let Some(location) = response.header("location") {
226                        if let Some(redirect_url) = location.get(0) {
227                            let redirect_url_str = redirect_url.as_str();
228                            let is_valid = !redirect_url_str.contains("/login");
229
230                            return is_valid;
231                        }
232                    }
233                }
234                true
235            }
236            Err(_e) => false,
237        }
238    }
239
240    /// Subscribe to internal client events.
241    pub fn subscribe(&self) -> ClientEventReceiver {
242        self.broadcaster.subscribe()
243    }
244
245    /// Get the latest client event without subscribing to future events.
246    pub fn latest_event(&self) -> Option<ClientEvent> {
247        self.broadcaster.latest_event()
248    }
249
250    /// Broadcast an internal event to all subscribers.
251    ///
252    /// This is used internally by the client to notify observers of important events.
253    fn broadcast_event(&self, event: ClientEvent) {
254        self.broadcaster.broadcast_event(event);
255    }
256
257    /// Fetch recent scrobbles from the user's listening history
258    /// This gives us real scrobble data with timestamps for editing
259    pub async fn get_recent_scrobbles(&self, page: u32) -> Result<Vec<Track>> {
260        let url = {
261            let session = self.session.lock().unwrap();
262            format!(
263                "{}/user/{}/library?page={}",
264                session.base_url, session.username, page
265            )
266        };
267
268        log::debug!("Fetching recent scrobbles page {page}");
269        let mut response = self.get(&url).await?;
270        let content = response
271            .body_string()
272            .await
273            .map_err(|e| LastFmError::Http(e.to_string()))?;
274
275        log::debug!(
276            "Recent scrobbles response: {} status, {} chars",
277            response.status(),
278            content.len()
279        );
280
281        let document = Html::parse_document(&content);
282        self.parser.parse_recent_scrobbles(&document)
283    }
284
285    /// Get a page of tracks from the user's recent listening history.
286    pub async fn get_recent_tracks_page(&self, page: u32) -> Result<TrackPage> {
287        let tracks = self.get_recent_scrobbles(page).await?;
288
289        // For now, we'll create a basic TrackPage from the tracks
290        // In a real implementation, we might need to parse pagination info from the HTML
291        let has_next_page = !tracks.is_empty(); // Simple heuristic
292
293        Ok(TrackPage {
294            tracks,
295            page_number: page,
296            has_next_page,
297            total_pages: None, // Recent tracks don't have a definite total
298        })
299    }
300
301    /// Find the most recent scrobble for a specific track
302    /// This searches through recent listening history to find real scrobble data
303    pub async fn find_recent_scrobble_for_track(
304        &self,
305        track_name: &str,
306        artist_name: &str,
307        max_pages: u32,
308    ) -> Result<Option<Track>> {
309        log::debug!("Searching for recent scrobble: '{track_name}' by '{artist_name}'");
310
311        for page in 1..=max_pages {
312            let scrobbles = self.get_recent_scrobbles(page).await?;
313
314            for scrobble in scrobbles {
315                if scrobble.name == track_name && scrobble.artist == artist_name {
316                    log::debug!(
317                        "Found recent scrobble: '{}' with timestamp {:?}",
318                        scrobble.name,
319                        scrobble.timestamp
320                    );
321                    return Ok(Some(scrobble));
322                }
323            }
324        }
325
326        log::debug!(
327            "No recent scrobble found for '{track_name}' by '{artist_name}' in {max_pages} pages"
328        );
329        Ok(None)
330    }
331
332    pub async fn edit_scrobble(&self, edit: &ScrobbleEdit) -> Result<EditResponse> {
333        // Use the generalized discovery function to find all relevant scrobble instances
334        let discovered_edits = self.discover_scrobble_edit_variations(edit).await?;
335
336        if discovered_edits.is_empty() {
337            let context = match (&edit.track_name_original, &edit.album_name_original) {
338                (Some(track_name), _) => {
339                    format!("track '{}' by '{}'", track_name, edit.artist_name_original)
340                }
341                (None, Some(album_name)) => {
342                    format!("album '{}' by '{}'", album_name, edit.artist_name_original)
343                }
344                (None, None) => format!("artist '{}'", edit.artist_name_original),
345            };
346            return Err(LastFmError::Parse(format!(
347                "No scrobbles found for {context}. Make sure the names are correct and that you have scrobbled recently."
348            )));
349        }
350
351        log::info!(
352            "Discovered {} scrobble instances to edit",
353            discovered_edits.len()
354        );
355
356        let mut all_results = Vec::new();
357
358        // For each discovered scrobble instance, apply the user's desired changes and edit it
359        for (index, discovered_edit) in discovered_edits.iter().enumerate() {
360            log::debug!(
361                "Processing scrobble {}/{}: '{}' from '{}'",
362                index + 1,
363                discovered_edits.len(),
364                discovered_edit.track_name_original,
365                discovered_edit.album_name_original
366            );
367
368            // Apply the user's desired changes to the discovered exact edit
369            let mut modified_exact_edit = discovered_edit.clone();
370
371            // Apply user's changes or keep original values
372            if let Some(new_track_name) = &edit.track_name {
373                modified_exact_edit.track_name = new_track_name.clone();
374            }
375            if let Some(new_album_name) = &edit.album_name {
376                modified_exact_edit.album_name = new_album_name.clone();
377            }
378            modified_exact_edit.artist_name = edit.artist_name.clone();
379            if let Some(new_album_artist_name) = &edit.album_artist_name {
380                modified_exact_edit.album_artist_name = new_album_artist_name.clone();
381            }
382            modified_exact_edit.edit_all = edit.edit_all;
383
384            let album_info = format!(
385                "{} by {}",
386                modified_exact_edit.album_name_original,
387                modified_exact_edit.album_artist_name_original
388            );
389
390            let single_response = self.edit_scrobble_single(&modified_exact_edit, 3).await?;
391            let success = single_response.success();
392            let message = single_response.message();
393
394            all_results.push(SingleEditResponse {
395                success,
396                message,
397                album_info: Some(album_info),
398                exact_scrobble_edit: modified_exact_edit.clone(),
399            });
400
401            // Add delay between edits to be respectful to the server
402            if index < discovered_edits.len() - 1 {
403                tokio::time::sleep(std::time::Duration::from_millis(1000)).await;
404            }
405        }
406
407        Ok(EditResponse::from_results(all_results))
408    }
409
410    /// Edit a single scrobble with retry logic, returning a single-result EditResponse.
411    ///
412    /// This method takes a fully-specified `ExactScrobbleEdit` and performs a single
413    /// edit operation. Unlike `edit_scrobble`, this method does not perform enrichment
414    /// or multiple edits - it edits exactly one scrobble instance.
415    ///
416    /// # Arguments
417    /// * `exact_edit` - A fully-specified edit with all required fields populated
418    /// * `max_retries` - Maximum number of retry attempts for rate limiting
419    ///
420    pub async fn edit_scrobble_single(
421        &self,
422        exact_edit: &ExactScrobbleEdit,
423        max_retries: u32,
424    ) -> Result<EditResponse> {
425        let config = RetryConfig {
426            max_retries,
427            base_delay: 5,
428            max_delay: 300,
429        };
430
431        let edit_clone = exact_edit.clone();
432        let client = self.clone();
433
434        match retry::retry_with_backoff(
435            config,
436            "Edit scrobble",
437            || client.edit_scrobble_impl(&edit_clone),
438            |delay, operation_name| {
439                self.broadcast_event(ClientEvent::RateLimited {
440                    delay_seconds: delay,
441                    request: None, // No specific request context in retry callback
442                    rate_limit_type: RateLimitType::ResponsePattern,
443                });
444                log::debug!("{operation_name} rate limited, waiting {delay} seconds");
445            },
446        )
447        .await
448        {
449            Ok(retry_result) => Ok(EditResponse::single(
450                retry_result.result,
451                None,
452                None,
453                exact_edit.clone(),
454            )),
455            Err(LastFmError::RateLimit { .. }) => Ok(EditResponse::single(
456                false,
457                Some(format!("Rate limit exceeded after {max_retries} retries")),
458                None,
459                exact_edit.clone(),
460            )),
461            Err(other_error) => Ok(EditResponse::single(
462                false,
463                Some(other_error.to_string()),
464                None,
465                exact_edit.clone(),
466            )),
467        }
468    }
469
470    async fn edit_scrobble_impl(&self, exact_edit: &ExactScrobbleEdit) -> Result<bool> {
471        let start_time = std::time::Instant::now();
472        let result = self.edit_scrobble_impl_internal(exact_edit).await;
473        let duration_ms = start_time.elapsed().as_millis() as u64;
474
475        // Broadcast edit attempt event
476        match &result {
477            Ok(success) => {
478                self.broadcast_event(ClientEvent::EditAttempted {
479                    edit: exact_edit.clone(),
480                    success: *success,
481                    error_message: None,
482                    duration_ms,
483                });
484            }
485            Err(error) => {
486                self.broadcast_event(ClientEvent::EditAttempted {
487                    edit: exact_edit.clone(),
488                    success: false,
489                    error_message: Some(error.to_string()),
490                    duration_ms,
491                });
492            }
493        }
494
495        result
496    }
497
498    async fn edit_scrobble_impl_internal(&self, exact_edit: &ExactScrobbleEdit) -> Result<bool> {
499        let edit_url = {
500            let session = self.session.lock().unwrap();
501            format!(
502                "{}/user/{}/library/edit?edited-variation=library-track-scrobble",
503                session.base_url, session.username
504            )
505        };
506
507        log::debug!("Getting fresh CSRF token for edit");
508
509        // First request: Get the edit form to extract fresh CSRF token
510        let form_html = self.get_edit_form_html(&edit_url).await?;
511
512        // Parse HTML to get fresh CSRF token - do parsing synchronously
513        let form_document = Html::parse_document(&form_html);
514        let fresh_csrf_token = self.extract_csrf_token(&form_document)?;
515
516        log::debug!("Submitting edit with fresh token");
517
518        let form_data = exact_edit.build_form_data(&fresh_csrf_token);
519
520        log::debug!(
521            "Editing scrobble: '{}' -> '{}'",
522            exact_edit.track_name_original,
523            exact_edit.track_name
524        );
525        {
526            let session = self.session.lock().unwrap();
527            log::trace!("Session cookies count: {}", session.cookies.len());
528        }
529
530        let mut request = Request::new(Method::Post, edit_url.parse::<Url>().unwrap());
531
532        // Add session cookies and set up headers
533        let referer_url = {
534            let session = self.session.lock().unwrap();
535            headers::add_cookies(&mut request, &session.cookies);
536            format!("{}/user/{}/library", session.base_url, session.username)
537        };
538
539        headers::add_edit_headers(&mut request, &referer_url);
540
541        // Convert form data to URL-encoded string
542        let form_string: String = form_data
543            .iter()
544            .map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v)))
545            .collect::<Vec<_>>()
546            .join("&");
547
548        request.set_body(form_string);
549
550        // Create request info for event broadcasting
551        let request_info = RequestInfo::from_url_and_method(&edit_url, "POST");
552        let request_start = std::time::Instant::now();
553
554        // Broadcast request started event
555        self.broadcast_event(ClientEvent::RequestStarted {
556            request: request_info.clone(),
557        });
558
559        let mut response = self
560            .client
561            .send(request)
562            .await
563            .map_err(|e| LastFmError::Http(e.to_string()))?;
564
565        // Broadcast request completed event
566        self.broadcast_event(ClientEvent::RequestCompleted {
567            request: request_info.clone(),
568            status_code: response.status().into(),
569            duration_ms: request_start.elapsed().as_millis() as u64,
570        });
571
572        log::debug!("Edit response status: {}", response.status());
573
574        let response_text = response
575            .body_string()
576            .await
577            .map_err(|e| LastFmError::Http(e.to_string()))?;
578
579        // Analyze the edit response to determine success/failure
580        let analysis = edit_analysis::analyze_edit_response(&response_text, response.status());
581
582        Ok(analysis.success)
583    }
584
585    /// Fetch raw HTML content for edit form page
586    /// This separates HTTP fetching from parsing to avoid Send/Sync issues
587    async fn get_edit_form_html(&self, edit_url: &str) -> Result<String> {
588        let mut form_response = self.get(edit_url).await?;
589        let form_html = form_response
590            .body_string()
591            .await
592            .map_err(|e| LastFmError::Http(e.to_string()))?;
593
594        log::debug!("Edit form response status: {}", form_response.status());
595        Ok(form_html)
596    }
597
598    /// Load prepopulated form values for editing a specific track
599    /// This extracts scrobble data directly from the track page forms
600    pub async fn load_edit_form_values_internal(
601        &self,
602        track_name: &str,
603        artist_name: &str,
604    ) -> Result<Vec<ExactScrobbleEdit>> {
605        log::debug!("Loading edit form values for '{track_name}' by '{artist_name}'");
606
607        // Get the specific track page to find scrobble forms
608        // Add +noredirect to avoid redirects as per lastfm-bulk-edit approach
609        // Use the correct URL format with underscore: artist/_/track
610        let base_track_url = {
611            let session = self.session.lock().unwrap();
612            format!(
613                "{}/user/{}/library/music/+noredirect/{}/_/{}",
614                session.base_url,
615                session.username,
616                urlencoding::encode(artist_name),
617                urlencoding::encode(track_name)
618            )
619        };
620
621        log::debug!("Fetching track page: {base_track_url}");
622
623        let mut response = self.get(&base_track_url).await?;
624        let html = response
625            .body_string()
626            .await
627            .map_err(|e| crate::LastFmError::Http(e.to_string()))?;
628
629        let document = Html::parse_document(&html);
630
631        // Handle pagination with a loop
632        let mut all_scrobble_edits = Vec::new();
633        let mut unique_albums = std::collections::HashSet::new();
634        let max_pages = 5;
635
636        // Start with the current page (page 1)
637        let page_edits = self.extract_scrobble_edits_from_page(
638            &document,
639            track_name,
640            artist_name,
641            &mut unique_albums,
642        )?;
643        all_scrobble_edits.extend(page_edits);
644
645        log::debug!(
646            "Page 1: found {} unique album variations",
647            all_scrobble_edits.len()
648        );
649
650        // Check for additional pages
651        let pagination_selector = Selector::parse(".pagination .pagination-next").unwrap();
652        let mut has_next_page = document.select(&pagination_selector).next().is_some();
653        let mut page = 2;
654
655        while has_next_page && page <= max_pages {
656            // For pagination, we need to remove +noredirect and add page parameter
657            let page_url = {
658                let session = self.session.lock().unwrap();
659                format!(
660                    "{}/user/{}/library/music/{}/_/{}?page={page}",
661                    session.base_url,
662                    session.username,
663                    urlencoding::encode(artist_name),
664                    urlencoding::encode(track_name)
665                )
666            };
667
668            log::debug!("Fetching page {page} for additional album variations");
669
670            let mut response = self.get(&page_url).await?;
671            let html = response
672                .body_string()
673                .await
674                .map_err(|e| crate::LastFmError::Http(e.to_string()))?;
675
676            let document = Html::parse_document(&html);
677
678            let page_edits = self.extract_scrobble_edits_from_page(
679                &document,
680                track_name,
681                artist_name,
682                &mut unique_albums,
683            )?;
684
685            let initial_count = all_scrobble_edits.len();
686            all_scrobble_edits.extend(page_edits);
687            let found_new_unique_albums = all_scrobble_edits.len() > initial_count;
688
689            // Check if there's another next page
690            has_next_page = document.select(&pagination_selector).next().is_some();
691
692            log::debug!(
693                "Page {page}: found {} total unique albums ({})",
694                all_scrobble_edits.len(),
695                if found_new_unique_albums {
696                    "new albums found"
697                } else {
698                    "no new unique albums"
699                }
700            );
701
702            // Continue to next page even if no new unique albums found on this page,
703            // as long as there are more pages available
704            page += 1;
705        }
706
707        if all_scrobble_edits.is_empty() {
708            return Err(crate::LastFmError::Parse(format!(
709                "No scrobble forms found for track '{track_name}' by '{artist_name}'"
710            )));
711        }
712
713        log::debug!(
714            "Final result: found {} unique album variations for '{track_name}' by '{artist_name}'",
715            all_scrobble_edits.len(),
716        );
717
718        Ok(all_scrobble_edits)
719    }
720
721    /// Extract scrobble edit data directly from track page forms. Based on the
722    /// approach used in lastfm-bulk-edit
723    fn extract_scrobble_edits_from_page(
724        &self,
725        document: &Html,
726        expected_track: &str,
727        expected_artist: &str,
728        unique_albums: &mut std::collections::HashSet<(String, String)>,
729    ) -> Result<Vec<ExactScrobbleEdit>> {
730        let mut scrobble_edits = Vec::new();
731        // Look for the chartlist table that contains scrobbles
732        let table_selector =
733            Selector::parse("table.chartlist:not(.chartlist__placeholder)").unwrap();
734        let table = document.select(&table_selector).next().ok_or_else(|| {
735            crate::LastFmError::Parse("No chartlist table found on track page".to_string())
736        })?;
737
738        // Look for table rows that contain scrobble edit forms
739        let row_selector = Selector::parse("tr").unwrap();
740        for row in table.select(&row_selector) {
741            // Check if this row has a count bar link (means it's an aggregation, not individual scrobbles)
742            let count_bar_link_selector = Selector::parse(".chartlist-count-bar-link").unwrap();
743            if row.select(&count_bar_link_selector).next().is_some() {
744                log::debug!("Found count bar link, skipping aggregated row");
745                continue;
746            }
747
748            // Look for scrobble edit form in this row
749            let form_selector = Selector::parse("form[data-edit-scrobble]").unwrap();
750            if let Some(form) = row.select(&form_selector).next() {
751                // Extract all form values directly
752                let extract_form_value = |name: &str| -> Option<String> {
753                    let selector = Selector::parse(&format!("input[name='{name}']")).unwrap();
754                    form.select(&selector)
755                        .next()
756                        .and_then(|input| input.value().attr("value"))
757                        .map(|s| s.to_string())
758                };
759
760                // Get the track and artist from this form
761                let form_track = extract_form_value("track_name").unwrap_or_default();
762                let form_artist = extract_form_value("artist_name").unwrap_or_default();
763                let form_album = extract_form_value("album_name").unwrap_or_default();
764                let form_album_artist =
765                    extract_form_value("album_artist_name").unwrap_or_else(|| form_artist.clone());
766                let form_timestamp = extract_form_value("timestamp").unwrap_or_default();
767
768                log::debug!(
769                    "Found scrobble form - Track: '{form_track}', Artist: '{form_artist}', Album: '{form_album}', Timestamp: {form_timestamp}"
770                );
771
772                // Check if this form matches the expected track and artist
773                if form_track == expected_track && form_artist == expected_artist {
774                    // Create a unique key for this album/album_artist combination
775                    let album_key = (form_album.clone(), form_album_artist.clone());
776                    if unique_albums.insert(album_key) {
777                        // Parse timestamp - skip entries without valid timestamps for ExactScrobbleEdit
778                        let timestamp = if form_timestamp.is_empty() {
779                            None
780                        } else {
781                            form_timestamp.parse::<u64>().ok()
782                        };
783
784                        if let Some(timestamp) = timestamp {
785                            log::debug!(
786                                "✅ Found unique album variation: '{form_album}' by '{form_album_artist}' for '{expected_track}' by '{expected_artist}'"
787                            );
788
789                            // Create ExactScrobbleEdit with all fields specified
790                            let scrobble_edit = ExactScrobbleEdit::new(
791                                form_track.clone(),
792                                form_album.clone(),
793                                form_artist.clone(),
794                                form_album_artist.clone(),
795                                form_track,
796                                form_album,
797                                form_artist,
798                                form_album_artist,
799                                timestamp,
800                                true,
801                            );
802                            scrobble_edits.push(scrobble_edit);
803                        } else {
804                            log::debug!(
805                                "⚠️ Skipping album variation without valid timestamp: '{form_album}' by '{form_album_artist}'"
806                            );
807                        }
808                    }
809                }
810            }
811        }
812
813        Ok(scrobble_edits)
814    }
815
816    pub async fn get_artist_tracks_page(&self, artist: &str, page: u32) -> Result<TrackPage> {
817        // Use AJAX endpoint for page content
818        let url = {
819            let session = self.session.lock().unwrap();
820            format!(
821                "{}/user/{}/library/music/{}/+tracks?page={}&ajax=true",
822                session.base_url,
823                session.username,
824                artist.replace(" ", "+"),
825                page
826            )
827        };
828
829        log::debug!("Fetching tracks page {page} for artist: {artist}");
830        let mut response = self.get(&url).await?;
831        let content = response
832            .body_string()
833            .await
834            .map_err(|e| LastFmError::Http(e.to_string()))?;
835
836        log::debug!(
837            "AJAX response: {} status, {} chars",
838            response.status(),
839            content.len()
840        );
841
842        log::debug!("Parsing HTML response from AJAX endpoint");
843        let document = Html::parse_document(&content);
844        self.parser.parse_tracks_page(&document, page, artist, None)
845    }
846
847    /// Extract tracks from HTML document (delegates to parser)
848    pub fn extract_tracks_from_document(
849        &self,
850        document: &Html,
851        artist: &str,
852        album: Option<&str>,
853    ) -> Result<Vec<Track>> {
854        self.parser
855            .extract_tracks_from_document(document, artist, album)
856    }
857
858    /// Parse tracks page (delegates to parser)
859    pub fn parse_tracks_page(
860        &self,
861        document: &Html,
862        page_number: u32,
863        artist: &str,
864        album: Option<&str>,
865    ) -> Result<TrackPage> {
866        self.parser
867            .parse_tracks_page(document, page_number, artist, album)
868    }
869
870    /// Parse recent scrobbles from HTML document (for testing)
871    pub fn parse_recent_scrobbles(&self, document: &Html) -> Result<Vec<Track>> {
872        self.parser.parse_recent_scrobbles(document)
873    }
874
875    fn extract_csrf_token(&self, document: &Html) -> Result<String> {
876        let csrf_selector = Selector::parse("input[name=\"csrfmiddlewaretoken\"]").unwrap();
877
878        document
879            .select(&csrf_selector)
880            .next()
881            .and_then(|input| input.value().attr("value"))
882            .map(|token| token.to_string())
883            .ok_or(LastFmError::CsrfNotFound)
884    }
885
886    /// Make an HTTP GET request with authentication and retry logic
887    pub async fn get(&self, url: &str) -> Result<Response> {
888        self.get_with_retry(url, 3).await
889    }
890
891    /// Make an HTTP GET request with retry logic for rate limits
892    async fn get_with_retry(&self, url: &str, max_retries: u32) -> Result<Response> {
893        let config = RetryConfig {
894            max_retries,
895            base_delay: 30, // Longer base delay for GET requests
896            max_delay: 300,
897        };
898
899        let url_string = url.to_string();
900        let client = self.clone();
901
902        let retry_result = retry::retry_with_backoff(
903            config,
904            &format!("GET {url}"),
905            || async {
906                let mut response = client.get_with_redirects(&url_string, 0).await?;
907
908                // Extract body and save debug info if enabled
909                let body = client
910                    .extract_response_body(&url_string, &mut response)
911                    .await?;
912
913                // Check for rate limit patterns in successful responses
914                if response.status().is_success() && client.is_rate_limit_response(&body) {
915                    log::debug!("Response body contains rate limit patterns");
916                    return Err(LastFmError::RateLimit { retry_after: 60 });
917                }
918
919                // Recreate response with the body we extracted
920                let mut new_response = http_types::Response::new(response.status());
921                for (name, values) in response.iter() {
922                    for value in values {
923                        let _ = new_response.insert_header(name.clone(), value.clone());
924                    }
925                }
926                new_response.set_body(body);
927
928                Ok(new_response)
929            },
930            |delay, operation_name| {
931                self.broadcast_event(ClientEvent::RateLimited {
932                    delay_seconds: delay,
933                    request: None, // No specific request context in retry callback
934                    rate_limit_type: RateLimitType::ResponsePattern,
935                });
936                log::debug!("{operation_name} rate limited, waiting {delay} seconds");
937            },
938        )
939        .await?;
940
941        Ok(retry_result.result)
942    }
943
944    async fn get_with_redirects(&self, url: &str, redirect_count: u32) -> Result<Response> {
945        if redirect_count > 5 {
946            return Err(LastFmError::Http("Too many redirects".to_string()));
947        }
948
949        let mut request = Request::new(Method::Get, url.parse::<Url>().unwrap());
950
951        // Add session cookies for all authenticated requests
952        {
953            let session = self.session.lock().unwrap();
954            headers::add_cookies(&mut request, &session.cookies);
955            if session.cookies.is_empty() && url.contains("page=") {
956                log::debug!("No cookies available for paginated request!");
957            }
958        }
959
960        let is_ajax = url.contains("ajax=true");
961        let referer_url = if url.contains("page=") {
962            Some(url.split('?').next().unwrap_or(url))
963        } else {
964            None
965        };
966
967        headers::add_get_headers(&mut request, is_ajax, referer_url);
968
969        let request_info = RequestInfo::from_url_and_method(url, "GET");
970        let request_start = std::time::Instant::now();
971
972        self.broadcast_event(ClientEvent::RequestStarted {
973            request: request_info.clone(),
974        });
975
976        let response = self
977            .client
978            .send(request)
979            .await
980            .map_err(|e| LastFmError::Http(e.to_string()))?;
981
982        self.broadcast_event(ClientEvent::RequestCompleted {
983            request: request_info.clone(),
984            status_code: response.status().into(),
985            duration_ms: request_start.elapsed().as_millis() as u64,
986        });
987
988        // Extract any new cookies from the response
989        self.extract_cookies(&response);
990
991        // Handle redirects manually
992        if response.status() == 302 || response.status() == 301 {
993            if let Some(location) = response.header("location") {
994                if let Some(redirect_url) = location.get(0) {
995                    let redirect_url_str = redirect_url.as_str();
996                    if url.contains("page=") {
997                        log::debug!("Following redirect from {url} to {redirect_url_str}");
998
999                        // Check if this is a redirect to login - authentication issue
1000                        if redirect_url_str.contains("/login") {
1001                            log::debug!("Redirect to login page - authentication failed for paginated request");
1002                            return Err(LastFmError::Auth(
1003                                "Session expired or invalid for paginated request".to_string(),
1004                            ));
1005                        }
1006                    }
1007
1008                    // Handle relative URLs
1009                    let full_redirect_url = if redirect_url_str.starts_with('/') {
1010                        let base_url = self.session.lock().unwrap().base_url.clone();
1011                        format!("{base_url}{redirect_url_str}")
1012                    } else if redirect_url_str.starts_with("http") {
1013                        redirect_url_str.to_string()
1014                    } else {
1015                        // Relative to current path
1016                        let base_url = url
1017                            .rsplit('/')
1018                            .skip(1)
1019                            .collect::<Vec<_>>()
1020                            .into_iter()
1021                            .rev()
1022                            .collect::<Vec<_>>()
1023                            .join("/");
1024                        format!("{base_url}/{redirect_url_str}")
1025                    };
1026
1027                    // Make a new request to the redirect URL
1028                    return Box::pin(
1029                        self.get_with_redirects(&full_redirect_url, redirect_count + 1),
1030                    )
1031                    .await;
1032                }
1033            }
1034        }
1035
1036        // Handle explicit rate limit responses
1037        if response.status() == 429 {
1038            let retry_after = response
1039                .header("retry-after")
1040                .and_then(|h| h.get(0))
1041                .and_then(|v| v.as_str().parse::<u64>().ok())
1042                .unwrap_or(60);
1043            self.broadcast_event(ClientEvent::RateLimited {
1044                delay_seconds: retry_after,
1045                request: Some(request_info.clone()),
1046                rate_limit_type: RateLimitType::Http429,
1047            });
1048            return Err(LastFmError::RateLimit { retry_after });
1049        }
1050
1051        // Check for 403 responses that might be rate limits
1052        if response.status() == 403 {
1053            log::debug!("Got 403 response, checking if it's a rate limit");
1054            // For now, treat 403s from authenticated endpoints as potential rate limits
1055            {
1056                let session = self.session.lock().unwrap();
1057                if !session.cookies.is_empty() {
1058                    log::debug!("403 on authenticated request - likely rate limit");
1059                    self.broadcast_event(ClientEvent::RateLimited {
1060                        delay_seconds: 60,
1061                        request: Some(request_info.clone()),
1062                        rate_limit_type: RateLimitType::Http403,
1063                    });
1064                    return Err(LastFmError::RateLimit { retry_after: 60 });
1065                }
1066            }
1067        }
1068
1069        Ok(response)
1070    }
1071
1072    /// Check if a response body indicates rate limiting
1073    fn is_rate_limit_response(&self, response_body: &str) -> bool {
1074        let body_lower = response_body.to_lowercase();
1075
1076        // Check against configured rate limit patterns
1077        for pattern in &self.rate_limit_patterns {
1078            if body_lower.contains(&pattern.to_lowercase()) {
1079                return true;
1080            }
1081        }
1082
1083        false
1084    }
1085
1086    fn extract_cookies(&self, response: &Response) {
1087        let mut session = self.session.lock().unwrap();
1088        extract_cookies_from_response(response, &mut session.cookies);
1089    }
1090
1091    /// Extract response body, optionally saving debug info
1092    async fn extract_response_body(&self, _url: &str, response: &mut Response) -> Result<String> {
1093        let body = response
1094            .body_string()
1095            .await
1096            .map_err(|e| LastFmError::Http(e.to_string()))?;
1097
1098        Ok(body)
1099    }
1100
1101    pub async fn get_artist_albums_page(&self, artist: &str, page: u32) -> Result<AlbumPage> {
1102        // Use AJAX endpoint for page content
1103        let url = {
1104            let session = self.session.lock().unwrap();
1105            format!(
1106                "{}/user/{}/library/music/{}/+albums?page={}&ajax=true",
1107                session.base_url,
1108                session.username,
1109                artist.replace(" ", "+"),
1110                page
1111            )
1112        };
1113
1114        log::debug!("Fetching albums page {page} for artist: {artist}");
1115        let mut response = self.get(&url).await?;
1116        let content = response
1117            .body_string()
1118            .await
1119            .map_err(|e| LastFmError::Http(e.to_string()))?;
1120
1121        log::debug!(
1122            "AJAX response: {} status, {} chars",
1123            response.status(),
1124            content.len()
1125        );
1126
1127        log::debug!("Parsing HTML response from AJAX endpoint");
1128        let document = Html::parse_document(&content);
1129        self.parser.parse_albums_page(&document, page, artist)
1130    }
1131}
1132
1133#[async_trait(?Send)]
1134impl LastFmEditClient for LastFmEditClientImpl {
1135    fn username(&self) -> String {
1136        self.username()
1137    }
1138
1139    async fn get_recent_scrobbles(&self, page: u32) -> Result<Vec<Track>> {
1140        self.get_recent_scrobbles(page).await
1141    }
1142
1143    async fn find_recent_scrobble_for_track(
1144        &self,
1145        track_name: &str,
1146        artist_name: &str,
1147        max_pages: u32,
1148    ) -> Result<Option<Track>> {
1149        self.find_recent_scrobble_for_track(track_name, artist_name, max_pages)
1150            .await
1151    }
1152
1153    async fn edit_scrobble(&self, edit: &ScrobbleEdit) -> Result<EditResponse> {
1154        self.edit_scrobble(edit).await
1155    }
1156
1157    async fn edit_scrobble_single(
1158        &self,
1159        exact_edit: &ExactScrobbleEdit,
1160        max_retries: u32,
1161    ) -> Result<EditResponse> {
1162        self.edit_scrobble_single(exact_edit, max_retries).await
1163    }
1164
1165    fn get_session(&self) -> LastFmEditSession {
1166        self.get_session()
1167    }
1168
1169    fn restore_session(&self, session: LastFmEditSession) {
1170        self.restore_session(session)
1171    }
1172
1173    fn subscribe(&self) -> ClientEventReceiver {
1174        self.subscribe()
1175    }
1176
1177    fn latest_event(&self) -> Option<ClientEvent> {
1178        self.latest_event()
1179    }
1180
1181    fn discover_scrobbles(
1182        &self,
1183        edit: ScrobbleEdit,
1184    ) -> Box<dyn crate::AsyncDiscoveryIterator<crate::ExactScrobbleEdit>> {
1185        let track_name = edit.track_name_original.clone();
1186        let album_name = edit.album_name_original.clone();
1187
1188        match (&track_name, &album_name) {
1189            // Case 1: Track+Album specified - exact match lookup
1190            (Some(track_name), Some(album_name)) => Box::new(crate::ExactMatchDiscovery::new(
1191                self.clone(),
1192                edit,
1193                track_name.clone(),
1194                album_name.clone(),
1195            )),
1196
1197            // Case 2: Track-specific discovery (discover all album variations of a specific track)
1198            (Some(track_name), None) => Box::new(crate::TrackVariationsDiscovery::new(
1199                self.clone(),
1200                edit,
1201                track_name.clone(),
1202            )),
1203
1204            // Case 3: Album-specific discovery (discover all tracks in a specific album)
1205            (None, Some(album_name)) => Box::new(crate::AlbumTracksDiscovery::new(
1206                self.clone(),
1207                edit,
1208                album_name.clone(),
1209            )),
1210
1211            // Case 4: Artist-specific discovery (discover all tracks by an artist)
1212            (None, None) => Box::new(crate::ArtistTracksDiscovery::new(self.clone(), edit)),
1213        }
1214    }
1215
1216    async fn get_artist_tracks_page(&self, artist: &str, page: u32) -> Result<TrackPage> {
1217        self.get_artist_tracks_page(artist, page).await
1218    }
1219
1220    async fn get_artist_albums_page(&self, artist: &str, page: u32) -> Result<AlbumPage> {
1221        self.get_artist_albums_page(artist, page).await
1222    }
1223
1224    fn artist_tracks(&self, artist: &str) -> crate::ArtistTracksIterator {
1225        crate::ArtistTracksIterator::new(self.clone(), artist.to_string())
1226    }
1227
1228    fn artist_albums(&self, artist: &str) -> crate::ArtistAlbumsIterator {
1229        crate::ArtistAlbumsIterator::new(self.clone(), artist.to_string())
1230    }
1231
1232    fn album_tracks(&self, album_name: &str, artist_name: &str) -> crate::AlbumTracksIterator {
1233        crate::AlbumTracksIterator::new(
1234            self.clone(),
1235            album_name.to_string(),
1236            artist_name.to_string(),
1237        )
1238    }
1239
1240    fn recent_tracks(&self) -> crate::RecentTracksIterator {
1241        crate::RecentTracksIterator::new(self.clone())
1242    }
1243
1244    fn recent_tracks_from_page(&self, starting_page: u32) -> crate::RecentTracksIterator {
1245        crate::RecentTracksIterator::with_starting_page(self.clone(), starting_page)
1246    }
1247
1248    async fn validate_session(&self) -> bool {
1249        self.validate_session().await
1250    }
1251}