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#[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
65fn guard_client(client: Box<dyn HttpClient + Send + Sync>) -> Arc<dyn HttpClient + Send + Sync> {
67 Arc::new(PanicGuardClient(client))
68}
69
70fn page_indicates_logged_out(html: &str) -> bool {
77 html.contains("/login?next=")
78}
79
80impl LastFmEditClientImpl {
81 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 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 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", ×tamp.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 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 pub fn rate_limit_state(&self) -> crate::types::RateLimitState {
527 self.broadcaster.rate_limit_state()
528 }
529
530 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 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 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, 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 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(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 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 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 }
1060 Err(crate::LastFmError::Parse(msg))
1061 if msg == "No chartlist table found on track page" =>
1062 {
1063 }
1065 Err(e) => return Err(e),
1066 }
1067 }
1068
1069 let base_track_url_root = base_track_url_root.ok_or_else(|| {
1070 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 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 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 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 if self.response_indicates_rate_limit(status, &body) {
1374 log::debug!("Response body contains rate limit patterns");
1375 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, 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 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 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 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 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 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 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 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 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 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 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 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#[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 const RATE_LIMITED_PAGE: &str = include_str!("../tests/fixtures/rate_limited_page.html");
2240
2241 #[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 assert!(page_indicates_logged_out(
2279 r#"<a href="/login?next=/user/someone/library">Set track as current obsession</a>"#
2280 ));
2281 assert!(!page_indicates_logged_out(
2283 r#"<form method="POST" data-edit-scrobble><input name="track_name"></form>"#
2284 ));
2285 }
2286
2287 #[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 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 assert!(client.response_indicates_rate_limit(
2334 http_types::StatusCode::ServiceUnavailable,
2335 RATE_LIMITED_PAGE
2336 ));
2337
2338 assert!(!client.response_indicates_rate_limit(
2340 http_types::StatusCode::Forbidden,
2341 "<html><body>Forbidden</body></html>"
2342 ));
2343
2344 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 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 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 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 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}