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::fs;
18use std::path::Path;
19use std::sync::{Arc, Mutex};
20
21#[derive(Clone)]
27pub struct LastFmEditClientImpl {
28 client: Arc<dyn HttpClient + Send + Sync>,
29 session: Arc<Mutex<LastFmEditSession>>,
30 rate_limit_patterns: Vec<String>,
31 debug_save_responses: bool,
32 parser: LastFmParser,
33 broadcaster: Arc<SharedEventBroadcaster>,
34}
35
36impl LastFmEditClientImpl {
37 pub fn from_session(
48 client: Box<dyn HttpClient + Send + Sync>,
49 session: LastFmEditSession,
50 ) -> Self {
51 Self::from_session_with_arc(Arc::from(client), session)
52 }
53
54 fn from_session_with_arc(
58 client: Arc<dyn HttpClient + Send + Sync>,
59 session: LastFmEditSession,
60 ) -> Self {
61 Self::from_session_with_broadcaster_arc(
62 client,
63 session,
64 Arc::new(SharedEventBroadcaster::new()),
65 )
66 }
67
68 pub fn from_session_with_rate_limit_patterns(
78 client: Box<dyn HttpClient + Send + Sync>,
79 session: LastFmEditSession,
80 rate_limit_patterns: Vec<String>,
81 ) -> Self {
82 Self {
83 client: Arc::from(client),
84 session: Arc::new(Mutex::new(session)),
85 rate_limit_patterns,
86 debug_save_responses: std::env::var("LASTFM_DEBUG_SAVE_RESPONSES").is_ok(),
87 parser: LastFmParser::new(),
88 broadcaster: Arc::new(SharedEventBroadcaster::new()),
89 }
90 }
91
92 pub async fn login_with_credentials(
107 client: Box<dyn HttpClient + Send + Sync>,
108 username: &str,
109 password: &str,
110 ) -> Result<Self> {
111 let client_arc: Arc<dyn HttpClient + Send + Sync> = Arc::from(client);
112 let login_manager =
113 crate::login::LoginManager::new(client_arc.clone(), "https://www.last.fm".to_string());
114 let session = login_manager.login(username, password).await?;
115 Ok(Self::from_session_with_arc(client_arc, session))
116 }
117
118 fn from_session_with_broadcaster(
133 client: Box<dyn HttpClient + Send + Sync>,
134 session: LastFmEditSession,
135 broadcaster: Arc<SharedEventBroadcaster>,
136 ) -> Self {
137 Self::from_session_with_broadcaster_arc(Arc::from(client), session, broadcaster)
138 }
139
140 fn from_session_with_broadcaster_arc(
142 client: Arc<dyn HttpClient + Send + Sync>,
143 session: LastFmEditSession,
144 broadcaster: Arc<SharedEventBroadcaster>,
145 ) -> Self {
146 Self {
147 client,
148 session: Arc::new(Mutex::new(session)),
149 rate_limit_patterns: vec![
150 "you've tried to log in too many times".to_string(),
151 "you're requesting too many pages".to_string(),
152 "slow down".to_string(),
153 "too fast".to_string(),
154 "rate limit".to_string(),
155 "throttled".to_string(),
156 "temporarily blocked".to_string(),
157 "temporarily restricted".to_string(),
158 "captcha".to_string(),
159 "verify you're human".to_string(),
160 "prove you're not a robot".to_string(),
161 "security check".to_string(),
162 "service temporarily unavailable".to_string(),
163 "quota exceeded".to_string(),
164 "limit exceeded".to_string(),
165 "daily limit".to_string(),
166 ],
167 debug_save_responses: std::env::var("LASTFM_DEBUG_SAVE_RESPONSES").is_ok(),
168 parser: LastFmParser::new(),
169 broadcaster,
170 }
171 }
172
173 pub fn get_session(&self) -> LastFmEditSession {
175 self.session.lock().unwrap().clone()
176 }
177
178 pub fn restore_session(&self, session: LastFmEditSession) {
180 *self.session.lock().unwrap() = session;
181 }
182
183 pub fn with_shared_broadcaster(&self, client: Box<dyn HttpClient + Send + Sync>) -> Self {
197 let session = self.get_session();
198 Self::from_session_with_broadcaster(client, session, self.broadcaster.clone())
199 }
200
201 pub fn username(&self) -> String {
205 self.session.lock().unwrap().username.clone()
206 }
207
208 pub fn subscribe(&self) -> ClientEventReceiver {
210 self.broadcaster.subscribe()
211 }
212
213 pub fn latest_event(&self) -> Option<ClientEvent> {
215 self.broadcaster.latest_event()
216 }
217
218 fn broadcast_event(&self, event: ClientEvent) {
222 self.broadcaster.broadcast_event(event);
223 }
224
225 pub async fn get_recent_scrobbles(&self, page: u32) -> Result<Vec<Track>> {
228 let url = {
229 let session = self.session.lock().unwrap();
230 format!(
231 "{}/user/{}/library?page={}",
232 session.base_url, session.username, page
233 )
234 };
235
236 log::debug!("Fetching recent scrobbles page {page}");
237 let mut response = self.get(&url).await?;
238 let content = response
239 .body_string()
240 .await
241 .map_err(|e| LastFmError::Http(e.to_string()))?;
242
243 log::debug!(
244 "Recent scrobbles response: {} status, {} chars",
245 response.status(),
246 content.len()
247 );
248
249 let document = Html::parse_document(&content);
250 self.parser.parse_recent_scrobbles(&document)
251 }
252
253 pub async fn get_recent_tracks_page(&self, page: u32) -> Result<TrackPage> {
255 let tracks = self.get_recent_scrobbles(page).await?;
256
257 let has_next_page = !tracks.is_empty(); Ok(TrackPage {
262 tracks,
263 page_number: page,
264 has_next_page,
265 total_pages: None, })
267 }
268
269 pub async fn find_recent_scrobble_for_track(
272 &self,
273 track_name: &str,
274 artist_name: &str,
275 max_pages: u32,
276 ) -> Result<Option<Track>> {
277 log::debug!("Searching for recent scrobble: '{track_name}' by '{artist_name}'");
278
279 for page in 1..=max_pages {
280 let scrobbles = self.get_recent_scrobbles(page).await?;
281
282 for scrobble in scrobbles {
283 if scrobble.name == track_name && scrobble.artist == artist_name {
284 log::debug!(
285 "Found recent scrobble: '{}' with timestamp {:?}",
286 scrobble.name,
287 scrobble.timestamp
288 );
289 return Ok(Some(scrobble));
290 }
291 }
292 }
293
294 log::debug!(
295 "No recent scrobble found for '{track_name}' by '{artist_name}' in {max_pages} pages"
296 );
297 Ok(None)
298 }
299
300 pub async fn edit_scrobble(&self, edit: &ScrobbleEdit) -> Result<EditResponse> {
301 let discovered_edits = self.discover_scrobble_edit_variations(edit).await?;
303
304 if discovered_edits.is_empty() {
305 let context = match (&edit.track_name_original, &edit.album_name_original) {
306 (Some(track_name), _) => {
307 format!("track '{}' by '{}'", track_name, edit.artist_name_original)
308 }
309 (None, Some(album_name)) => {
310 format!("album '{}' by '{}'", album_name, edit.artist_name_original)
311 }
312 (None, None) => format!("artist '{}'", edit.artist_name_original),
313 };
314 return Err(LastFmError::Parse(format!(
315 "No scrobbles found for {context}. Make sure the names are correct and that you have scrobbled recently."
316 )));
317 }
318
319 log::info!(
320 "Discovered {} scrobble instances to edit",
321 discovered_edits.len()
322 );
323
324 let mut all_results = Vec::new();
325
326 for (index, discovered_edit) in discovered_edits.iter().enumerate() {
328 log::debug!(
329 "Processing scrobble {}/{}: '{}' from '{}'",
330 index + 1,
331 discovered_edits.len(),
332 discovered_edit.track_name_original,
333 discovered_edit.album_name_original
334 );
335
336 let mut modified_exact_edit = discovered_edit.clone();
338
339 if let Some(new_track_name) = &edit.track_name {
341 modified_exact_edit.track_name = new_track_name.clone();
342 }
343 if let Some(new_album_name) = &edit.album_name {
344 modified_exact_edit.album_name = new_album_name.clone();
345 }
346 modified_exact_edit.artist_name = edit.artist_name.clone();
347 if let Some(new_album_artist_name) = &edit.album_artist_name {
348 modified_exact_edit.album_artist_name = new_album_artist_name.clone();
349 }
350 modified_exact_edit.edit_all = edit.edit_all;
351
352 let album_info = format!(
353 "{} by {}",
354 modified_exact_edit.album_name_original,
355 modified_exact_edit.album_artist_name_original
356 );
357
358 let single_response = self.edit_scrobble_single(&modified_exact_edit, 3).await?;
359 let success = single_response.success();
360 let message = single_response.message();
361
362 all_results.push(SingleEditResponse {
363 success,
364 message,
365 album_info: Some(album_info),
366 exact_scrobble_edit: modified_exact_edit.clone(),
367 });
368
369 if index < discovered_edits.len() - 1 {
371 tokio::time::sleep(std::time::Duration::from_millis(1000)).await;
372 }
373 }
374
375 Ok(EditResponse::from_results(all_results))
376 }
377
378 pub async fn edit_scrobble_single(
389 &self,
390 exact_edit: &ExactScrobbleEdit,
391 max_retries: u32,
392 ) -> Result<EditResponse> {
393 let config = RetryConfig {
394 max_retries,
395 base_delay: 5,
396 max_delay: 300,
397 };
398
399 let edit_clone = exact_edit.clone();
400 let client = self.clone();
401
402 match retry::retry_with_backoff(
403 config,
404 "Edit scrobble",
405 || client.edit_scrobble_impl(&edit_clone),
406 |delay, operation_name| {
407 self.broadcast_event(ClientEvent::RateLimited {
408 delay_seconds: delay,
409 request: None, rate_limit_type: RateLimitType::ResponsePattern,
411 });
412 log::debug!("{operation_name} rate limited, waiting {delay} seconds");
413 },
414 )
415 .await
416 {
417 Ok(retry_result) => Ok(EditResponse::single(
418 retry_result.result,
419 None,
420 None,
421 exact_edit.clone(),
422 )),
423 Err(LastFmError::RateLimit { .. }) => Ok(EditResponse::single(
424 false,
425 Some(format!("Rate limit exceeded after {max_retries} retries")),
426 None,
427 exact_edit.clone(),
428 )),
429 Err(other_error) => Ok(EditResponse::single(
430 false,
431 Some(other_error.to_string()),
432 None,
433 exact_edit.clone(),
434 )),
435 }
436 }
437
438 async fn edit_scrobble_impl(&self, exact_edit: &ExactScrobbleEdit) -> Result<bool> {
439 let start_time = std::time::Instant::now();
440 let result = self.edit_scrobble_impl_internal(exact_edit).await;
441 let duration_ms = start_time.elapsed().as_millis() as u64;
442
443 match &result {
445 Ok(success) => {
446 self.broadcast_event(ClientEvent::EditAttempted {
447 edit: exact_edit.clone(),
448 success: *success,
449 error_message: None,
450 duration_ms,
451 });
452 }
453 Err(error) => {
454 self.broadcast_event(ClientEvent::EditAttempted {
455 edit: exact_edit.clone(),
456 success: false,
457 error_message: Some(error.to_string()),
458 duration_ms,
459 });
460 }
461 }
462
463 result
464 }
465
466 async fn edit_scrobble_impl_internal(&self, exact_edit: &ExactScrobbleEdit) -> Result<bool> {
467 let edit_url = {
468 let session = self.session.lock().unwrap();
469 format!(
470 "{}/user/{}/library/edit?edited-variation=library-track-scrobble",
471 session.base_url, session.username
472 )
473 };
474
475 log::debug!("Getting fresh CSRF token for edit");
476
477 let form_html = self.get_edit_form_html(&edit_url).await?;
479
480 let form_document = Html::parse_document(&form_html);
482 let fresh_csrf_token = self.extract_csrf_token(&form_document)?;
483
484 log::debug!("Submitting edit with fresh token");
485
486 let form_data = exact_edit.build_form_data(&fresh_csrf_token);
487
488 log::debug!(
489 "Editing scrobble: '{}' -> '{}'",
490 exact_edit.track_name_original,
491 exact_edit.track_name
492 );
493 {
494 let session = self.session.lock().unwrap();
495 log::trace!("Session cookies count: {}", session.cookies.len());
496 }
497
498 let mut request = Request::new(Method::Post, edit_url.parse::<Url>().unwrap());
499
500 let referer_url = {
502 let session = self.session.lock().unwrap();
503 headers::add_cookies(&mut request, &session.cookies);
504 format!("{}/user/{}/library", session.base_url, session.username)
505 };
506
507 headers::add_edit_headers(&mut request, &referer_url);
508
509 let form_string: String = form_data
511 .iter()
512 .map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v)))
513 .collect::<Vec<_>>()
514 .join("&");
515
516 request.set_body(form_string);
517
518 let request_info = RequestInfo::from_url_and_method(&edit_url, "POST");
520 let request_start = std::time::Instant::now();
521
522 self.broadcast_event(ClientEvent::RequestStarted {
524 request: request_info.clone(),
525 });
526
527 let mut response = self
528 .client
529 .send(request)
530 .await
531 .map_err(|e| LastFmError::Http(e.to_string()))?;
532
533 self.broadcast_event(ClientEvent::RequestCompleted {
535 request: request_info.clone(),
536 status_code: response.status().into(),
537 duration_ms: request_start.elapsed().as_millis() as u64,
538 });
539
540 log::debug!("Edit response status: {}", response.status());
541
542 let response_text = response
543 .body_string()
544 .await
545 .map_err(|e| LastFmError::Http(e.to_string()))?;
546
547 let analysis = edit_analysis::analyze_edit_response(&response_text, response.status());
549
550 Ok(analysis.success)
551 }
552
553 async fn get_edit_form_html(&self, edit_url: &str) -> Result<String> {
556 let mut form_response = self.get(edit_url).await?;
557 let form_html = form_response
558 .body_string()
559 .await
560 .map_err(|e| LastFmError::Http(e.to_string()))?;
561
562 log::debug!("Edit form response status: {}", form_response.status());
563 Ok(form_html)
564 }
565
566 pub async fn load_edit_form_values_internal(
569 &self,
570 track_name: &str,
571 artist_name: &str,
572 ) -> Result<Vec<ExactScrobbleEdit>> {
573 log::debug!("Loading edit form values for '{track_name}' by '{artist_name}'");
574
575 let base_track_url = {
579 let session = self.session.lock().unwrap();
580 format!(
581 "{}/user/{}/library/music/+noredirect/{}/_/{}",
582 session.base_url,
583 session.username,
584 urlencoding::encode(artist_name),
585 urlencoding::encode(track_name)
586 )
587 };
588
589 log::debug!("Fetching track page: {base_track_url}");
590
591 let mut response = self.get(&base_track_url).await?;
592 let html = response
593 .body_string()
594 .await
595 .map_err(|e| crate::LastFmError::Http(e.to_string()))?;
596
597 let document = Html::parse_document(&html);
598
599 let mut all_scrobble_edits = Vec::new();
601 let mut unique_albums = std::collections::HashSet::new();
602 let max_pages = 5;
603
604 let page_edits = self.extract_scrobble_edits_from_page(
606 &document,
607 track_name,
608 artist_name,
609 &mut unique_albums,
610 )?;
611 all_scrobble_edits.extend(page_edits);
612
613 log::debug!(
614 "Page 1: found {} unique album variations",
615 all_scrobble_edits.len()
616 );
617
618 let pagination_selector = Selector::parse(".pagination .pagination-next").unwrap();
620 let mut has_next_page = document.select(&pagination_selector).next().is_some();
621 let mut page = 2;
622
623 while has_next_page && page <= max_pages {
624 let page_url = {
626 let session = self.session.lock().unwrap();
627 format!(
628 "{}/user/{}/library/music/{}/_/{}?page={page}",
629 session.base_url,
630 session.username,
631 urlencoding::encode(artist_name),
632 urlencoding::encode(track_name)
633 )
634 };
635
636 log::debug!("Fetching page {page} for additional album variations");
637
638 let mut response = self.get(&page_url).await?;
639 let html = response
640 .body_string()
641 .await
642 .map_err(|e| crate::LastFmError::Http(e.to_string()))?;
643
644 let document = Html::parse_document(&html);
645
646 let page_edits = self.extract_scrobble_edits_from_page(
647 &document,
648 track_name,
649 artist_name,
650 &mut unique_albums,
651 )?;
652
653 let initial_count = all_scrobble_edits.len();
654 all_scrobble_edits.extend(page_edits);
655 let found_new_unique_albums = all_scrobble_edits.len() > initial_count;
656
657 has_next_page = document.select(&pagination_selector).next().is_some();
659
660 log::debug!(
661 "Page {page}: found {} total unique albums ({})",
662 all_scrobble_edits.len(),
663 if found_new_unique_albums {
664 "new albums found"
665 } else {
666 "no new unique albums"
667 }
668 );
669
670 page += 1;
673 }
674
675 if all_scrobble_edits.is_empty() {
676 return Err(crate::LastFmError::Parse(format!(
677 "No scrobble forms found for track '{track_name}' by '{artist_name}'"
678 )));
679 }
680
681 log::debug!(
682 "Final result: found {} unique album variations for '{track_name}' by '{artist_name}'",
683 all_scrobble_edits.len(),
684 );
685
686 Ok(all_scrobble_edits)
687 }
688
689 fn extract_scrobble_edits_from_page(
692 &self,
693 document: &Html,
694 expected_track: &str,
695 expected_artist: &str,
696 unique_albums: &mut std::collections::HashSet<(String, String)>,
697 ) -> Result<Vec<ExactScrobbleEdit>> {
698 let mut scrobble_edits = Vec::new();
699 let table_selector =
701 Selector::parse("table.chartlist:not(.chartlist__placeholder)").unwrap();
702 let table = document.select(&table_selector).next().ok_or_else(|| {
703 crate::LastFmError::Parse("No chartlist table found on track page".to_string())
704 })?;
705
706 let row_selector = Selector::parse("tr").unwrap();
708 for row in table.select(&row_selector) {
709 let count_bar_link_selector = Selector::parse(".chartlist-count-bar-link").unwrap();
711 if row.select(&count_bar_link_selector).next().is_some() {
712 log::debug!("Found count bar link, skipping aggregated row");
713 continue;
714 }
715
716 let form_selector = Selector::parse("form[data-edit-scrobble]").unwrap();
718 if let Some(form) = row.select(&form_selector).next() {
719 let extract_form_value = |name: &str| -> Option<String> {
721 let selector = Selector::parse(&format!("input[name='{name}']")).unwrap();
722 form.select(&selector)
723 .next()
724 .and_then(|input| input.value().attr("value"))
725 .map(|s| s.to_string())
726 };
727
728 let form_track = extract_form_value("track_name").unwrap_or_default();
730 let form_artist = extract_form_value("artist_name").unwrap_or_default();
731 let form_album = extract_form_value("album_name").unwrap_or_default();
732 let form_album_artist =
733 extract_form_value("album_artist_name").unwrap_or_else(|| form_artist.clone());
734 let form_timestamp = extract_form_value("timestamp").unwrap_or_default();
735
736 log::debug!(
737 "Found scrobble form - Track: '{form_track}', Artist: '{form_artist}', Album: '{form_album}', Timestamp: {form_timestamp}"
738 );
739
740 if form_track == expected_track && form_artist == expected_artist {
742 let album_key = (form_album.clone(), form_album_artist.clone());
744 if unique_albums.insert(album_key) {
745 let timestamp = if form_timestamp.is_empty() {
747 None
748 } else {
749 form_timestamp.parse::<u64>().ok()
750 };
751
752 if let Some(timestamp) = timestamp {
753 log::debug!(
754 "✅ Found unique album variation: '{form_album}' by '{form_album_artist}' for '{expected_track}' by '{expected_artist}'"
755 );
756
757 let scrobble_edit = ExactScrobbleEdit::new(
759 form_track.clone(),
760 form_album.clone(),
761 form_artist.clone(),
762 form_album_artist.clone(),
763 form_track,
764 form_album,
765 form_artist,
766 form_album_artist,
767 timestamp,
768 true,
769 );
770 scrobble_edits.push(scrobble_edit);
771 } else {
772 log::debug!(
773 "⚠️ Skipping album variation without valid timestamp: '{form_album}' by '{form_album_artist}'"
774 );
775 }
776 }
777 }
778 }
779 }
780
781 Ok(scrobble_edits)
782 }
783
784 pub async fn get_artist_tracks_page(&self, artist: &str, page: u32) -> Result<TrackPage> {
785 let url = {
787 let session = self.session.lock().unwrap();
788 format!(
789 "{}/user/{}/library/music/{}/+tracks?page={}&ajax=true",
790 session.base_url,
791 session.username,
792 artist.replace(" ", "+"),
793 page
794 )
795 };
796
797 log::debug!("Fetching tracks page {page} for artist: {artist}");
798 let mut response = self.get(&url).await?;
799 let content = response
800 .body_string()
801 .await
802 .map_err(|e| LastFmError::Http(e.to_string()))?;
803
804 log::debug!(
805 "AJAX response: {} status, {} chars",
806 response.status(),
807 content.len()
808 );
809
810 log::debug!("Parsing HTML response from AJAX endpoint");
811 let document = Html::parse_document(&content);
812 self.parser.parse_tracks_page(&document, page, artist, None)
813 }
814
815 pub fn extract_tracks_from_document(
817 &self,
818 document: &Html,
819 artist: &str,
820 album: Option<&str>,
821 ) -> Result<Vec<Track>> {
822 self.parser
823 .extract_tracks_from_document(document, artist, album)
824 }
825
826 pub fn parse_tracks_page(
828 &self,
829 document: &Html,
830 page_number: u32,
831 artist: &str,
832 album: Option<&str>,
833 ) -> Result<TrackPage> {
834 self.parser
835 .parse_tracks_page(document, page_number, artist, album)
836 }
837
838 pub fn parse_recent_scrobbles(&self, document: &Html) -> Result<Vec<Track>> {
840 self.parser.parse_recent_scrobbles(document)
841 }
842
843 fn extract_csrf_token(&self, document: &Html) -> Result<String> {
844 let csrf_selector = Selector::parse("input[name=\"csrfmiddlewaretoken\"]").unwrap();
845
846 document
847 .select(&csrf_selector)
848 .next()
849 .and_then(|input| input.value().attr("value"))
850 .map(|token| token.to_string())
851 .ok_or(LastFmError::CsrfNotFound)
852 }
853
854 pub async fn get(&self, url: &str) -> Result<Response> {
856 self.get_with_retry(url, 3).await
857 }
858
859 async fn get_with_retry(&self, url: &str, max_retries: u32) -> Result<Response> {
861 let config = RetryConfig {
862 max_retries,
863 base_delay: 30, max_delay: 300,
865 };
866
867 let url_string = url.to_string();
868 let client = self.clone();
869
870 let retry_result = retry::retry_with_backoff(
871 config,
872 &format!("GET {url}"),
873 || async {
874 let mut response = client.get_with_redirects(&url_string, 0).await?;
875
876 let body = client
878 .extract_response_body(&url_string, &mut response)
879 .await?;
880
881 if response.status().is_success() && client.is_rate_limit_response(&body) {
883 log::debug!("Response body contains rate limit patterns");
884 return Err(LastFmError::RateLimit { retry_after: 60 });
885 }
886
887 let mut new_response = http_types::Response::new(response.status());
889 for (name, values) in response.iter() {
890 for value in values {
891 let _ = new_response.insert_header(name.clone(), value.clone());
892 }
893 }
894 new_response.set_body(body);
895
896 Ok(new_response)
897 },
898 |delay, operation_name| {
899 self.broadcast_event(ClientEvent::RateLimited {
900 delay_seconds: delay,
901 request: None, rate_limit_type: RateLimitType::ResponsePattern,
903 });
904 log::debug!("{operation_name} rate limited, waiting {delay} seconds");
905 },
906 )
907 .await?;
908
909 Ok(retry_result.result)
910 }
911
912 async fn get_with_redirects(&self, url: &str, redirect_count: u32) -> Result<Response> {
913 if redirect_count > 5 {
914 return Err(LastFmError::Http("Too many redirects".to_string()));
915 }
916
917 let mut request = Request::new(Method::Get, url.parse::<Url>().unwrap());
918
919 {
921 let session = self.session.lock().unwrap();
922 headers::add_cookies(&mut request, &session.cookies);
923 if session.cookies.is_empty() && url.contains("page=") {
924 log::debug!("No cookies available for paginated request!");
925 }
926 }
927
928 let is_ajax = url.contains("ajax=true");
930 let referer_url = if url.contains("page=") {
931 Some(url.split('?').next().unwrap_or(url))
932 } else {
933 None
934 };
935
936 headers::add_get_headers(&mut request, is_ajax, referer_url);
937
938 let request_info = RequestInfo::from_url_and_method(url, "GET");
940 let request_start = std::time::Instant::now();
941
942 self.broadcast_event(ClientEvent::RequestStarted {
944 request: request_info.clone(),
945 });
946
947 let response = self
948 .client
949 .send(request)
950 .await
951 .map_err(|e| LastFmError::Http(e.to_string()))?;
952
953 self.broadcast_event(ClientEvent::RequestCompleted {
955 request: request_info.clone(),
956 status_code: response.status().into(),
957 duration_ms: request_start.elapsed().as_millis() as u64,
958 });
959
960 self.extract_cookies(&response);
962
963 if response.status() == 302 || response.status() == 301 {
965 if let Some(location) = response.header("location") {
966 if let Some(redirect_url) = location.get(0) {
967 let redirect_url_str = redirect_url.as_str();
968 if url.contains("page=") {
969 log::debug!("Following redirect from {url} to {redirect_url_str}");
970
971 if redirect_url_str.contains("/login") {
973 log::debug!("Redirect to login page - authentication failed for paginated request");
974 return Err(LastFmError::Auth(
975 "Session expired or invalid for paginated request".to_string(),
976 ));
977 }
978 }
979
980 let full_redirect_url = if redirect_url_str.starts_with('/') {
982 let base_url = self.session.lock().unwrap().base_url.clone();
983 format!("{base_url}{redirect_url_str}")
984 } else if redirect_url_str.starts_with("http") {
985 redirect_url_str.to_string()
986 } else {
987 let base_url = url
989 .rsplit('/')
990 .skip(1)
991 .collect::<Vec<_>>()
992 .into_iter()
993 .rev()
994 .collect::<Vec<_>>()
995 .join("/");
996 format!("{base_url}/{redirect_url_str}")
997 };
998
999 return Box::pin(
1001 self.get_with_redirects(&full_redirect_url, redirect_count + 1),
1002 )
1003 .await;
1004 }
1005 }
1006 }
1007
1008 if response.status() == 429 {
1010 let retry_after = response
1011 .header("retry-after")
1012 .and_then(|h| h.get(0))
1013 .and_then(|v| v.as_str().parse::<u64>().ok())
1014 .unwrap_or(60);
1015 self.broadcast_event(ClientEvent::RateLimited {
1016 delay_seconds: retry_after,
1017 request: Some(request_info.clone()),
1018 rate_limit_type: RateLimitType::Http429,
1019 });
1020 return Err(LastFmError::RateLimit { retry_after });
1021 }
1022
1023 if response.status() == 403 {
1025 log::debug!("Got 403 response, checking if it's a rate limit");
1026 {
1028 let session = self.session.lock().unwrap();
1029 if !session.cookies.is_empty() {
1030 log::debug!("403 on authenticated request - likely rate limit");
1031 self.broadcast_event(ClientEvent::RateLimited {
1032 delay_seconds: 60,
1033 request: Some(request_info.clone()),
1034 rate_limit_type: RateLimitType::Http403,
1035 });
1036 return Err(LastFmError::RateLimit { retry_after: 60 });
1037 }
1038 }
1039 }
1040
1041 Ok(response)
1042 }
1043
1044 fn is_rate_limit_response(&self, response_body: &str) -> bool {
1046 let body_lower = response_body.to_lowercase();
1047
1048 for pattern in &self.rate_limit_patterns {
1050 if body_lower.contains(&pattern.to_lowercase()) {
1051 return true;
1052 }
1053 }
1054
1055 false
1056 }
1057
1058 fn extract_cookies(&self, response: &Response) {
1059 let mut session = self.session.lock().unwrap();
1060 extract_cookies_from_response(response, &mut session.cookies);
1061 }
1062
1063 async fn extract_response_body(&self, url: &str, response: &mut Response) -> Result<String> {
1065 let body = response
1066 .body_string()
1067 .await
1068 .map_err(|e| LastFmError::Http(e.to_string()))?;
1069
1070 if self.debug_save_responses {
1071 self.save_debug_response(url, response.status().into(), &body);
1072 }
1073
1074 Ok(body)
1075 }
1076
1077 fn save_debug_response(&self, url: &str, status_code: u16, body: &str) {
1079 if let Err(e) = self.try_save_debug_response(url, status_code, body) {
1080 log::warn!("Failed to save debug response: {e}");
1081 }
1082 }
1083
1084 fn try_save_debug_response(&self, url: &str, status_code: u16, body: &str) -> Result<()> {
1086 let debug_dir = Path::new("debug_responses");
1088 if !debug_dir.exists() {
1089 fs::create_dir_all(debug_dir)
1090 .map_err(|e| LastFmError::Http(format!("Failed to create debug directory: {e}")))?;
1091 }
1092
1093 let url_path = {
1095 let session = self.session.lock().unwrap();
1096 if url.starts_with(&session.base_url) {
1097 &url[session.base_url.len()..]
1098 } else {
1099 url
1100 }
1101 };
1102
1103 let now = chrono::Utc::now();
1105 let timestamp = now.format("%Y%m%d_%H%M%S_%3f");
1106 let safe_path = url_path.replace(['/', '?', '&', '=', '%', '+'], "_");
1107
1108 let filename = format!("{timestamp}_{safe_path}_status{status_code}.html");
1109 let file_path = debug_dir.join(filename);
1110
1111 fs::write(&file_path, body)
1113 .map_err(|e| LastFmError::Http(format!("Failed to write debug file: {e}")))?;
1114
1115 log::debug!(
1116 "Saved HTTP response to {file_path:?} (status: {status_code}, url: {url_path})"
1117 );
1118
1119 Ok(())
1120 }
1121
1122 pub async fn get_artist_albums_page(&self, artist: &str, page: u32) -> Result<AlbumPage> {
1123 let url = {
1125 let session = self.session.lock().unwrap();
1126 format!(
1127 "{}/user/{}/library/music/{}/+albums?page={}&ajax=true",
1128 session.base_url,
1129 session.username,
1130 artist.replace(" ", "+"),
1131 page
1132 )
1133 };
1134
1135 log::debug!("Fetching albums page {page} for artist: {artist}");
1136 let mut response = self.get(&url).await?;
1137 let content = response
1138 .body_string()
1139 .await
1140 .map_err(|e| LastFmError::Http(e.to_string()))?;
1141
1142 log::debug!(
1143 "AJAX response: {} status, {} chars",
1144 response.status(),
1145 content.len()
1146 );
1147
1148 log::debug!("Parsing HTML response from AJAX endpoint");
1149 let document = Html::parse_document(&content);
1150 self.parser.parse_albums_page(&document, page, artist)
1151 }
1152}
1153
1154#[async_trait(?Send)]
1155impl LastFmEditClient for LastFmEditClientImpl {
1156 fn username(&self) -> String {
1157 self.username()
1158 }
1159
1160 async fn get_recent_scrobbles(&self, page: u32) -> Result<Vec<Track>> {
1161 self.get_recent_scrobbles(page).await
1162 }
1163
1164 async fn find_recent_scrobble_for_track(
1165 &self,
1166 track_name: &str,
1167 artist_name: &str,
1168 max_pages: u32,
1169 ) -> Result<Option<Track>> {
1170 self.find_recent_scrobble_for_track(track_name, artist_name, max_pages)
1171 .await
1172 }
1173
1174 async fn edit_scrobble(&self, edit: &ScrobbleEdit) -> Result<EditResponse> {
1175 self.edit_scrobble(edit).await
1176 }
1177
1178 async fn edit_scrobble_single(
1179 &self,
1180 exact_edit: &ExactScrobbleEdit,
1181 max_retries: u32,
1182 ) -> Result<EditResponse> {
1183 self.edit_scrobble_single(exact_edit, max_retries).await
1184 }
1185
1186 fn get_session(&self) -> LastFmEditSession {
1187 self.get_session()
1188 }
1189
1190 fn restore_session(&self, session: LastFmEditSession) {
1191 self.restore_session(session)
1192 }
1193
1194 fn subscribe(&self) -> ClientEventReceiver {
1195 self.subscribe()
1196 }
1197
1198 fn latest_event(&self) -> Option<ClientEvent> {
1199 self.latest_event()
1200 }
1201
1202 fn discover_scrobbles(
1203 &self,
1204 edit: ScrobbleEdit,
1205 ) -> Box<dyn crate::AsyncDiscoveryIterator<crate::ExactScrobbleEdit>> {
1206 let track_name = edit.track_name_original.clone();
1207 let album_name = edit.album_name_original.clone();
1208
1209 match (&track_name, &album_name) {
1210 (Some(track_name), Some(album_name)) => Box::new(crate::ExactMatchDiscovery::new(
1212 self.clone(),
1213 edit,
1214 track_name.clone(),
1215 album_name.clone(),
1216 )),
1217
1218 (Some(track_name), None) => Box::new(crate::TrackVariationsDiscovery::new(
1220 self.clone(),
1221 edit,
1222 track_name.clone(),
1223 )),
1224
1225 (None, Some(album_name)) => Box::new(crate::AlbumTracksDiscovery::new(
1227 self.clone(),
1228 edit,
1229 album_name.clone(),
1230 )),
1231
1232 (None, None) => Box::new(crate::ArtistTracksDiscovery::new(self.clone(), edit)),
1234 }
1235 }
1236
1237 async fn get_artist_tracks_page(&self, artist: &str, page: u32) -> Result<TrackPage> {
1238 self.get_artist_tracks_page(artist, page).await
1239 }
1240
1241 async fn get_artist_albums_page(&self, artist: &str, page: u32) -> Result<AlbumPage> {
1242 self.get_artist_albums_page(artist, page).await
1243 }
1244
1245 fn artist_tracks(&self, artist: &str) -> crate::ArtistTracksIterator {
1246 crate::ArtistTracksIterator::new(self.clone(), artist.to_string())
1247 }
1248
1249 fn artist_albums(&self, artist: &str) -> crate::ArtistAlbumsIterator {
1250 crate::ArtistAlbumsIterator::new(self.clone(), artist.to_string())
1251 }
1252
1253 fn album_tracks(&self, album_name: &str, artist_name: &str) -> crate::AlbumTracksIterator {
1254 crate::AlbumTracksIterator::new(
1255 self.clone(),
1256 album_name.to_string(),
1257 artist_name.to_string(),
1258 )
1259 }
1260
1261 fn recent_tracks(&self) -> crate::RecentTracksIterator {
1262 crate::RecentTracksIterator::new(self.clone())
1263 }
1264
1265 fn recent_tracks_from_page(&self, starting_page: u32) -> crate::RecentTracksIterator {
1266 crate::RecentTracksIterator::with_starting_page(self.clone(), starting_page)
1267 }
1268}