lastfm_edit/
client.rs

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