lastfm_edit/
client.rs

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