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