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