Skip to main content

lastfm_edit/
client.rs

1use crate::edit_analysis;
2use crate::headers;
3use crate::login::extract_cookies_from_response;
4use crate::parsing::LastFmParser;
5use crate::r#trait::{LastFmBaseClient, LastFmEditClient};
6use crate::retry;
7use crate::types::{
8    AlbumPage, ArtistPage, ClientConfig, ClientEvent, ClientEventReceiver, DelayReason,
9    EditResponse, ExactScrobbleEdit, LastFmEditSession, LastFmError, RateLimitBehavior,
10    RateLimitConfig, RateLimitType, RequestInfo, RetryConfig, ScrobbleEdit, SharedEventBroadcaster,
11    SingleEditResponse, Track, TrackPage,
12};
13use crate::Result;
14use crate::{cancel, CancellationState};
15use async_trait::async_trait;
16use http_client::{HttpClient, Request, Response};
17use http_types::{Method, Url};
18use scraper::{Html, Selector};
19use std::sync::{Arc, Mutex};
20
21#[derive(Clone)]
22pub struct LastFmEditClientImpl {
23    client: Arc<dyn HttpClient + Send + Sync>,
24    session: Arc<Mutex<LastFmEditSession>>,
25    parser: LastFmParser,
26    broadcaster: Arc<SharedEventBroadcaster>,
27    config: ClientConfig,
28    cancel: CancellationState,
29    api_key: Option<String>,
30}
31
32/// Converts panics inside the wrapped client's `send` into `http_types::Error`s.
33///
34/// Some backends panic on malformed upstream data — e.g. http-client's isahc adapter
35/// calls `http_types::Response::new(status_u16)`, which panics for any status code
36/// outside the `http_types::StatusCode` enum. Such a panic unwinds through whatever
37/// task drove the request, which can silently kill long-running callers.
38#[derive(Debug)]
39struct PanicGuardClient(Box<dyn HttpClient + Send + Sync>);
40
41#[async_trait]
42impl HttpClient for PanicGuardClient {
43    async fn send(&self, req: Request) -> std::result::Result<Response, http_types::Error> {
44        use futures::FutureExt;
45        match std::panic::AssertUnwindSafe(self.0.send(req))
46            .catch_unwind()
47            .await
48        {
49            Ok(result) => result,
50            Err(panic) => {
51                let msg = panic
52                    .downcast_ref::<&str>()
53                    .map(|s| (*s).to_string())
54                    .or_else(|| panic.downcast_ref::<String>().cloned())
55                    .unwrap_or_else(|| "non-string panic payload".to_string());
56                Err(http_types::Error::from_str(
57                    http_types::StatusCode::InternalServerError,
58                    format!("HTTP client panicked: {msg}"),
59                ))
60            }
61        }
62    }
63}
64
65/// Wrap a caller-supplied HTTP client so backend panics surface as request errors.
66fn guard_client(client: Box<dyn HttpClient + Send + Sync>) -> Arc<dyn HttpClient + Send + Sync> {
67    Arc::new(PanicGuardClient(client))
68}
69
70/// True when a last.fm page was rendered for a logged-out visitor.
71///
72/// Logged-out library pages still render the chartlist, but omit the
73/// `form[data-edit-scrobble]` elements and link actions through `/login?next=...`
74/// instead. A missing-forms parse failure on such a page actually means the session
75/// has expired, which callers need to distinguish from a page-shape change.
76fn page_indicates_logged_out(html: &str) -> bool {
77    html.contains("/login?next=")
78}
79
80impl LastFmEditClientImpl {
81    /// Custom URL encoding for Last.fm paths
82    fn lastfm_encode(&self, input: &str) -> String {
83        urlencoding::encode(input).to_string()
84    }
85
86    pub fn from_session(
87        client: Box<dyn HttpClient + Send + Sync>,
88        session: LastFmEditSession,
89    ) -> Self {
90        Self::from_session_with_arc(guard_client(client), session)
91    }
92
93    fn from_session_with_arc(
94        client: Arc<dyn HttpClient + Send + Sync>,
95        session: LastFmEditSession,
96    ) -> Self {
97        Self::from_session_with_broadcaster_arc(
98            client,
99            session,
100            Arc::new(SharedEventBroadcaster::new()),
101        )
102    }
103
104    pub fn from_session_with_rate_limit_patterns(
105        client: Box<dyn HttpClient + Send + Sync>,
106        session: LastFmEditSession,
107        rate_limit_patterns: Vec<String>,
108    ) -> Self {
109        let config = ClientConfig::default()
110            .with_rate_limit_config(RateLimitConfig::default().with_patterns(rate_limit_patterns));
111        Self::from_session_with_client_config(client, session, config)
112    }
113
114    pub async fn login_with_credentials(
115        client: Box<dyn HttpClient + Send + Sync>,
116        username: &str,
117        password: &str,
118    ) -> Result<Self> {
119        let client_arc: Arc<dyn HttpClient + Send + Sync> = guard_client(client);
120        let login_manager =
121            crate::login::LoginManager::new(client_arc.clone(), "https://www.last.fm".to_string());
122        let session = login_manager.login(username, password).await?;
123        Ok(Self::from_session_with_arc(client_arc, session))
124    }
125
126    pub fn from_session_with_client_config(
127        client: Box<dyn HttpClient + Send + Sync>,
128        session: LastFmEditSession,
129        config: ClientConfig,
130    ) -> Self {
131        Self::from_session_with_client_config_arc(guard_client(client), session, config)
132    }
133
134    pub async fn login_with_credentials_and_client_config(
135        client: Box<dyn HttpClient + Send + Sync>,
136        username: &str,
137        password: &str,
138        config: ClientConfig,
139    ) -> Result<Self> {
140        let client_arc: Arc<dyn HttpClient + Send + Sync> = guard_client(client);
141        let login_manager =
142            crate::login::LoginManager::new(client_arc.clone(), "https://www.last.fm".to_string());
143        let session = login_manager.login(username, password).await?;
144        Ok(Self::from_session_with_client_config_arc(
145            client_arc, session, config,
146        ))
147    }
148
149    pub fn from_session_with_config(
150        client: Box<dyn HttpClient + Send + Sync>,
151        session: LastFmEditSession,
152        retry_config: RetryConfig,
153        rate_limit_config: RateLimitConfig,
154    ) -> Self {
155        Self::from_session_with_config_arc(
156            guard_client(client),
157            session,
158            retry_config,
159            rate_limit_config,
160        )
161    }
162
163    pub async fn login_with_credentials_and_config(
164        client: Box<dyn HttpClient + Send + Sync>,
165        username: &str,
166        password: &str,
167        retry_config: RetryConfig,
168        rate_limit_config: RateLimitConfig,
169    ) -> Result<Self> {
170        let client_arc: Arc<dyn HttpClient + Send + Sync> = guard_client(client);
171        let login_manager =
172            crate::login::LoginManager::new(client_arc.clone(), "https://www.last.fm".to_string());
173        let session = login_manager.login(username, password).await?;
174        Ok(Self::from_session_with_config_arc(
175            client_arc,
176            session,
177            retry_config,
178            rate_limit_config,
179        ))
180    }
181
182    fn from_session_with_broadcaster(
183        client: Box<dyn HttpClient + Send + Sync>,
184        session: LastFmEditSession,
185        broadcaster: Arc<SharedEventBroadcaster>,
186    ) -> Self {
187        Self::from_session_with_broadcaster_arc(guard_client(client), session, broadcaster)
188    }
189
190    fn from_session_with_client_config_arc(
191        client: Arc<dyn HttpClient + Send + Sync>,
192        session: LastFmEditSession,
193        config: ClientConfig,
194    ) -> Self {
195        Self::from_session_with_client_config_and_broadcaster_arc(
196            client,
197            session,
198            config,
199            Arc::new(SharedEventBroadcaster::new()),
200        )
201    }
202
203    fn from_session_with_config_arc(
204        client: Arc<dyn HttpClient + Send + Sync>,
205        session: LastFmEditSession,
206        retry_config: RetryConfig,
207        rate_limit_config: RateLimitConfig,
208    ) -> Self {
209        let config = ClientConfig {
210            retry: retry_config,
211            rate_limit: rate_limit_config,
212            ..Default::default()
213        };
214        Self::from_session_with_client_config_arc(client, session, config)
215    }
216
217    fn from_session_with_broadcaster_arc(
218        client: Arc<dyn HttpClient + Send + Sync>,
219        session: LastFmEditSession,
220        broadcaster: Arc<SharedEventBroadcaster>,
221    ) -> Self {
222        Self::from_session_with_client_config_and_broadcaster_arc(
223            client,
224            session,
225            ClientConfig::default(),
226            broadcaster,
227        )
228    }
229
230    fn from_session_with_client_config_and_broadcaster_arc(
231        client: Arc<dyn HttpClient + Send + Sync>,
232        session: LastFmEditSession,
233        config: ClientConfig,
234        broadcaster: Arc<SharedEventBroadcaster>,
235    ) -> Self {
236        let api_key = config.api_key.clone();
237        Self {
238            client,
239            session: Arc::new(Mutex::new(session)),
240            parser: LastFmParser::new(),
241            broadcaster,
242            config,
243            cancel: CancellationState::new(),
244            api_key,
245        }
246    }
247
248    pub fn get_session(&self) -> LastFmEditSession {
249        self.session.lock().unwrap().clone()
250    }
251
252    pub fn cancel(&self) {
253        self.cancel.cancel();
254    }
255
256    pub fn reset_cancel(&self) {
257        self.cancel.reset();
258    }
259
260    pub fn is_cancelled(&self) -> bool {
261        self.cancel.is_cancelled()
262    }
263
264    fn cancel_rx(&self) -> tokio::sync::watch::Receiver<bool> {
265        self.cancel.subscribe()
266    }
267
268    async fn sleep_ms(&self, delay_ms: u64) -> Result<()> {
269        if delay_ms == 0 {
270            return Ok(());
271        }
272        cancel::sleep_with_cancel(self.cancel_rx(), std::time::Duration::from_millis(delay_ms))
273            .await
274    }
275
276    pub fn with_shared_broadcaster(&self, client: Box<dyn HttpClient + Send + Sync>) -> Self {
277        let session = self.get_session();
278        Self::from_session_with_broadcaster(client, session, self.broadcaster.clone())
279    }
280
281    /// Get a non-blocking clone of this client that returns errors instead of sleeping when
282    /// rate limited.
283    ///
284    /// The returned client shares this client's HTTP client, session, event broadcaster, and
285    /// cancellation state (all `Arc`/channel-shared on plain `clone()`); only its
286    /// [`ClientConfig::rate_limit_behavior`] differs, set to [`RateLimitBehavior::ReturnError`].
287    /// Any events it emits (including `RateLimited`) are visible to subscribers of the
288    /// original client, and both clients report the same [`rate_limit_state`](Self::rate_limit_state).
289    ///
290    /// # Intended usage: caller-managed queues
291    ///
292    /// Instead of the client parking internally with backoff sleeps, operations on the
293    /// non-blocking clone fail fast with [`LastFmError::RateLimit`]. A caller building its own
294    /// work queue can:
295    ///
296    /// 1. Attempt an operation; on `Err(LastFmError::RateLimit { .. })`, requeue it.
297    /// 2. Watch [`watch_rate_limit_state`](Self::watch_rate_limit_state) for the
298    ///    [`RateLimitState::RateLimited`](crate::RateLimitState) "paused until T" estimate.
299    /// 3. Schedule its own retry once the state returns to
300    ///    [`RateLimitState::Ready`](crate::RateLimitState) (or `until_estimate` passes).
301    pub fn non_blocking(&self) -> Self {
302        let mut clone = self.clone();
303        clone.config.rate_limit_behavior = RateLimitBehavior::ReturnError;
304        clone
305    }
306
307    pub fn username(&self) -> String {
308        self.session.lock().unwrap().username.clone()
309    }
310
311    pub async fn validate_session(&self) -> bool {
312        let test_url = {
313            let session = self.session.lock().unwrap();
314            format!(
315                "{}/settings/subscription/automatic-edits/tracks",
316                session.base_url
317            )
318        };
319
320        let mut request = Request::new(Method::Get, test_url.parse::<Url>().unwrap());
321
322        {
323            let session = self.session.lock().unwrap();
324            headers::add_cookies(&mut request, &session.cookies);
325        }
326
327        headers::add_get_headers(&mut request, false, None);
328
329        match self.client.send(request).await {
330            Ok(response) => {
331                if response.status() == 302 || response.status() == 301 {
332                    if let Some(location) = response.header("location") {
333                        if let Some(redirect_url) = location.get(0) {
334                            let redirect_url_str = redirect_url.as_str();
335                            let is_valid = !redirect_url_str.contains("/login");
336
337                            return is_valid;
338                        }
339                    }
340                }
341                true
342            }
343            Err(_e) => false,
344        }
345    }
346
347    pub async fn delete_scrobble(
348        &self,
349        artist_name: &str,
350        track_name: &str,
351        timestamp: u64,
352    ) -> Result<bool> {
353        // Non-blocking mode: single attempt, no internal sleeping/retrying. Rate limits are
354        // PROPAGATED as `Err(LastFmError::RateLimit { .. })` (instead of being folded into
355        // `Ok(false)` by the retry path) so queue-building callers can reschedule the delete.
356        if self.config.rate_limit_behavior == RateLimitBehavior::ReturnError {
357            return self
358                .delete_scrobble_impl(artist_name, track_name, timestamp)
359                .await;
360        }
361
362        if !self.config.retry.enabled {
363            return self
364                .delete_scrobble_impl(artist_name, track_name, timestamp)
365                .await;
366        }
367
368        let config = self.config.retry.clone();
369
370        let artist_name = artist_name.to_string();
371        let track_name = track_name.to_string();
372        let client = self.clone();
373
374        match retry::retry_with_backoff_cancelable(
375            config,
376            "Delete scrobble",
377            || client.delete_scrobble_impl(&artist_name, &track_name, timestamp),
378            |delay, rate_limit_timestamp, operation_name| {
379                self.broadcast_event(ClientEvent::RateLimited {
380                    delay_seconds: delay,
381                    request: None,
382                    rate_limit_type: RateLimitType::ResponsePattern,
383                    rate_limit_timestamp,
384                });
385                self.broadcast_event(ClientEvent::Delaying {
386                    delay_ms: delay * 1000,
387                    reason: DelayReason::RetryBackoff,
388                    request: None,
389                    delay_timestamp: rate_limit_timestamp,
390                });
391                log::debug!("{operation_name} rate limited, waiting {delay} seconds");
392            },
393            |total_duration, _operation_name| {
394                self.broadcast_event(ClientEvent::RateLimitEnded {
395                    request: crate::types::RequestInfo::from_url_and_method(
396                        &format!("delete_scrobble/{artist_name}/{track_name}/{timestamp}"),
397                        "POST",
398                    ),
399                    rate_limit_type: RateLimitType::ResponsePattern,
400                    total_rate_limit_duration_seconds: total_duration,
401                });
402            },
403            Some(self.cancel_rx()),
404        )
405        .await
406        {
407            Ok(retry_result) => Ok(retry_result.result),
408            Err(_) => Ok(false),
409        }
410    }
411
412    async fn delete_scrobble_impl(
413        &self,
414        artist_name: &str,
415        track_name: &str,
416        timestamp: u64,
417    ) -> Result<bool> {
418        let delete_url = {
419            let session = self.session.lock().unwrap();
420            format!(
421                "{}/user/{}/library/delete",
422                session.base_url, session.username
423            )
424        };
425
426        log::debug!("Getting fresh CSRF token for delete");
427        let library_url = {
428            let session = self.session.lock().unwrap();
429            format!("{}/user/{}/library", session.base_url, session.username)
430        };
431
432        let mut response = self.get(&library_url).await?;
433        let content = response
434            .body_string()
435            .await
436            .map_err(|e| LastFmError::Http(e.to_string()))?;
437
438        let document = Html::parse_document(&content);
439        let fresh_csrf_token = self.extract_csrf_token(&document)?;
440
441        log::debug!("Submitting delete request with fresh token");
442
443        let mut request = Request::new(Method::Post, delete_url.parse::<Url>().unwrap());
444
445        let referer_url = {
446            let session = self.session.lock().unwrap();
447            headers::add_cookies(&mut request, &session.cookies);
448            format!("{}/user/{}", session.base_url, session.username)
449        };
450
451        headers::add_edit_headers(&mut request, &referer_url);
452
453        let form_data = [
454            ("csrfmiddlewaretoken", fresh_csrf_token.as_str()),
455            ("artist_name", artist_name),
456            ("track_name", track_name),
457            ("timestamp", &timestamp.to_string()),
458            ("ajax", "1"),
459        ];
460
461        let form_string: String = form_data
462            .iter()
463            .map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v)))
464            .collect::<Vec<_>>()
465            .join("&");
466
467        request.set_body(form_string);
468
469        log::debug!(
470            "Deleting scrobble: '{track_name}' by '{artist_name}' with timestamp {timestamp}"
471        );
472
473        let request_info = RequestInfo::from_url_and_method(&delete_url, "POST");
474        let request_start = std::time::Instant::now();
475
476        self.broadcast_event(ClientEvent::RequestStarted {
477            request: request_info.clone(),
478        });
479
480        let mut response = self
481            .client
482            .send(request)
483            .await
484            .map_err(|e| LastFmError::Http(e.to_string()))?;
485
486        self.broadcast_event(ClientEvent::RequestCompleted {
487            request: request_info.clone(),
488            status_code: response.status().into(),
489            duration_ms: request_start.elapsed().as_millis() as u64,
490        });
491
492        log::debug!("Delete response status: {}", response.status());
493
494        let response_text = response
495            .body_string()
496            .await
497            .map_err(|e| LastFmError::Http(e.to_string()))?;
498
499        // A rate-limit interstitial (served site-wide, e.g. HTTP 406 after edit bursts) must
500        // surface as a RateLimit error, not be misread as the delete failing.
501        self.check_post_response_for_rate_limit(&delete_url, response.status(), &response_text)?;
502
503        let success = response.status().is_success();
504
505        if success {
506            log::debug!("Successfully deleted scrobble");
507        } else {
508            log::debug!("Delete failed with response: {response_text}");
509        }
510
511        Ok(success)
512    }
513
514    pub fn subscribe(&self) -> ClientEventReceiver {
515        self.broadcaster.subscribe()
516    }
517
518    pub fn latest_event(&self) -> Option<ClientEvent> {
519        self.broadcaster.latest_event()
520    }
521
522    /// Get the current rate-limit state snapshot.
523    ///
524    /// Shared across all clones of this client (they share the same broadcaster), so a
525    /// monitoring task can hold a clone and observe rate limiting caused by any of them.
526    pub fn rate_limit_state(&self) -> crate::types::RateLimitState {
527        self.broadcaster.rate_limit_state()
528    }
529
530    /// Get a watch receiver tracking the rate-limit state. Await `.changed()` to react to
531    /// pause/resume transitions without polling.
532    pub fn watch_rate_limit_state(&self) -> crate::types::RateLimitStateWatcher {
533        self.broadcaster.watch_rate_limit_state()
534    }
535
536    fn broadcast_event(&self, event: ClientEvent) {
537        self.broadcaster.broadcast_event(event);
538    }
539
540    pub async fn get_recent_scrobbles(&self, page: u32) -> Result<Vec<Track>> {
541        let url = {
542            let session = self.session.lock().unwrap();
543            format!(
544                "{}/user/{}/library?page={}",
545                session.base_url, session.username, page
546            )
547        };
548
549        log::debug!("Fetching recent scrobbles page {page}");
550        let mut response = self.get(&url).await?;
551        let content = response
552            .body_string()
553            .await
554            .map_err(|e| LastFmError::Http(e.to_string()))?;
555
556        log::debug!(
557            "Recent scrobbles response: {} status, {} chars",
558            response.status(),
559            content.len()
560        );
561
562        let document = Html::parse_document(&content);
563        self.parser.parse_recent_scrobbles(&document)
564    }
565
566    pub async fn get_recent_tracks_page(&self, page: u32) -> Result<TrackPage> {
567        let url = {
568            let session = self.session.lock().unwrap();
569            format!(
570                "{}/user/{}/library?page={}",
571                session.base_url, session.username, page
572            )
573        };
574
575        log::debug!("Fetching recent tracks page {page}");
576        let mut response = self.get(&url).await?;
577        let content = response
578            .body_string()
579            .await
580            .map_err(|e| LastFmError::Http(e.to_string()))?;
581
582        log::debug!(
583            "Recent tracks response: {} status, {} chars",
584            response.status(),
585            content.len()
586        );
587
588        let document = Html::parse_document(&content);
589        let tracks = self.parser.parse_recent_scrobbles(&document)?;
590        let (has_next_page, total_pages) = self.parser.parse_pagination(&document, page)?;
591
592        Ok(TrackPage {
593            tracks,
594            page_number: page,
595            has_next_page,
596            total_pages,
597        })
598    }
599
600    pub async fn find_recent_scrobble_for_track(
601        &self,
602        track_name: &str,
603        artist_name: &str,
604        max_pages: u32,
605    ) -> Result<Option<Track>> {
606        log::debug!("Searching for recent scrobble: '{track_name}' by '{artist_name}'");
607
608        for page in 1..=max_pages {
609            let scrobbles = self.get_recent_scrobbles(page).await?;
610
611            for scrobble in scrobbles {
612                if scrobble.name == track_name && scrobble.artist == artist_name {
613                    log::debug!(
614                        "Found recent scrobble: '{}' with timestamp {:?}",
615                        scrobble.name,
616                        scrobble.timestamp
617                    );
618                    return Ok(Some(scrobble));
619                }
620            }
621        }
622
623        log::debug!(
624            "No recent scrobble found for '{track_name}' by '{artist_name}' in {max_pages} pages"
625        );
626        Ok(None)
627    }
628
629    pub async fn edit_scrobble(&self, edit: &ScrobbleEdit) -> Result<EditResponse> {
630        let discovered_edits = self.discover_scrobble_edit_variations(edit).await?;
631
632        if discovered_edits.is_empty() {
633            let context = match (&edit.track_name_original, &edit.album_name_original) {
634                (Some(track_name), _) => {
635                    format!("track '{}' by '{}'", track_name, edit.artist_name_original)
636                }
637                (None, Some(album_name)) => {
638                    format!("album '{}' by '{}'", album_name, edit.artist_name_original)
639                }
640                (None, None) => format!("artist '{}'", edit.artist_name_original),
641            };
642            return Err(LastFmError::Parse(format!(
643                "No scrobbles found for {context}. Make sure the names are correct and that you have scrobbled recently."
644            )));
645        }
646
647        let mut all_results = Vec::new();
648
649        for (index, discovered_edit) in discovered_edits.iter().enumerate() {
650            log::debug!(
651                "Processing scrobble {}/{}: '{}' from '{}'",
652                index + 1,
653                discovered_edits.len(),
654                discovered_edit.track_name_original,
655                discovered_edit.album_name_original
656            );
657
658            let mut modified_exact_edit = discovered_edit.clone();
659
660            if let Some(new_track_name) = &edit.track_name {
661                modified_exact_edit.track_name = new_track_name.clone();
662            }
663            if let Some(new_album_name) = &edit.album_name {
664                modified_exact_edit.album_name = new_album_name.clone();
665            }
666            modified_exact_edit.artist_name = edit.artist_name.clone();
667            if let Some(new_album_artist_name) = &edit.album_artist_name {
668                modified_exact_edit.album_artist_name = new_album_artist_name.clone();
669            }
670            modified_exact_edit.edit_all = edit.edit_all;
671
672            let album_info = format!(
673                "{} by {}",
674                modified_exact_edit.album_name_original,
675                modified_exact_edit.album_artist_name_original
676            );
677
678            let single_response = self.edit_scrobble_single(&modified_exact_edit, 3).await?;
679            let success = single_response.success();
680            let message = single_response.message();
681
682            all_results.push(SingleEditResponse {
683                success,
684                message,
685                album_info: Some(album_info),
686                exact_scrobble_edit: modified_exact_edit.clone(),
687            });
688
689            if index < discovered_edits.len() - 1
690                && self.config.operational_delays.edit_delay_ms > 0
691            {
692                log::info!(
693                    "Operational edit delay: waiting {}ms before next edit",
694                    self.config.operational_delays.edit_delay_ms
695                );
696                let delay_timestamp = std::time::SystemTime::now()
697                    .duration_since(std::time::UNIX_EPOCH)
698                    .unwrap_or_default()
699                    .as_secs();
700                self.broadcast_event(ClientEvent::Delaying {
701                    delay_ms: self.config.operational_delays.edit_delay_ms,
702                    reason: DelayReason::OperationalEditDelay,
703                    request: None,
704                    delay_timestamp,
705                });
706                self.sleep_ms(self.config.operational_delays.edit_delay_ms)
707                    .await?;
708            }
709        }
710
711        Ok(EditResponse::from_results(all_results))
712    }
713
714    pub async fn edit_scrobble_single(
715        &self,
716        exact_edit: &ExactScrobbleEdit,
717        max_retries: u32,
718    ) -> Result<EditResponse> {
719        // Non-blocking mode: single attempt, no internal sleeping/retrying. Rate limits are
720        // PROPAGATED as `Err(LastFmError::RateLimit { .. })` so queue-building callers can
721        // reschedule the edit themselves; other errors keep the usual "folded into a failed
722        // EditResponse" behavior.
723        if self.config.rate_limit_behavior == RateLimitBehavior::ReturnError {
724            return match self.edit_scrobble_impl(exact_edit).await {
725                Ok(success) => Ok(EditResponse::single(
726                    success,
727                    None,
728                    None,
729                    exact_edit.clone(),
730                )),
731                Err(rate_limit @ LastFmError::RateLimit { .. }) => Err(rate_limit),
732                Err(error) => Ok(EditResponse::single(
733                    false,
734                    Some(error.to_string()),
735                    None,
736                    exact_edit.clone(),
737                )),
738            };
739        }
740
741        // Skip retry if disabled in config or max_retries is 0
742        if !self.config.retry.enabled || max_retries == 0 {
743            return match self.edit_scrobble_impl(exact_edit).await {
744                Ok(success) => Ok(EditResponse::single(
745                    success,
746                    None,
747                    None,
748                    exact_edit.clone(),
749                )),
750                Err(error) => Ok(EditResponse::single(
751                    false,
752                    Some(error.to_string()),
753                    None,
754                    exact_edit.clone(),
755                )),
756            };
757        }
758
759        let mut config = self.config.retry.clone();
760        config.max_retries = max_retries;
761
762        let edit_clone = exact_edit.clone();
763        let client = self.clone();
764
765        match retry::retry_with_backoff_cancelable(
766            config,
767            "Edit scrobble",
768            || client.edit_scrobble_impl(&edit_clone),
769            |delay, rate_limit_timestamp, operation_name| {
770                self.broadcast_event(ClientEvent::RateLimited {
771                    delay_seconds: delay,
772                    request: None, // No specific request context in retry callback
773                    rate_limit_type: RateLimitType::ResponsePattern,
774                    rate_limit_timestamp,
775                });
776                self.broadcast_event(ClientEvent::Delaying {
777                    delay_ms: delay * 1000,
778                    reason: DelayReason::RetryBackoff,
779                    request: None,
780                    delay_timestamp: rate_limit_timestamp,
781                });
782                log::debug!("{operation_name} rate limited, waiting {delay} seconds");
783            },
784            |total_duration, _operation_name| {
785                self.broadcast_event(ClientEvent::RateLimitEnded {
786                    request: crate::types::RequestInfo::from_url_and_method(
787                        &format!(
788                            "edit_scrobble/{}/{}",
789                            edit_clone.artist_name, edit_clone.track_name
790                        ),
791                        "POST",
792                    ),
793                    rate_limit_type: RateLimitType::ResponsePattern,
794                    total_rate_limit_duration_seconds: total_duration,
795                });
796            },
797            Some(self.cancel_rx()),
798        )
799        .await
800        {
801            Ok(retry_result) => Ok(EditResponse::single(
802                retry_result.result,
803                None,
804                None,
805                exact_edit.clone(),
806            )),
807            Err(LastFmError::RateLimit { .. }) => Ok(EditResponse::single(
808                false,
809                Some(format!("Rate limit exceeded after {max_retries} retries")),
810                None,
811                exact_edit.clone(),
812            )),
813            Err(other_error) => Ok(EditResponse::single(
814                false,
815                Some(other_error.to_string()),
816                None,
817                exact_edit.clone(),
818            )),
819        }
820    }
821
822    async fn edit_scrobble_impl(&self, exact_edit: &ExactScrobbleEdit) -> Result<bool> {
823        let start_time = std::time::Instant::now();
824        let result = self.edit_scrobble_impl_internal(exact_edit).await;
825        let duration_ms = start_time.elapsed().as_millis() as u64;
826
827        match &result {
828            Ok(success) => {
829                self.broadcast_event(ClientEvent::EditAttempted {
830                    edit: exact_edit.clone(),
831                    success: *success,
832                    error_message: None,
833                    duration_ms,
834                });
835            }
836            Err(error) => {
837                self.broadcast_event(ClientEvent::EditAttempted {
838                    edit: exact_edit.clone(),
839                    success: false,
840                    error_message: Some(error.to_string()),
841                    duration_ms,
842                });
843            }
844        }
845
846        result
847    }
848
849    async fn edit_scrobble_impl_internal(&self, exact_edit: &ExactScrobbleEdit) -> Result<bool> {
850        let edit_url = {
851            let session = self.session.lock().unwrap();
852            format!(
853                "{}/user/{}/library/edit?edited-variation=library-track-scrobble",
854                session.base_url, session.username
855            )
856        };
857
858        log::debug!("Getting fresh CSRF token for edit");
859        let form_html = self.get_edit_form_html(&edit_url).await?;
860
861        let form_document = Html::parse_document(&form_html);
862        let fresh_csrf_token = self.extract_csrf_token(&form_document)?;
863
864        log::debug!("Submitting edit with fresh token");
865
866        let form_data = exact_edit.build_form_data(&fresh_csrf_token);
867
868        log::debug!(
869            "Editing scrobble: '{}' -> '{}'",
870            exact_edit.track_name_original,
871            exact_edit.track_name
872        );
873        {
874            let session = self.session.lock().unwrap();
875            log::trace!("Session cookies count: {}", session.cookies.len());
876        }
877
878        let mut request = Request::new(Method::Post, edit_url.parse::<Url>().unwrap());
879
880        let referer_url = {
881            let session = self.session.lock().unwrap();
882            headers::add_cookies(&mut request, &session.cookies);
883            format!("{}/user/{}/library", session.base_url, session.username)
884        };
885
886        headers::add_edit_headers(&mut request, &referer_url);
887
888        let form_string: String = form_data
889            .iter()
890            .map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v)))
891            .collect::<Vec<_>>()
892            .join("&");
893
894        request.set_body(form_string);
895
896        let request_info = RequestInfo::from_url_and_method(&edit_url, "POST");
897        let request_start = std::time::Instant::now();
898
899        self.broadcast_event(ClientEvent::RequestStarted {
900            request: request_info.clone(),
901        });
902
903        let mut response = self
904            .client
905            .send(request)
906            .await
907            .map_err(|e| LastFmError::Http(e.to_string()))?;
908
909        self.broadcast_event(ClientEvent::RequestCompleted {
910            request: request_info.clone(),
911            status_code: response.status().into(),
912            duration_ms: request_start.elapsed().as_millis() as u64,
913        });
914
915        log::debug!("Edit response status: {}", response.status());
916
917        let response_text = response
918            .body_string()
919            .await
920            .map_err(|e| LastFmError::Http(e.to_string()))?;
921
922        // A rate-limit interstitial (served site-wide, e.g. HTTP 406 after edit bursts) must
923        // surface as a RateLimit error, not be misread by edit analysis as "rejected".
924        self.check_post_response_for_rate_limit(&edit_url, response.status(), &response_text)?;
925
926        let analysis = edit_analysis::analyze_edit_response(&response_text, response.status());
927
928        Ok(analysis.success)
929    }
930
931    async fn get_edit_form_html(&self, edit_url: &str) -> Result<String> {
932        let mut form_response = self.get(edit_url).await?;
933        let form_html = form_response
934            .body_string()
935            .await
936            .map_err(|e| LastFmError::Http(e.to_string()))?;
937
938        log::debug!("Edit form response status: {}", form_response.status());
939        Ok(form_html)
940    }
941
942    /// Deprecated alias for [`get_scrobble_edit_variations`](Self::get_scrobble_edit_variations).
943    #[deprecated(note = "use get_scrobble_edit_variations")]
944    pub async fn load_edit_form_values_internal(
945        &self,
946        track_name: &str,
947        artist_name: &str,
948    ) -> Result<Vec<ExactScrobbleEdit>> {
949        self.get_scrobble_edit_variations(track_name, artist_name)
950            .await
951    }
952
953    /// Discover every album variation of a track in the user's library, fully populated from
954    /// the scrobble edit forms on the track's library page.
955    ///
956    /// This scrapes `/user/{user}/library/music/{artist}/_/{track}` (following pagination up to
957    /// a few pages) and returns one [`ExactScrobbleEdit`] per unique
958    /// `(album, album artist)` combination, with all original fields — including the
959    /// authoritative `album_artist_name_original` — taken from the hidden form inputs.
960    pub async fn get_scrobble_edit_variations(
961        &self,
962        track_name: &str,
963        artist_name: &str,
964    ) -> Result<Vec<ExactScrobbleEdit>> {
965        log::debug!("Loading edit form values for '{track_name}' by '{artist_name}'");
966
967        let noredirect_track_url_root = {
968            let session = self.session.lock().unwrap();
969            format!(
970                "{}/user/{}/library/music/+noredirect/{}/_/{}",
971                session.base_url,
972                session.username,
973                urlencoding::encode(artist_name),
974                urlencoding::encode(track_name)
975            )
976        };
977
978        let redirect_track_url_root = {
979            let session = self.session.lock().unwrap();
980            format!(
981                "{}/user/{}/library/music/{}/_/{}",
982                session.base_url,
983                session.username,
984                urlencoding::encode(artist_name),
985                urlencoding::encode(track_name)
986            )
987        };
988
989        let mut all_scrobble_edits = Vec::new();
990        let max_pages = 5;
991
992        let build_track_page_url = |root: &str, page: u32, ajax_param: Option<&str>| -> String {
993            if page <= 1 {
994                if let Some(param) = ajax_param {
995                    format!("{root}?{param}")
996                } else {
997                    root.to_string()
998                }
999            } else if let Some(param) = ajax_param {
1000                format!("{root}?page={page}&{param}")
1001            } else {
1002                format!("{root}?page={page}")
1003            }
1004        };
1005
1006        // Prefer +noredirect to avoid Last.fm canonicalization changing the target, but fall back
1007        // to the redirecting URL and/or the ajax variant when the HTML doesn't include the
1008        // chartlist containing edit forms (Last.fm can serve placeholder HTML + async loads).
1009        //
1010        // Note: some endpoints use `ajax=true`, others use `ajax=1`, so try both.
1011        let candidates = [
1012            (&noredirect_track_url_root, None),
1013            (&redirect_track_url_root, None),
1014            (&noredirect_track_url_root, Some("ajax=true")),
1015            (&redirect_track_url_root, Some("ajax=true")),
1016            (&noredirect_track_url_root, Some("ajax=1")),
1017            (&redirect_track_url_root, Some("ajax=1")),
1018        ];
1019
1020        let mut base_track_url_root = None::<String>;
1021        let mut base_track_url_ajax_param = None::<String>;
1022        let mut document = None::<Html>;
1023        let mut unique_albums = None::<std::collections::HashSet<(String, String)>>;
1024        let mut page_edits = None::<Vec<ExactScrobbleEdit>>;
1025        let mut last_tried_url = None::<String>;
1026        let mut last_tried_html = None::<String>;
1027
1028        for (root, ajax_param) in candidates {
1029            let url = build_track_page_url(root, 1, ajax_param);
1030            log::debug!("Fetching track page: {url}");
1031
1032            let mut response = self.get(&url).await?;
1033            let html = response
1034                .body_string()
1035                .await
1036                .map_err(|e| crate::LastFmError::Http(e.to_string()))?;
1037            last_tried_url = Some(url.clone());
1038            last_tried_html = Some(html.clone());
1039            let parsed = Html::parse_document(&html);
1040
1041            let mut attempt_unique_albums = std::collections::HashSet::new();
1042            match self.extract_scrobble_edits_from_page(
1043                &parsed,
1044                track_name,
1045                artist_name,
1046                &mut attempt_unique_albums,
1047            ) {
1048                Ok(edits) if !edits.is_empty() => {
1049                    base_track_url_root = Some((*root).to_string());
1050                    base_track_url_ajax_param = ajax_param.map(|s| s.to_string());
1051                    document = Some(parsed);
1052                    unique_albums = Some(attempt_unique_albums);
1053                    page_edits = Some(edits);
1054                    break;
1055                }
1056                Ok(_) => {
1057                    // Chartlist exists, but no edit forms were extractable. Try the next candidate
1058                    // (e.g. ajax=true variant) before giving up.
1059                }
1060                Err(crate::LastFmError::Parse(msg))
1061                    if msg == "No chartlist table found on track page" =>
1062                {
1063                    // Try next candidate.
1064                }
1065                Err(e) => return Err(e),
1066            }
1067        }
1068
1069        let base_track_url_root = base_track_url_root.ok_or_else(|| {
1070            // Try to leave breadcrumbs for debugging Last.fm page-shape changes.
1071            if let (Some(url), Some(html)) = (&last_tried_url, &last_tried_html) {
1072                let _ = std::fs::write("/tmp/lastfm-edit-track-page-no-forms.html", html);
1073                log::warn!(
1074                    "Failed to locate scrobble edit forms for '{track_name}' by '{artist_name}'. Last URL tried: {url}. Wrote HTML to /tmp/lastfm-edit-track-page-no-forms.html"
1075                );
1076                if page_indicates_logged_out(html) {
1077                    return crate::LastFmError::Auth(format!(
1078                        "session expired or logged out: track page for '{track_name}' by '{artist_name}' shows a login link and no edit forms"
1079                    ));
1080                }
1081            }
1082
1083            crate::LastFmError::Parse(format!(
1084                "No scrobble forms found for track '{track_name}' by '{artist_name}'"
1085            ))
1086        })?;
1087        let document = document.ok_or_else(|| {
1088            crate::LastFmError::Parse("No chartlist table found on track page".to_string())
1089        })?;
1090        let mut unique_albums = unique_albums.unwrap_or_default();
1091
1092        all_scrobble_edits.extend(page_edits.unwrap_or_default());
1093
1094        log::debug!(
1095            "Page 1: found {} unique album variations",
1096            all_scrobble_edits.len()
1097        );
1098
1099        let pagination_selector = Selector::parse(".pagination .pagination-next").unwrap();
1100        let mut has_next_page = document.select(&pagination_selector).next().is_some();
1101        let mut page = 2;
1102
1103        while has_next_page && page <= max_pages {
1104            let page_url = build_track_page_url(
1105                &base_track_url_root,
1106                page,
1107                base_track_url_ajax_param.as_deref(),
1108            );
1109
1110            log::debug!("Fetching page {page} for additional album variations");
1111
1112            let mut response = self.get(&page_url).await?;
1113            let html = response
1114                .body_string()
1115                .await
1116                .map_err(|e| crate::LastFmError::Http(e.to_string()))?;
1117
1118            let document = Html::parse_document(&html);
1119
1120            let page_edits = self.extract_scrobble_edits_from_page(
1121                &document,
1122                track_name,
1123                artist_name,
1124                &mut unique_albums,
1125            )?;
1126
1127            let initial_count = all_scrobble_edits.len();
1128            all_scrobble_edits.extend(page_edits);
1129            let found_new_unique_albums = all_scrobble_edits.len() > initial_count;
1130
1131            has_next_page = document.select(&pagination_selector).next().is_some();
1132
1133            log::debug!(
1134                "Page {page}: found {} total unique albums ({})",
1135                all_scrobble_edits.len(),
1136                if found_new_unique_albums {
1137                    "new albums found"
1138                } else {
1139                    "no new unique albums"
1140                }
1141            );
1142
1143            page += 1;
1144        }
1145
1146        if all_scrobble_edits.is_empty() {
1147            return Err(crate::LastFmError::Parse(format!(
1148                "No scrobble forms found for track '{track_name}' by '{artist_name}'"
1149            )));
1150        }
1151
1152        log::debug!(
1153            "Final result: found {} unique album variations for '{track_name}' by '{artist_name}'",
1154            all_scrobble_edits.len(),
1155        );
1156
1157        Ok(all_scrobble_edits)
1158    }
1159
1160    fn extract_scrobble_edits_from_page(
1161        &self,
1162        document: &Html,
1163        expected_track: &str,
1164        expected_artist: &str,
1165        unique_albums: &mut std::collections::HashSet<(String, String)>,
1166    ) -> Result<Vec<ExactScrobbleEdit>> {
1167        let table_selector =
1168            Selector::parse("table.chartlist:not(.chartlist__placeholder)").unwrap();
1169        let table = document.select(&table_selector).next().ok_or_else(|| {
1170            crate::LastFmError::Parse("No chartlist table found on track page".to_string())
1171        })?;
1172
1173        let row_selector = Selector::parse("tr").unwrap();
1174        let mut scrobble_edits: Vec<ExactScrobbleEdit> = table
1175            .select(&row_selector)
1176            .filter_map(|row| {
1177                Self::extract_scrobble_edit_from_row(
1178                    row,
1179                    expected_track,
1180                    expected_artist,
1181                    unique_albums,
1182                    true,
1183                )
1184            })
1185            .collect();
1186
1187        // Some track pages (especially when Last.fm redirects/canonicalizes artist or track names)
1188        // still contain valid edit forms, but their hidden `track_name`/`artist_name` values don't
1189        // exactly match the requested URL params. In that case, fall back to "any form on page"
1190        // so the caller can still apply edits rather than failing hard.
1191        if scrobble_edits.is_empty() {
1192            log::debug!(
1193                "No exact-match scrobble forms found for '{expected_track}' by '{expected_artist}'; falling back to unfiltered forms on page"
1194            );
1195            scrobble_edits = table
1196                .select(&row_selector)
1197                .filter_map(|row| {
1198                    Self::extract_scrobble_edit_from_row(
1199                        row,
1200                        expected_track,
1201                        expected_artist,
1202                        unique_albums,
1203                        false,
1204                    )
1205                })
1206                .collect();
1207        }
1208
1209        Ok(scrobble_edits)
1210    }
1211
1212    fn extract_scrobble_edit_from_row(
1213        row: scraper::ElementRef,
1214        expected_track: &str,
1215        expected_artist: &str,
1216        unique_albums: &mut std::collections::HashSet<(String, String)>,
1217        require_exact_match: bool,
1218    ) -> Option<ExactScrobbleEdit> {
1219        let count_bar_link_selector = Selector::parse(".chartlist-count-bar-link").unwrap();
1220        if row.select(&count_bar_link_selector).next().is_some() {
1221            log::debug!("Found count bar link, skipping aggregated row");
1222            return None;
1223        }
1224
1225        let form_selector = Selector::parse("form[data-edit-scrobble]").unwrap();
1226        let form = row.select(&form_selector).next()?;
1227
1228        let extract_form_value = |name: &str| -> Option<String> {
1229            let selector = Selector::parse(&format!("input[name='{name}']")).unwrap();
1230            form.select(&selector)
1231                .next()
1232                .and_then(|input| input.value().attr("value"))
1233                .map(|s| s.to_string())
1234        };
1235
1236        let form_track = extract_form_value("track_name").unwrap_or_default();
1237        let form_artist = extract_form_value("artist_name").unwrap_or_default();
1238
1239        if require_exact_match && (form_track != expected_track || form_artist != expected_artist) {
1240            return None;
1241        }
1242
1243        let form_album = extract_form_value("album_name").unwrap_or_default();
1244        // Last.fm's edit forms normally include an `album_artist_name` input whose value is
1245        // authoritative (it can legitimately differ from the track artist for compilations
1246        // and soundtracks). When the input is missing entirely (observed for some scrobbles
1247        // without album metadata), we fall back to the track artist as the best available
1248        // guess so the edit form can still be submitted with a complete field set.
1249        let form_album_artist = extract_form_value("album_artist_name").unwrap_or_else(|| {
1250            log::debug!(
1251                "No album_artist_name input in edit form for '{form_track}' by '{form_artist}'; \
1252                 falling back to track artist as album artist"
1253            );
1254            form_artist.clone()
1255        });
1256
1257        let album_key = (form_album.clone(), form_album_artist.clone());
1258        if !unique_albums.insert(album_key) {
1259            return None;
1260        }
1261
1262        let form_timestamp = extract_form_value("timestamp").unwrap_or_default();
1263        let timestamp: u64 = match form_timestamp.parse() {
1264            Ok(ts) => ts,
1265            Err(_) => {
1266                log::warn!(
1267                    "âš ī¸ Skipping form without valid timestamp: '{form_album}' by '{form_album_artist}'"
1268                );
1269                return None;
1270            }
1271        };
1272
1273        Some(ExactScrobbleEdit::new(
1274            form_track.clone(),
1275            form_album.clone(),
1276            form_artist.clone(),
1277            form_album_artist.clone(),
1278            form_track,
1279            form_album,
1280            form_artist,
1281            form_album_artist,
1282            timestamp,
1283            true,
1284        ))
1285    }
1286
1287    pub async fn get_artist_tracks_page(&self, artist: &str, page: u32) -> Result<TrackPage> {
1288        let url = {
1289            let session = self.session.lock().unwrap();
1290            format!(
1291                "{}/user/{}/library/music/{}/+tracks?page={}&ajax=true",
1292                session.base_url,
1293                session.username,
1294                urlencoding::encode(artist),
1295                page
1296            )
1297        };
1298
1299        log::debug!("Fetching tracks page {page} for artist: {artist}");
1300        let mut response = self.get(&url).await?;
1301        let content = response
1302            .body_string()
1303            .await
1304            .map_err(|e| LastFmError::Http(e.to_string()))?;
1305
1306        log::debug!(
1307            "AJAX response: {} status, {} chars",
1308            response.status(),
1309            content.len()
1310        );
1311
1312        log::debug!("Parsing HTML response from AJAX endpoint");
1313        let document = Html::parse_document(&content);
1314        self.parser.parse_tracks_page(&document, page, artist, None)
1315    }
1316
1317    pub fn extract_tracks_from_document(
1318        &self,
1319        document: &Html,
1320        artist: &str,
1321        album: Option<&str>,
1322    ) -> Result<Vec<Track>> {
1323        self.parser
1324            .extract_tracks_from_document(document, artist, album)
1325    }
1326
1327    pub fn parse_tracks_page(
1328        &self,
1329        document: &Html,
1330        page_number: u32,
1331        artist: &str,
1332        album: Option<&str>,
1333    ) -> Result<TrackPage> {
1334        self.parser
1335            .parse_tracks_page(document, page_number, artist, album)
1336    }
1337
1338    fn extract_csrf_token(&self, document: &Html) -> Result<String> {
1339        let csrf_selector = Selector::parse("input[name=\"csrfmiddlewaretoken\"]").unwrap();
1340
1341        document
1342            .select(&csrf_selector)
1343            .next()
1344            .and_then(|input| input.value().attr("value"))
1345            .map(|token| token.to_string())
1346            .ok_or(LastFmError::CsrfNotFound)
1347    }
1348
1349    pub async fn get(&self, url: &str) -> Result<Response> {
1350        // In non-blocking mode we never sleep/retry internally on rate limits, regardless of
1351        // the retry configuration. Detection still broadcasts RateLimited events and updates
1352        // the shared rate-limit state inside `get_without_retry`.
1353        if self.config.rate_limit_behavior == RateLimitBehavior::ReturnError {
1354            return self.get_without_retry(url).await;
1355        }
1356        if self.config.retry.enabled {
1357            self.get_with_retry(url).await
1358        } else {
1359            self.get_without_retry(url).await
1360        }
1361    }
1362
1363    async fn get_without_retry(&self, url: &str) -> Result<Response> {
1364        let mut response = self.get_with_redirects(url, 0).await?;
1365
1366        let status = response.status();
1367        let body = self.extract_response_body(url, &mut response).await?;
1368
1369        // Only scan the body for rate-limit patterns on non-success responses. Last.fm
1370        // serves its "Rate Limited" interstitial with a non-success status (e.g. 503), so
1371        // scanning 2xx bodies is unnecessary and false-positives on ordinary pages whose
1372        // track/album titles happen to contain phrases like "Slow Down".
1373        if self.response_indicates_rate_limit(status, &body) {
1374            log::debug!("Response body contains rate limit patterns");
1375            // Broadcast here so pattern-detected rate limits are observable even when the
1376            // retry loop (which also broadcasts) is not driving this call.
1377            self.broadcast_event(ClientEvent::RateLimited {
1378                delay_seconds: 60,
1379                request: Some(RequestInfo::from_url_and_method(url, "GET")),
1380                rate_limit_type: RateLimitType::ResponsePattern,
1381                rate_limit_timestamp: std::time::SystemTime::now()
1382                    .duration_since(std::time::UNIX_EPOCH)
1383                    .unwrap_or_default()
1384                    .as_secs(),
1385            });
1386            return Err(LastFmError::RateLimit { retry_after: 60 });
1387        }
1388
1389        let mut new_response = http_types::Response::new(response.status());
1390        for (name, values) in response.iter() {
1391            for value in values {
1392                let _ = new_response.insert_header(name.clone(), value.clone());
1393            }
1394        }
1395        new_response.set_body(body);
1396
1397        Ok(new_response)
1398    }
1399
1400    async fn get_with_retry(&self, url: &str) -> Result<Response> {
1401        let config = self.config.retry.clone();
1402
1403        let url_string = url.to_string();
1404        let client = self.clone();
1405
1406        let retry_result = retry::retry_with_backoff_cancelable(
1407            config,
1408            &format!("GET {url}"),
1409            || client.get_without_retry(&url_string),
1410            |delay, rate_limit_timestamp, operation_name| {
1411                self.broadcast_event(ClientEvent::RateLimited {
1412                    delay_seconds: delay,
1413                    request: None, // No specific request context in retry callback
1414                    rate_limit_type: RateLimitType::ResponsePattern,
1415                    rate_limit_timestamp,
1416                });
1417                self.broadcast_event(ClientEvent::Delaying {
1418                    delay_ms: delay * 1000,
1419                    reason: DelayReason::RetryBackoff,
1420                    request: None,
1421                    delay_timestamp: rate_limit_timestamp,
1422                });
1423                log::debug!("{operation_name} rate limited, waiting {delay} seconds");
1424            },
1425            |total_duration, _operation_name| {
1426                self.broadcast_event(ClientEvent::RateLimitEnded {
1427                    request: crate::types::RequestInfo::from_url_and_method(&url_string, "GET"),
1428                    rate_limit_type: RateLimitType::ResponsePattern,
1429                    total_rate_limit_duration_seconds: total_duration,
1430                });
1431            },
1432            Some(self.cancel_rx()),
1433        )
1434        .await?;
1435
1436        Ok(retry_result.result)
1437    }
1438
1439    async fn get_with_redirects(&self, url: &str, redirect_count: u32) -> Result<Response> {
1440        if redirect_count > 5 {
1441            return Err(LastFmError::Http("Too many redirects".to_string()));
1442        }
1443
1444        let mut request = Request::new(Method::Get, url.parse::<Url>().unwrap());
1445
1446        {
1447            let session = self.session.lock().unwrap();
1448            headers::add_cookies(&mut request, &session.cookies);
1449            if session.cookies.is_empty() && url.contains("page=") {
1450                log::debug!("No cookies available for paginated request!");
1451            }
1452        }
1453
1454        let is_ajax = url.contains("ajax=true") || url.contains("ajax=1");
1455        let referer_url = if url.contains("page=") {
1456            Some(url.split('?').next().unwrap_or(url))
1457        } else {
1458            None
1459        };
1460
1461        headers::add_get_headers(&mut request, is_ajax, referer_url);
1462
1463        let request_info = RequestInfo::from_url_and_method(url, "GET");
1464        let request_start = std::time::Instant::now();
1465
1466        self.broadcast_event(ClientEvent::RequestStarted {
1467            request: request_info.clone(),
1468        });
1469
1470        // Optional throttle for heavy library scanning. Kept cancelable so callers can stop.
1471        if self.config.operational_delays.get_delay_ms > 0 {
1472            self.sleep_ms(self.config.operational_delays.get_delay_ms)
1473                .await?;
1474        }
1475
1476        let mut response = self
1477            .client
1478            .send(request)
1479            .await
1480            .map_err(|e| LastFmError::Http(e.to_string()))?;
1481
1482        self.broadcast_event(ClientEvent::RequestCompleted {
1483            request: request_info.clone(),
1484            status_code: response.status().into(),
1485            duration_ms: request_start.elapsed().as_millis() as u64,
1486        });
1487
1488        self.extract_cookies(&response);
1489
1490        if response.status() == 302 || response.status() == 301 {
1491            if let Some(location) = response.header("location") {
1492                if let Some(redirect_url) = location.get(0) {
1493                    let redirect_url_str = redirect_url.as_str();
1494                    if url.contains("page=") {
1495                        log::debug!("Following redirect from {url} to {redirect_url_str}");
1496
1497                        if redirect_url_str.contains("/login") {
1498                            log::debug!("Redirect to login page - authentication failed for paginated request");
1499                            return Err(LastFmError::Auth(
1500                                "Session expired or invalid for paginated request".to_string(),
1501                            ));
1502                        }
1503                    }
1504
1505                    let full_redirect_url = if redirect_url_str.starts_with('/') {
1506                        let base_url = self.session.lock().unwrap().base_url.clone();
1507                        format!("{base_url}{redirect_url_str}")
1508                    } else if redirect_url_str.starts_with("http") {
1509                        redirect_url_str.to_string()
1510                    } else {
1511                        let base_url = url
1512                            .rsplit('/')
1513                            .skip(1)
1514                            .collect::<Vec<_>>()
1515                            .into_iter()
1516                            .rev()
1517                            .collect::<Vec<_>>()
1518                            .join("/");
1519                        format!("{base_url}/{redirect_url_str}")
1520                    };
1521
1522                    return Box::pin(
1523                        self.get_with_redirects(&full_redirect_url, redirect_count + 1),
1524                    )
1525                    .await;
1526                }
1527            }
1528        }
1529
1530        if self.config.rate_limit.detect_by_status && response.status() == 429 {
1531            let retry_after = response
1532                .header("retry-after")
1533                .and_then(|h| h.get(0))
1534                .and_then(|v| v.as_str().parse::<u64>().ok())
1535                .unwrap_or(60);
1536            self.broadcast_event(ClientEvent::RateLimited {
1537                delay_seconds: retry_after,
1538                request: Some(request_info.clone()),
1539                rate_limit_type: RateLimitType::Http429,
1540                rate_limit_timestamp: std::time::SystemTime::now()
1541                    .duration_since(std::time::UNIX_EPOCH)
1542                    .unwrap_or_default()
1543                    .as_secs(),
1544            });
1545            return Err(LastFmError::RateLimit { retry_after });
1546        }
1547
1548        if self.config.rate_limit.detect_by_status && response.status() == 503 {
1549            let retry_after = response
1550                .header("retry-after")
1551                .and_then(|h| h.get(0))
1552                .and_then(|v| v.as_str().parse::<u64>().ok())
1553                .unwrap_or(60);
1554            self.broadcast_event(ClientEvent::RateLimited {
1555                delay_seconds: retry_after,
1556                request: Some(request_info.clone()),
1557                rate_limit_type: RateLimitType::Http503,
1558                rate_limit_timestamp: std::time::SystemTime::now()
1559                    .duration_since(std::time::UNIX_EPOCH)
1560                    .unwrap_or_default()
1561                    .as_secs(),
1562            });
1563            return Err(LastFmError::RateLimit { retry_after });
1564        }
1565
1566        if self.config.rate_limit.detect_by_status && response.status() == 403 {
1567            log::debug!("Got 403 response, checking if it's a rate limit");
1568            // Read the body so we can confirm this is really a rate-limit interstitial
1569            // rather than an ordinary 403. We only treat it as a rate limit when the body
1570            // matches a known pattern; a bare 403 (e.g. genuine authorization failure) is
1571            // passed through as a normal response.
1572            let status = response.status();
1573            let body = self.extract_response_body(url, &mut response).await?;
1574            if self.is_rate_limit_response(&body) {
1575                log::debug!("403 body matches rate-limit pattern - treating as rate limit");
1576                self.broadcast_event(ClientEvent::RateLimited {
1577                    delay_seconds: 60,
1578                    request: Some(request_info.clone()),
1579                    rate_limit_type: RateLimitType::Http403,
1580                    rate_limit_timestamp: std::time::SystemTime::now()
1581                        .duration_since(std::time::UNIX_EPOCH)
1582                        .unwrap_or_default()
1583                        .as_secs(),
1584                });
1585                return Err(LastFmError::RateLimit { retry_after: 60 });
1586            }
1587
1588            // Not a rate limit: rebuild the response with the already-consumed body.
1589            let mut new_response = http_types::Response::new(status);
1590            for (name, values) in response.iter() {
1591                for value in values {
1592                    let _ = new_response.insert_header(name.clone(), value.clone());
1593                }
1594            }
1595            new_response.set_body(body);
1596            return Ok(new_response);
1597        }
1598
1599        Ok(response)
1600    }
1601
1602    /// Decide whether a response is a rate-limit interstitial given its status and body.
1603    ///
1604    /// Detection is two-tier:
1605    /// - Non-success statuses: the broad pattern lists ([`RateLimitConfig::patterns`] and
1606    ///   `custom_patterns`) apply, since error pages never embed user library content.
1607    /// - Success (2xx) statuses: only the narrow [`RateLimitConfig::success_patterns`] list
1608    ///   applies. Last.fm serves its "requesting a lot of pages" soft-throttle interstitial
1609    ///   with HTTP 200, so some 2xx detection is required — but 2xx pages embed track/album
1610    ///   titles (e.g. a real track named "Slow Down"), so only unambiguous interstitial
1611    ///   phrases are checked.
1612    fn response_indicates_rate_limit(&self, status: http_types::StatusCode, body: &str) -> bool {
1613        if status.is_success() {
1614            let body_lower = body.to_lowercase();
1615            for pattern in &self.config.rate_limit.success_patterns {
1616                if body_lower.contains(&pattern.to_lowercase()) {
1617                    log::debug!(
1618                        "Rate limit detected on 2xx response (success pattern: '{pattern}')"
1619                    );
1620                    return true;
1621                }
1622            }
1623            false
1624        } else {
1625            self.is_rate_limit_response(body)
1626        }
1627    }
1628
1629    /// Guard a POST response against the rate-limit interstitial.
1630    ///
1631    /// Last.fm serves its "Last.fm - Rate Limited" interstitial site-wide (observed with
1632    /// HTTP 406 after edit bursts), including to POST endpoints. Without this check, an
1633    /// edit/delete POST that receives the interstitial would be misinterpreted as the
1634    /// operation being rejected. If the response matches, broadcasts
1635    /// [`ClientEvent::RateLimited`] and returns `Err(LastFmError::RateLimit)`, mirroring
1636    /// the GET path in `get_without_retry`.
1637    fn check_post_response_for_rate_limit(
1638        &self,
1639        url: &str,
1640        status: http_types::StatusCode,
1641        body: &str,
1642    ) -> Result<()> {
1643        if self.response_indicates_rate_limit(status, body) {
1644            log::debug!("POST response body contains rate limit patterns (status {status})");
1645            self.broadcast_event(ClientEvent::RateLimited {
1646                delay_seconds: 60,
1647                request: Some(RequestInfo::from_url_and_method(url, "POST")),
1648                rate_limit_type: RateLimitType::ResponsePattern,
1649                rate_limit_timestamp: std::time::SystemTime::now()
1650                    .duration_since(std::time::UNIX_EPOCH)
1651                    .unwrap_or_default()
1652                    .as_secs(),
1653            });
1654            return Err(LastFmError::RateLimit { retry_after: 60 });
1655        }
1656        Ok(())
1657    }
1658
1659    fn is_rate_limit_response(&self, response_body: &str) -> bool {
1660        let rate_limit_config = &self.config.rate_limit;
1661
1662        if !rate_limit_config.detect_by_patterns && rate_limit_config.custom_patterns.is_empty() {
1663            return false;
1664        }
1665
1666        let body_lower = response_body.to_lowercase();
1667
1668        for pattern in &rate_limit_config.custom_patterns {
1669            if body_lower.contains(&pattern.to_lowercase()) {
1670                log::debug!("Rate limit detected (custom pattern: '{pattern}')");
1671                return true;
1672            }
1673        }
1674
1675        if rate_limit_config.detect_by_patterns {
1676            for pattern in &rate_limit_config.patterns {
1677                let pattern_lower = pattern.to_lowercase();
1678                if body_lower.contains(&pattern_lower) {
1679                    log::debug!("Rate limit detected (pattern: '{pattern}')");
1680                    return true;
1681                }
1682            }
1683        }
1684
1685        false
1686    }
1687
1688    fn extract_cookies(&self, response: &Response) {
1689        let mut session = self.session.lock().unwrap();
1690        extract_cookies_from_response(response, &mut session.cookies);
1691    }
1692
1693    async fn extract_response_body(&self, _url: &str, response: &mut Response) -> Result<String> {
1694        let body = response
1695            .body_string()
1696            .await
1697            .map_err(|e| LastFmError::Http(e.to_string()))?;
1698
1699        Ok(body)
1700    }
1701
1702    pub async fn get_artists_page(&self, page: u32) -> Result<crate::ArtistPage> {
1703        let url = {
1704            let session = self.session.lock().unwrap();
1705            format!(
1706                "{}/user/{}/library/artists?page={}",
1707                session.base_url, session.username, page
1708            )
1709        };
1710
1711        log::debug!("Fetching artists page {page}");
1712        let mut response = self.get(&url).await?;
1713        let content = response
1714            .body_string()
1715            .await
1716            .map_err(|e| LastFmError::Http(e.to_string()))?;
1717
1718        log::debug!(
1719            "Artist library response: {} status, {} chars",
1720            response.status(),
1721            content.len()
1722        );
1723
1724        log::debug!("Parsing HTML response from artist library endpoint");
1725        let document = Html::parse_document(&content);
1726        self.parser.parse_artists_page(&document, page)
1727    }
1728
1729    pub async fn get_artist_albums_page(&self, artist: &str, page: u32) -> Result<AlbumPage> {
1730        let url = {
1731            let session = self.session.lock().unwrap();
1732            format!(
1733                "{}/user/{}/library/music/{}/+albums?page={}&ajax=true",
1734                session.base_url,
1735                session.username,
1736                urlencoding::encode(artist),
1737                page
1738            )
1739        };
1740
1741        log::debug!("Fetching albums page {page} for artist: {artist}");
1742        let mut response = self.get(&url).await?;
1743        let content = response
1744            .body_string()
1745            .await
1746            .map_err(|e| LastFmError::Http(e.to_string()))?;
1747
1748        log::debug!(
1749            "AJAX response: {} status, {} chars",
1750            response.status(),
1751            content.len()
1752        );
1753
1754        log::debug!("Parsing HTML response from AJAX endpoint");
1755        let document = Html::parse_document(&content);
1756        self.parser.parse_albums_page(&document, page, artist)
1757    }
1758
1759    pub async fn get_album_tracks_page(
1760        &self,
1761        album_name: &str,
1762        artist_name: &str,
1763        page: u32,
1764    ) -> Result<TrackPage> {
1765        let url = {
1766            let session = self.session.lock().unwrap();
1767            format!(
1768                "{}/user/{}/library/music/{}/{}?page={}&ajax=true",
1769                session.base_url,
1770                session.username,
1771                self.lastfm_encode(artist_name),
1772                self.lastfm_encode(album_name),
1773                page
1774            )
1775        };
1776
1777        log::debug!("Fetching tracks page {page} for album '{album_name}' by '{artist_name}'");
1778        log::debug!("🔗 Album URL: {url}");
1779
1780        let mut response = self.get(&url).await?;
1781        let content = response
1782            .body_string()
1783            .await
1784            .map_err(|e| LastFmError::Http(e.to_string()))?;
1785
1786        log::debug!(
1787            "AJAX response: {} status, {} chars",
1788            response.status(),
1789            content.len()
1790        );
1791
1792        log::debug!("Parsing HTML response from AJAX endpoint");
1793        let document = Html::parse_document(&content);
1794        let result =
1795            self.parser
1796                .parse_tracks_page(&document, page, artist_name, Some(album_name))?;
1797
1798        // Debug logging for albums that return 0 tracks
1799        if result.tracks.is_empty() {
1800            if content.contains("404") || content.contains("Not Found") {
1801                log::warn!("🚨 404 ERROR for album '{album_name}' by '{artist_name}': {url}");
1802            } else if content.contains("no tracks") || content.contains("no music") {
1803                log::debug!("â„šī¸  Album '{album_name}' by '{artist_name}' explicitly has no tracks in user's library");
1804            } else {
1805                log::warn!(
1806                    "🚨 UNKNOWN EMPTY RESPONSE for album '{album_name}' by '{artist_name}': {url}"
1807                );
1808                log::debug!("🔍 Response length: {} chars", content.len());
1809                log::debug!(
1810                    "🔍 Response preview (first 200 chars): {}",
1811                    content.chars().take(200).collect::<String>()
1812                );
1813            }
1814        } else {
1815            log::debug!(
1816                "✅ SUCCESS: Album '{album_name}' by '{artist_name}' returned {} tracks",
1817                result.tracks.len()
1818            );
1819        }
1820
1821        Ok(result)
1822    }
1823
1824    pub async fn search_tracks_page(&self, query: &str, page: u32) -> Result<TrackPage> {
1825        let url = {
1826            let session = self.session.lock().unwrap();
1827            format!(
1828                "{}/user/{}/library/tracks/search?page={}&query={}&ajax=1",
1829                session.base_url,
1830                session.username,
1831                page,
1832                urlencoding::encode(query)
1833            )
1834        };
1835
1836        log::debug!("Searching tracks for query '{query}' on page {page}");
1837        let mut response = self.get(&url).await?;
1838        let content = response
1839            .body_string()
1840            .await
1841            .map_err(|e| LastFmError::Http(e.to_string()))?;
1842
1843        log::debug!(
1844            "Track search response: {} status, {} chars",
1845            response.status(),
1846            content.len()
1847        );
1848
1849        let document = Html::parse_document(&content);
1850        let tracks = self.parser.parse_track_search_results(&document)?;
1851
1852        // For search results, we need to determine pagination differently
1853        // since we don't have the same pagination structure as regular library pages
1854        let (has_next_page, total_pages) = self.parser.parse_pagination(&document, page)?;
1855
1856        Ok(TrackPage {
1857            tracks,
1858            page_number: page,
1859            has_next_page,
1860            total_pages,
1861        })
1862    }
1863
1864    pub async fn search_albums_page(&self, query: &str, page: u32) -> Result<AlbumPage> {
1865        let url = {
1866            let session = self.session.lock().unwrap();
1867            format!(
1868                "{}/user/{}/library/albums/search?page={}&query={}&ajax=1",
1869                session.base_url,
1870                session.username,
1871                page,
1872                urlencoding::encode(query)
1873            )
1874        };
1875
1876        log::debug!("Searching albums for query '{query}' on page {page}");
1877        let mut response = self.get(&url).await?;
1878        let content = response
1879            .body_string()
1880            .await
1881            .map_err(|e| LastFmError::Http(e.to_string()))?;
1882
1883        log::debug!(
1884            "Album search response: {} status, {} chars",
1885            response.status(),
1886            content.len()
1887        );
1888
1889        let document = Html::parse_document(&content);
1890        let albums = self.parser.parse_album_search_results(&document)?;
1891
1892        // For search results, we need to determine pagination differently
1893        let (has_next_page, total_pages) = self.parser.parse_pagination(&document, page)?;
1894
1895        Ok(AlbumPage {
1896            albums,
1897            page_number: page,
1898            has_next_page,
1899            total_pages,
1900        })
1901    }
1902
1903    pub async fn search_artists_page(&self, query: &str, page: u32) -> Result<ArtistPage> {
1904        let url = {
1905            let session = self.session.lock().unwrap();
1906            format!(
1907                "{}/user/{}/library/artists/search?page={}&query={}&ajax=1",
1908                session.base_url,
1909                session.username,
1910                page,
1911                urlencoding::encode(query)
1912            )
1913        };
1914
1915        log::debug!("Searching artists for query '{query}' on page {page}");
1916        let mut response = self.get(&url).await?;
1917        let content = response
1918            .body_string()
1919            .await
1920            .map_err(|e| LastFmError::Http(e.to_string()))?;
1921
1922        log::debug!(
1923            "Artist search response: {} status, {} chars",
1924            response.status(),
1925            content.len()
1926        );
1927
1928        let document = Html::parse_document(&content);
1929        let artists = self.parser.parse_artist_search_results(&document)?;
1930
1931        // For search results, we need to determine pagination differently
1932        let (has_next_page, total_pages) = self.parser.parse_pagination(&document, page)?;
1933
1934        Ok(ArtistPage {
1935            artists,
1936            page_number: page,
1937            has_next_page,
1938            total_pages,
1939        })
1940    }
1941
1942    /// Construct a standalone `LastFmApiClientImpl` from this edit client's fields.
1943    ///
1944    /// Returns `None` if no API key is configured.
1945    pub fn api_client(&self) -> Option<crate::api::LastFmApiClientImpl> {
1946        self.api_key.as_ref().map(|key| {
1947            crate::api::LastFmApiClientImpl::new(
1948                Box::new(ArcHttpClient(self.client.clone())),
1949                self.username(),
1950                key.clone(),
1951            )
1952        })
1953    }
1954
1955    /// Expose the inner HTTP client for advanced use cases like VCR cassette management
1956    pub fn inner_client(&self) -> Arc<dyn HttpClient + Send + Sync> {
1957        self.client.clone()
1958    }
1959}
1960
1961#[async_trait(?Send)]
1962impl LastFmBaseClient for LastFmEditClientImpl {
1963    async fn get_artists_page(&self, page: u32) -> Result<crate::ArtistPage> {
1964        self.get_artists_page(page).await
1965    }
1966
1967    async fn get_artist_tracks_page(&self, artist: &str, page: u32) -> Result<TrackPage> {
1968        self.get_artist_tracks_page(artist, page).await
1969    }
1970
1971    async fn get_artist_albums_page(&self, artist: &str, page: u32) -> Result<AlbumPage> {
1972        self.get_artist_albums_page(artist, page).await
1973    }
1974
1975    async fn get_album_tracks_page(
1976        &self,
1977        album_name: &str,
1978        artist_name: &str,
1979        page: u32,
1980    ) -> Result<TrackPage> {
1981        self.get_album_tracks_page(album_name, artist_name, page)
1982            .await
1983    }
1984
1985    async fn get_recent_tracks_page(&self, page: u32) -> Result<TrackPage> {
1986        self.get_recent_tracks_page(page).await
1987    }
1988
1989    async fn search_tracks_page(&self, query: &str, page: u32) -> Result<crate::TrackPage> {
1990        self.search_tracks_page(query, page).await
1991    }
1992
1993    async fn search_albums_page(&self, query: &str, page: u32) -> Result<crate::AlbumPage> {
1994        self.search_albums_page(query, page).await
1995    }
1996
1997    async fn search_artists_page(&self, query: &str, page: u32) -> Result<crate::ArtistPage> {
1998        self.search_artists_page(query, page).await
1999    }
2000
2001    fn username(&self) -> String {
2002        self.username()
2003    }
2004
2005    fn get_session(&self) -> LastFmEditSession {
2006        self.get_session()
2007    }
2008
2009    fn subscribe(&self) -> ClientEventReceiver {
2010        self.subscribe()
2011    }
2012
2013    fn latest_event(&self) -> Option<ClientEvent> {
2014        self.latest_event()
2015    }
2016
2017    fn rate_limit_state(&self) -> crate::types::RateLimitState {
2018        self.rate_limit_state()
2019    }
2020
2021    fn watch_rate_limit_state(&self) -> crate::types::RateLimitStateWatcher {
2022        self.watch_rate_limit_state()
2023    }
2024
2025    async fn validate_session(&self) -> bool {
2026        self.validate_session().await
2027    }
2028
2029    async fn find_recent_scrobble_for_track(
2030        &self,
2031        track_name: &str,
2032        artist_name: &str,
2033        max_pages: u32,
2034    ) -> Result<Option<Track>> {
2035        self.find_recent_scrobble_for_track(track_name, artist_name, max_pages)
2036            .await
2037    }
2038
2039    fn cancel(&self) {
2040        self.cancel.cancel();
2041    }
2042
2043    fn reset_cancel(&self) {
2044        self.cancel.reset();
2045    }
2046
2047    fn is_cancelled(&self) -> bool {
2048        self.cancel.is_cancelled()
2049    }
2050}
2051
2052#[async_trait(?Send)]
2053impl LastFmEditClient for LastFmEditClientImpl {
2054    async fn edit_scrobble(&self, edit: &ScrobbleEdit) -> Result<EditResponse> {
2055        self.edit_scrobble(edit).await
2056    }
2057
2058    async fn edit_scrobble_single(
2059        &self,
2060        exact_edit: &ExactScrobbleEdit,
2061        max_retries: u32,
2062    ) -> Result<EditResponse> {
2063        self.edit_scrobble_single(exact_edit, max_retries).await
2064    }
2065
2066    async fn delete_scrobble(
2067        &self,
2068        artist_name: &str,
2069        track_name: &str,
2070        timestamp: u64,
2071    ) -> Result<bool> {
2072        self.delete_scrobble(artist_name, track_name, timestamp)
2073            .await
2074    }
2075
2076    async fn get_scrobble_edit_variations(
2077        &self,
2078        track_name: &str,
2079        artist_name: &str,
2080    ) -> Result<Vec<ExactScrobbleEdit>> {
2081        self.get_scrobble_edit_variations(track_name, artist_name)
2082            .await
2083    }
2084
2085    fn discover_scrobbles(
2086        &self,
2087        edit: ScrobbleEdit,
2088    ) -> Box<dyn crate::AsyncDiscoveryIterator<crate::ExactScrobbleEdit>> {
2089        let track_name = edit.track_name_original.clone();
2090        let album_name = edit.album_name_original.clone();
2091
2092        match (&track_name, &album_name) {
2093            (Some(track_name), Some(album_name)) => Box::new(crate::ExactMatchDiscovery::new(
2094                self.clone(),
2095                edit,
2096                track_name.clone(),
2097                album_name.clone(),
2098            )),
2099
2100            (Some(track_name), None) => Box::new(crate::TrackVariationsDiscovery::new(
2101                self.clone(),
2102                edit,
2103                track_name.clone(),
2104            )),
2105
2106            (None, Some(album_name)) => Box::new(crate::AlbumTracksDiscovery::new(
2107                self.clone(),
2108                edit,
2109                album_name.clone(),
2110            )),
2111
2112            (None, None) => Box::new(crate::ArtistTracksDiscovery::new(self.clone(), edit)),
2113        }
2114    }
2115
2116    fn artists(&self) -> Box<dyn crate::AsyncPaginatedIterator<crate::Artist>> {
2117        Box::new(crate::iterator::ArtistsIterator::new(self.clone()))
2118    }
2119
2120    fn artist_tracks(&self, artist: &str) -> Box<dyn crate::AsyncPaginatedIterator<Track>> {
2121        Box::new(crate::ArtistTracksIterator::new(
2122            self.clone(),
2123            artist.to_string(),
2124        ))
2125    }
2126
2127    fn artist_tracks_direct(&self, artist: &str) -> Box<dyn crate::AsyncPaginatedIterator<Track>> {
2128        Box::new(crate::iterator::ArtistTracksDirectIterator::new(
2129            self.clone(),
2130            artist.to_string(),
2131        ))
2132    }
2133
2134    fn artist_albums(&self, artist: &str) -> Box<dyn crate::AsyncPaginatedIterator<crate::Album>> {
2135        Box::new(crate::ArtistAlbumsIterator::new(
2136            self.clone(),
2137            artist.to_string(),
2138        ))
2139    }
2140
2141    fn album_tracks(
2142        &self,
2143        album_name: &str,
2144        artist_name: &str,
2145    ) -> Box<dyn crate::AsyncPaginatedIterator<Track>> {
2146        Box::new(crate::AlbumTracksIterator::new(
2147            self.clone(),
2148            album_name.to_string(),
2149            artist_name.to_string(),
2150        ))
2151    }
2152
2153    fn recent_tracks(&self) -> Box<dyn crate::AsyncPaginatedIterator<Track>> {
2154        Box::new(crate::RecentTracksIterator::new(self.clone()))
2155    }
2156
2157    fn recent_tracks_from_page(
2158        &self,
2159        starting_page: u32,
2160    ) -> Box<dyn crate::AsyncPaginatedIterator<Track>> {
2161        Box::new(crate::RecentTracksIterator::with_starting_page(
2162            self.clone(),
2163            starting_page,
2164        ))
2165    }
2166
2167    fn search_tracks(&self, query: &str) -> Box<dyn crate::AsyncPaginatedIterator<Track>> {
2168        Box::new(crate::SearchTracksIterator::new(
2169            self.clone(),
2170            query.to_string(),
2171        ))
2172    }
2173
2174    fn search_albums(&self, query: &str) -> Box<dyn crate::AsyncPaginatedIterator<crate::Album>> {
2175        Box::new(crate::SearchAlbumsIterator::new(
2176            self.clone(),
2177            query.to_string(),
2178        ))
2179    }
2180
2181    fn search_artists(&self, query: &str) -> Box<dyn crate::AsyncPaginatedIterator<crate::Artist>> {
2182        Box::new(crate::SearchArtistsIterator::new(
2183            self.clone(),
2184            query.to_string(),
2185        ))
2186    }
2187}
2188
2189#[async_trait(?Send)]
2190impl crate::api::LastFmApiClient for LastFmEditClientImpl {
2191    async fn api_get_recent_tracks_page_in_range(
2192        &self,
2193        page: u32,
2194        from: Option<u64>,
2195        to: Option<u64>,
2196    ) -> Result<TrackPage> {
2197        let api_key = self
2198            .api_key
2199            .as_ref()
2200            .ok_or_else(|| LastFmError::Auth("No API key configured".to_string()))?;
2201
2202        let username = self.username();
2203        crate::api::fetch_recent_tracks_page(
2204            &self.client,
2205            &self.broadcaster,
2206            &username,
2207            api_key,
2208            page,
2209            from,
2210            to,
2211        )
2212        .await
2213    }
2214}
2215
2216/// Wrapper that turns an `Arc<dyn HttpClient>` into `Box<dyn HttpClient>`.
2217///
2218/// This is used by `api_client()` so that the standalone `LastFmApiClientImpl`
2219/// can share the same underlying HTTP client as the edit client.
2220#[derive(Debug)]
2221struct ArcHttpClient(Arc<dyn HttpClient + Send + Sync>);
2222
2223#[async_trait::async_trait]
2224impl HttpClient for ArcHttpClient {
2225    async fn send(
2226        &self,
2227        req: http_client::Request,
2228    ) -> std::result::Result<http_client::Response, http_types::Error> {
2229        self.0.send(req).await
2230    }
2231}
2232
2233#[cfg(test)]
2234mod tests {
2235    use super::*;
2236
2237    /// Captured copy of the "Last.fm - Rate Limited" interstitial that last.fm serves
2238    /// (with a non-success HTTP status) when it throttles an account.
2239    const RATE_LIMITED_PAGE: &str = include_str!("../tests/fixtures/rate_limited_page.html");
2240
2241    /// HTTP client stub for tests that never issue requests.
2242    #[derive(Debug)]
2243    struct NoopHttpClient;
2244
2245    #[async_trait::async_trait]
2246    impl HttpClient for NoopHttpClient {
2247        async fn send(
2248            &self,
2249            _req: http_client::Request,
2250        ) -> std::result::Result<http_client::Response, http_types::Error> {
2251            Ok(http_types::Response::new(200))
2252        }
2253    }
2254
2255    fn test_client(config: ClientConfig) -> LastFmEditClientImpl {
2256        let session = LastFmEditSession::new(
2257            "test_user".to_string(),
2258            vec!["sessionid=.test_session_id".to_string()],
2259            Some("test_csrf_token".to_string()),
2260            "https://www.last.fm".to_string(),
2261        );
2262        LastFmEditClientImpl::from_session_with_client_config(
2263            Box::new(NoopHttpClient),
2264            session,
2265            config,
2266        )
2267    }
2268
2269    #[test]
2270    fn is_rate_limit_response_matches_captured_rate_limited_page() {
2271        let client = test_client(ClientConfig::default());
2272        assert!(client.is_rate_limit_response(RATE_LIMITED_PAGE));
2273    }
2274
2275    #[test]
2276    fn page_indicates_logged_out_detects_login_links() {
2277        // Logged-out library pages route actions through /login?next=...
2278        assert!(page_indicates_logged_out(
2279            r#"<a href="/login?next=/user/someone/library">Set track as current obsession</a>"#
2280        ));
2281        // A logged-in page with edit forms has no such link.
2282        assert!(!page_indicates_logged_out(
2283            r#"<form method="POST" data-edit-scrobble><input name="track_name"></form>"#
2284        ));
2285    }
2286
2287    /// HTTP client stub that panics inside `send`, like http-client's isahc adapter
2288    /// does when a server returns a status code outside http-types' enum.
2289    #[derive(Debug)]
2290    struct PanickingHttpClient;
2291
2292    #[async_trait::async_trait]
2293    impl HttpClient for PanickingHttpClient {
2294        async fn send(
2295            &self,
2296            _req: http_client::Request,
2297        ) -> std::result::Result<http_client::Response, http_types::Error> {
2298            panic!("Could not convert into a valid `StatusCode`: Invalid status code");
2299        }
2300    }
2301
2302    #[tokio::test]
2303    async fn guard_client_turns_send_panics_into_errors() {
2304        let client = guard_client(Box::new(PanickingHttpClient));
2305        let request = Request::new(Method::Get, "https://www.last.fm/".parse::<Url>().unwrap());
2306        let err = client
2307            .send(request)
2308            .await
2309            .expect_err("panic must become an error");
2310        assert!(
2311            err.to_string().contains("HTTP client panicked"),
2312            "unexpected error: {err}"
2313        );
2314    }
2315
2316    #[test]
2317    fn is_rate_limit_response_ignores_normal_page() {
2318        let client = test_client(ClientConfig::default());
2319        assert!(!client.is_rate_limit_response(
2320            "<html><head><title>Music | Last.fm</title></head><body>ok</body></html>"
2321        ));
2322    }
2323
2324    #[test]
2325    fn response_indicates_rate_limit_scopes_pattern_detection_to_non_success() {
2326        let client = test_client(ClientConfig::default());
2327
2328        // 200 OK whose body contains a pattern phrase (as a track title) is NOT rate limited.
2329        let ok_body = "<html><body><a class=\"track\">Slow Down</a> by Douglas</body></html>";
2330        assert!(!client.response_indicates_rate_limit(http_types::StatusCode::Ok, ok_body));
2331
2332        // 503 serving the captured interstitial IS rate limited.
2333        assert!(client.response_indicates_rate_limit(
2334            http_types::StatusCode::ServiceUnavailable,
2335            RATE_LIMITED_PAGE
2336        ));
2337
2338        // 403 with a plain body is NOT rate limited.
2339        assert!(!client.response_indicates_rate_limit(
2340            http_types::StatusCode::Forbidden,
2341            "<html><body>Forbidden</body></html>"
2342        ));
2343
2344        // 403 whose body matches a rate-limit pattern IS rate limited.
2345        assert!(client.response_indicates_rate_limit(
2346            http_types::StatusCode::Forbidden,
2347            "<html><body>you're requesting too many pages</body></html>"
2348        ));
2349    }
2350
2351    #[test]
2352    fn response_indicates_rate_limit_detects_200_status_interstitial() {
2353        let client = test_client(ClientConfig::default());
2354
2355        // 200 OK whose body contains a track literally named "Slow Down" is NOT rate limited:
2356        // the narrow success-pattern tier must not collide with library content.
2357        let track_body =
2358            "<html><body><a class=\"track\">Slow Down</a> by The Beatles</body></html>";
2359        assert!(!client.response_indicates_rate_limit(http_types::StatusCode::Ok, track_body));
2360
2361        // 200 OK serving the real soft-throttle interstitial sentence IS rate limited.
2362        let interstitial_body = "<html><body><p>You're requesting a lot of pages which may \
2363             slow down the site for other users</p></body></html>";
2364        assert!(client.response_indicates_rate_limit(http_types::StatusCode::Ok, interstitial_body));
2365    }
2366
2367    #[test]
2368    fn post_rate_limit_guard_rejects_406_interstitial_and_passes_normal_responses() {
2369        let client = test_client(ClientConfig::default());
2370        let interstitial = "<html><head><title>Last.fm - Rate Limited</title></head></html>";
2371
2372        // 406 interstitial (as served site-wide after edit bursts) yields a RateLimit error
2373        // instead of flowing on to edit analysis / delete interpretation.
2374        let err = client
2375            .check_post_response_for_rate_limit(
2376                "https://www.last.fm/user/x/library/edit",
2377                http_types::StatusCode::NotAcceptable,
2378                interstitial,
2379            )
2380            .unwrap_err();
2381        assert!(matches!(err, LastFmError::RateLimit { retry_after: 60 }));
2382
2383        // A normal edit response body passes through untouched.
2384        assert!(client
2385            .check_post_response_for_rate_limit(
2386                "https://www.last.fm/user/x/library/edit",
2387                http_types::StatusCode::Ok,
2388                "<div class=\"alert-success\">Your edit was saved</div>",
2389            )
2390            .is_ok());
2391    }
2392}