1use crate::parsing::LastFmParser;
2use crate::session::ClientSession;
3use crate::{
4 AlbumPage, ArtistAlbumsIterator, ArtistTracksIterator, AsyncPaginatedIterator, EditResponse,
5 LastFmError, RecentTracksIterator, Result, ScrobbleEdit, Track, TrackPage,
6};
7use http_client::{HttpClient, Request, Response};
8use http_types::{Method, Url};
9use scraper::{Html, Selector};
10use std::collections::HashMap;
11use std::fs;
12use std::path::Path;
13
14pub struct LastFmEditClient {
40 client: Box<dyn HttpClient + Send + Sync>,
41 username: String,
42 csrf_token: Option<String>,
43 base_url: String,
44 session_cookies: Vec<String>,
45 rate_limit_patterns: Vec<String>,
46 debug_save_responses: bool,
47 parser: LastFmParser,
48}
49
50impl LastFmEditClient {
51 pub fn new(client: Box<dyn HttpClient + Send + Sync>) -> Self {
74 Self::with_base_url(client, "https://www.last.fm".to_string())
75 }
76
77 pub fn with_base_url(client: Box<dyn HttpClient + Send + Sync>, base_url: String) -> Self {
89 Self::with_rate_limit_patterns(
90 client,
91 base_url,
92 vec![
93 "you've tried to log in too many times".to_string(),
94 "you're requesting too many pages".to_string(),
95 "slow down".to_string(),
96 "too fast".to_string(),
97 "rate limit".to_string(),
98 "throttled".to_string(),
99 "temporarily blocked".to_string(),
100 "temporarily restricted".to_string(),
101 "captcha".to_string(),
102 "verify you're human".to_string(),
103 "prove you're not a robot".to_string(),
104 "security check".to_string(),
105 "service temporarily unavailable".to_string(),
106 "quota exceeded".to_string(),
107 "limit exceeded".to_string(),
108 "daily limit".to_string(),
109 ],
110 )
111 }
112
113 pub fn with_rate_limit_patterns(
121 client: Box<dyn HttpClient + Send + Sync>,
122 base_url: String,
123 rate_limit_patterns: Vec<String>,
124 ) -> Self {
125 Self {
126 client,
127 username: String::new(),
128 csrf_token: None,
129 base_url,
130 session_cookies: Vec::new(),
131 rate_limit_patterns,
132 debug_save_responses: std::env::var("LASTFM_DEBUG_SAVE_RESPONSES").is_ok(),
133 parser: LastFmParser::new(),
134 }
135 }
136
137 pub async fn login_with_credentials(
168 client: Box<dyn HttpClient + Send + Sync>,
169 username: &str,
170 password: &str,
171 ) -> Result<Self> {
172 let mut new_client = Self::new(client);
173 new_client.login(username, password).await?;
174 Ok(new_client)
175 }
176
177 pub fn from_session(client: Box<dyn HttpClient + Send + Sync>, session: ClientSession) -> Self {
209 Self {
210 client,
211 username: session.username,
212 csrf_token: session.csrf_token,
213 base_url: session.base_url,
214 session_cookies: session.session_cookies,
215 rate_limit_patterns: vec![
216 "you've tried to log in too many times".to_string(),
217 "you're requesting too many pages".to_string(),
218 "slow down".to_string(),
219 "too fast".to_string(),
220 "rate limit".to_string(),
221 "throttled".to_string(),
222 "temporarily blocked".to_string(),
223 "temporarily restricted".to_string(),
224 "captcha".to_string(),
225 "verify you're human".to_string(),
226 "prove you're not a robot".to_string(),
227 "security check".to_string(),
228 "service temporarily unavailable".to_string(),
229 "quota exceeded".to_string(),
230 "limit exceeded".to_string(),
231 "daily limit".to_string(),
232 ],
233 debug_save_responses: std::env::var("LASTFM_DEBUG_SAVE_RESPONSES").is_ok(),
234 parser: LastFmParser::new(),
235 }
236 }
237
238 pub fn get_session(&self) -> ClientSession {
265 ClientSession::new(
266 self.username.clone(),
267 self.session_cookies.clone(),
268 self.csrf_token.clone(),
269 self.base_url.clone(),
270 )
271 }
272
273 pub fn restore_session(&mut self, session: ClientSession) {
299 self.username = session.username;
300 self.csrf_token = session.csrf_token;
301 self.base_url = session.base_url;
302 self.session_cookies = session.session_cookies;
303 }
304
305 pub async fn login(&mut self, username: &str, password: &str) -> Result<()> {
334 let login_url = format!("{}/login", self.base_url);
336 let mut response = self.get(&login_url).await?;
337
338 self.extract_cookies(&response);
340
341 let html = response
342 .body_string()
343 .await
344 .map_err(|e| LastFmError::Http(e.to_string()))?;
345 let document = Html::parse_document(&html);
346
347 let csrf_token = self.extract_csrf_token(&document)?;
348
349 let mut form_data = HashMap::new();
351 form_data.insert("csrfmiddlewaretoken", csrf_token.as_str());
352 form_data.insert("username_or_email", username);
353 form_data.insert("password", password);
354
355 let next_selector = Selector::parse("input[name=\"next\"]").unwrap();
357 if let Some(next_input) = document.select(&next_selector).next() {
358 if let Some(next_value) = next_input.value().attr("value") {
359 form_data.insert("next", next_value);
360 }
361 }
362
363 let mut request = Request::new(Method::Post, login_url.parse::<Url>().unwrap());
364 let _ = request.insert_header("Referer", &login_url);
365 let _ = request.insert_header("Origin", &self.base_url);
366 let _ = request.insert_header("Content-Type", "application/x-www-form-urlencoded");
367 let _ = request.insert_header(
368 "User-Agent",
369 "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36"
370 );
371 let _ = request.insert_header(
372 "Accept",
373 "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"
374 );
375 let _ = request.insert_header("Accept-Language", "en-US,en;q=0.9");
376 let _ = request.insert_header("Accept-Encoding", "gzip, deflate, br");
377 let _ = request.insert_header("DNT", "1");
378 let _ = request.insert_header("Connection", "keep-alive");
379 let _ = request.insert_header("Upgrade-Insecure-Requests", "1");
380 let _ = request.insert_header(
381 "sec-ch-ua",
382 "\"Not)A;Brand\";v=\"8\", \"Chromium\";v=\"138\", \"Google Chrome\";v=\"138\"",
383 );
384 let _ = request.insert_header("sec-ch-ua-mobile", "?0");
385 let _ = request.insert_header("sec-ch-ua-platform", "\"Linux\"");
386 let _ = request.insert_header("Sec-Fetch-Dest", "document");
387 let _ = request.insert_header("Sec-Fetch-Mode", "navigate");
388 let _ = request.insert_header("Sec-Fetch-Site", "same-origin");
389 let _ = request.insert_header("Sec-Fetch-User", "?1");
390
391 if !self.session_cookies.is_empty() {
393 let cookie_header = self.session_cookies.join("; ");
394 let _ = request.insert_header("Cookie", &cookie_header);
395 }
396
397 let form_string: String = form_data
399 .iter()
400 .map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v)))
401 .collect::<Vec<_>>()
402 .join("&");
403
404 request.set_body(form_string);
405
406 let mut response = self
407 .client
408 .send(request)
409 .await
410 .map_err(|e| LastFmError::Http(e.to_string()))?;
411
412 self.extract_cookies(&response);
414
415 log::debug!("Login response status: {}", response.status());
416
417 if response.status() == 403 {
419 let response_html = response
421 .body_string()
422 .await
423 .map_err(|e| LastFmError::Http(e.to_string()))?;
424
425 if self.is_rate_limit_response(&response_html) {
427 log::debug!("403 response appears to be rate limiting");
428 return Err(LastFmError::RateLimit { retry_after: 60 });
429 } else {
430 log::debug!("403 response appears to be authentication failure");
431
432 let success_doc = Html::parse_document(&response_html);
434 let login_form_selector =
435 Selector::parse("form[action*=\"login\"], input[name=\"username_or_email\"]")
436 .unwrap();
437 let has_login_form = success_doc.select(&login_form_selector).next().is_some();
438
439 if !has_login_form {
440 return Err(LastFmError::Auth(
441 "Login failed - 403 Forbidden. Check credentials.".to_string(),
442 ));
443 } else {
444 let error_selector =
446 Selector::parse(".alert-danger, .form-error, .error-message").unwrap();
447 let mut error_messages = Vec::new();
448 for error in success_doc.select(&error_selector) {
449 let error_text = error.text().collect::<String>().trim().to_string();
450 if !error_text.is_empty() {
451 error_messages.push(error_text);
452 }
453 }
454 let error_msg = if error_messages.is_empty() {
455 "Login failed - 403 Forbidden. Check credentials.".to_string()
456 } else {
457 format!("Login failed: {}", error_messages.join("; "))
458 };
459 return Err(LastFmError::Auth(error_msg));
460 }
461 }
462 }
463
464 let has_real_session = self
466 .session_cookies
467 .iter()
468 .any(|cookie| cookie.starts_with("sessionid=.") && cookie.len() > 50);
469
470 if has_real_session && (response.status() == 302 || response.status() == 200) {
471 self.username = username.to_string();
473 self.csrf_token = Some(csrf_token);
474 log::debug!("Login successful - authenticated session established");
475 return Ok(());
476 }
477
478 let response_html = response
480 .body_string()
481 .await
482 .map_err(|e| LastFmError::Http(e.to_string()))?;
483
484 let success_doc = Html::parse_document(&response_html);
486 let login_form_selector =
487 Selector::parse("form[action*=\"login\"], input[name=\"username_or_email\"]").unwrap();
488 let has_login_form = success_doc.select(&login_form_selector).next().is_some();
489
490 if !has_login_form && response.status() == 200 {
491 self.username = username.to_string();
492 self.csrf_token = Some(csrf_token);
493 Ok(())
494 } else {
495 let error_doc = success_doc;
497 let error_selector =
498 Selector::parse(".alert-danger, .form-error, .error-message").unwrap();
499
500 let mut error_messages = Vec::new();
501 for error in error_doc.select(&error_selector) {
502 let error_text = error.text().collect::<String>().trim().to_string();
503 if !error_text.is_empty() {
504 error_messages.push(error_text);
505 }
506 }
507
508 let error_msg = if error_messages.is_empty() {
509 "Login failed - please check your credentials".to_string()
510 } else {
511 format!("Login failed: {}", error_messages.join("; "))
512 };
513
514 Err(LastFmError::Auth(error_msg))
515 }
516 }
517
518 pub fn username(&self) -> &str {
522 &self.username
523 }
524
525 pub fn is_logged_in(&self) -> bool {
529 !self.username.is_empty() && self.csrf_token.is_some()
530 }
531
532 pub fn artist_tracks<'a>(&'a mut self, artist: &str) -> ArtistTracksIterator<'a> {
542 ArtistTracksIterator::new(self, artist.to_string())
543 }
544
545 pub fn artist_albums<'a>(&'a mut self, artist: &str) -> ArtistAlbumsIterator<'a> {
555 ArtistAlbumsIterator::new(self, artist.to_string())
556 }
557
558 pub fn recent_tracks<'a>(&'a mut self) -> RecentTracksIterator<'a> {
567 RecentTracksIterator::new(self)
568 }
569
570 pub async fn get_recent_scrobbles(&mut self, page: u32) -> Result<Vec<Track>> {
573 let url = format!(
574 "{}/user/{}/library?page={}",
575 self.base_url, self.username, page
576 );
577
578 log::debug!("Fetching recent scrobbles page {page}");
579 let mut response = self.get(&url).await?;
580 let content = response
581 .body_string()
582 .await
583 .map_err(|e| LastFmError::Http(e.to_string()))?;
584
585 log::debug!(
586 "Recent scrobbles response: {} status, {} chars",
587 response.status(),
588 content.len()
589 );
590
591 let document = Html::parse_document(&content);
592 self.parser.parse_recent_scrobbles(&document)
593 }
594
595 pub async fn find_recent_scrobble_for_track(
598 &mut self,
599 track_name: &str,
600 artist_name: &str,
601 max_pages: u32,
602 ) -> Result<Option<Track>> {
603 log::debug!("Searching for recent scrobble: '{track_name}' by '{artist_name}'");
604
605 for page in 1..=max_pages {
606 let scrobbles = self.get_recent_scrobbles(page).await?;
607
608 for scrobble in scrobbles {
609 if scrobble.name == track_name && scrobble.artist == artist_name {
610 log::debug!(
611 "Found recent scrobble: '{}' with timestamp {:?}",
612 scrobble.name,
613 scrobble.timestamp
614 );
615 return Ok(Some(scrobble));
616 }
617 }
618
619 }
621
622 log::debug!(
623 "No recent scrobble found for '{track_name}' by '{artist_name}' in {max_pages} pages"
624 );
625 Ok(None)
626 }
627
628 pub async fn edit_scrobble(&mut self, edit: &ScrobbleEdit) -> Result<EditResponse> {
629 self.edit_scrobble_with_retry(edit, 3).await
630 }
631
632 pub async fn edit_scrobble_with_retry(
633 &mut self,
634 edit: &ScrobbleEdit,
635 max_retries: u32,
636 ) -> Result<EditResponse> {
637 let mut retries = 0;
638
639 loop {
640 match self.edit_scrobble_impl(edit).await {
641 Ok(result) => return Ok(result),
642 Err(LastFmError::RateLimit { retry_after }) => {
643 if retries >= max_retries {
644 log::warn!("Max retries ({max_retries}) exceeded for edit operation");
645 return Err(LastFmError::RateLimit { retry_after });
646 }
647
648 let delay = std::cmp::min(retry_after, 2_u64.pow(retries + 1) * 5);
649 log::info!(
650 "Edit rate limited. Waiting {} seconds before retry {} of {}",
651 delay,
652 retries + 1,
653 max_retries
654 );
655 retries += 1;
657 }
658 Err(other_error) => return Err(other_error),
659 }
660 }
661 }
662
663 async fn edit_scrobble_impl(&mut self, edit: &ScrobbleEdit) -> Result<EditResponse> {
664 if !self.is_logged_in() {
665 return Err(LastFmError::Auth(
666 "Must be logged in to edit scrobbles".to_string(),
667 ));
668 }
669
670 let edit_url = format!(
671 "{}/user/{}/library/edit?edited-variation=library-track-scrobble",
672 self.base_url, self.username
673 );
674
675 log::debug!("Getting fresh CSRF token for edit");
676
677 let mut form_response = self.get(&edit_url).await?;
679 let form_html = form_response
680 .body_string()
681 .await
682 .map_err(|e| LastFmError::Http(e.to_string()))?;
683
684 log::debug!("Edit form response status: {}", form_response.status());
685
686 let form_document = Html::parse_document(&form_html);
688 let fresh_csrf_token = self.extract_csrf_token(&form_document)?;
689
690 log::debug!("Submitting edit with fresh token");
691
692 let mut form_data = HashMap::new();
693
694 form_data.insert("csrfmiddlewaretoken", fresh_csrf_token.as_str());
696
697 form_data.insert("track_name_original", &edit.track_name_original);
699 form_data.insert("track_name", &edit.track_name);
700 form_data.insert("artist_name_original", &edit.artist_name_original);
701 form_data.insert("artist_name", &edit.artist_name);
702 form_data.insert("album_name_original", &edit.album_name_original);
703 form_data.insert("album_name", &edit.album_name);
704 form_data.insert(
705 "album_artist_name_original",
706 &edit.album_artist_name_original,
707 );
708 form_data.insert("album_artist_name", &edit.album_artist_name);
709
710 let timestamp_str = edit.timestamp.to_string();
712 form_data.insert("timestamp", ×tamp_str);
713
714 if edit.edit_all {
716 form_data.insert("edit_all", "1");
717 }
718 form_data.insert("submit", "edit-scrobble");
719 form_data.insert("ajax", "1");
720
721 log::debug!(
722 "Editing scrobble: '{}' -> '{}'",
723 edit.track_name_original,
724 edit.track_name
725 );
726 log::trace!("Session cookies count: {}", self.session_cookies.len());
727
728 let mut request = Request::new(Method::Post, edit_url.parse::<Url>().unwrap());
729
730 let _ = request.insert_header("Accept", "*/*");
732 let _ = request.insert_header("Accept-Language", "en-US,en;q=0.9");
733 let _ = request.insert_header(
734 "Content-Type",
735 "application/x-www-form-urlencoded;charset=UTF-8",
736 );
737 let _ = request.insert_header("Priority", "u=1, i");
738 let _ = request.insert_header("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36");
739 let _ = request.insert_header("X-Requested-With", "XMLHttpRequest");
740 let _ = request.insert_header("Sec-Fetch-Dest", "empty");
741 let _ = request.insert_header("Sec-Fetch-Mode", "cors");
742 let _ = request.insert_header("Sec-Fetch-Site", "same-origin");
743 let _ = request.insert_header(
744 "sec-ch-ua",
745 "\"Not)A;Brand\";v=\"8\", \"Chromium\";v=\"138\", \"Google Chrome\";v=\"138\"",
746 );
747 let _ = request.insert_header("sec-ch-ua-mobile", "?0");
748 let _ = request.insert_header("sec-ch-ua-platform", "\"Linux\"");
749
750 if !self.session_cookies.is_empty() {
752 let cookie_header = self.session_cookies.join("; ");
753 let _ = request.insert_header("Cookie", &cookie_header);
754 }
755
756 let _ = request.insert_header(
758 "Referer",
759 format!("{}/user/{}/library", self.base_url, self.username),
760 );
761
762 let form_string: String = form_data
764 .iter()
765 .map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v)))
766 .collect::<Vec<_>>()
767 .join("&");
768
769 request.set_body(form_string);
770
771 let mut response = self
772 .client
773 .send(request)
774 .await
775 .map_err(|e| LastFmError::Http(e.to_string()))?;
776
777 log::debug!("Edit response status: {}", response.status());
778
779 let response_text = response
780 .body_string()
781 .await
782 .map_err(|e| LastFmError::Http(e.to_string()))?;
783
784 let document = Html::parse_document(&response_text);
786
787 let success_selector = Selector::parse(".alert-success").unwrap();
789 let error_selector = Selector::parse(".alert-danger, .alert-error, .error").unwrap();
790
791 let has_success_alert = document.select(&success_selector).next().is_some();
792 let has_error_alert = document.select(&error_selector).next().is_some();
793
794 let mut actual_track_name = None;
797 let mut actual_album_name = None;
798
799 let track_name_selector = Selector::parse("td.chartlist-name a").unwrap();
801 let album_name_selector = Selector::parse("td.chartlist-album a").unwrap();
802
803 if let Some(track_element) = document.select(&track_name_selector).next() {
804 actual_track_name = Some(track_element.text().collect::<String>().trim().to_string());
805 }
806
807 if let Some(album_element) = document.select(&album_name_selector).next() {
808 actual_album_name = Some(album_element.text().collect::<String>().trim().to_string());
809 }
810
811 if actual_track_name.is_none() || actual_album_name.is_none() {
813 let track_pattern = regex::Regex::new(r#"href="/music/[^"]+/_/([^"]+)""#).unwrap();
816 if let Some(captures) = track_pattern.captures(&response_text) {
817 if let Some(track_match) = captures.get(1) {
818 let raw_track = track_match.as_str();
819 let decoded_track = urlencoding::decode(raw_track)
821 .unwrap_or_else(|_| raw_track.into())
822 .replace("+", " ");
823 actual_track_name = Some(decoded_track);
824 }
825 }
826
827 let album_pattern =
830 regex::Regex::new(r#"href="/music/[^"]+/([^"/_]+)"[^>]*>[^<]*</a>"#).unwrap();
831 if let Some(captures) = album_pattern.captures(&response_text) {
832 if let Some(album_match) = captures.get(1) {
833 let raw_album = album_match.as_str();
834 let decoded_album = urlencoding::decode(raw_album)
836 .unwrap_or_else(|_| raw_album.into())
837 .replace("+", " ");
838 actual_album_name = Some(decoded_album);
839 }
840 }
841 }
842
843 log::debug!(
844 "Response analysis: success_alert={}, error_alert={}, track='{}', album='{}'",
845 has_success_alert,
846 has_error_alert,
847 actual_track_name.as_deref().unwrap_or("not found"),
848 actual_album_name.as_deref().unwrap_or("not found")
849 );
850
851 let final_success = response.status().is_success() && has_success_alert && !has_error_alert;
853
854 let message = if has_error_alert {
856 if let Some(error_element) = document.select(&error_selector).next() {
858 Some(format!(
859 "Edit failed: {}",
860 error_element.text().collect::<String>().trim()
861 ))
862 } else {
863 Some("Edit failed with unknown error".to_string())
864 }
865 } else if final_success {
866 Some(format!(
867 "Edit successful - Track: '{}', Album: '{}'",
868 actual_track_name.as_deref().unwrap_or("unknown"),
869 actual_album_name.as_deref().unwrap_or("unknown")
870 ))
871 } else {
872 Some(format!("Edit failed with status: {}", response.status()))
873 };
874
875 Ok(EditResponse {
876 success: final_success,
877 message,
878 })
879 }
880
881 pub async fn load_edit_form_values(
884 &mut self,
885 track_name: &str,
886 artist_name: &str,
887 ) -> Result<crate::ScrobbleEdit> {
888 log::debug!("Loading edit form values for '{track_name}' by '{artist_name}'");
889
890 let track_url = format!(
894 "{}/user/{}/library/music/+noredirect/{}/_/{}",
895 self.base_url,
896 self.username,
897 urlencoding::encode(artist_name),
898 urlencoding::encode(track_name)
899 );
900
901 log::debug!("Fetching track page: {track_url}");
902
903 let mut response = self.get(&track_url).await?;
904 let html = response
905 .body_string()
906 .await
907 .map_err(|e| crate::LastFmError::Http(e.to_string()))?;
908
909 let document = Html::parse_document(&html);
910
911 self.extract_scrobble_data_from_track_page(&document, track_name, artist_name)
913 }
914
915 fn extract_scrobble_data_from_track_page(
918 &self,
919 document: &Html,
920 expected_track: &str,
921 expected_artist: &str,
922 ) -> Result<crate::ScrobbleEdit> {
923 let table_selector =
925 Selector::parse("table.chartlist:not(.chartlist__placeholder)").unwrap();
926 let table = document.select(&table_selector).next().ok_or_else(|| {
927 crate::LastFmError::Parse("No chartlist table found on track page".to_string())
928 })?;
929
930 let row_selector = Selector::parse("tr").unwrap();
932 for row in table.select(&row_selector) {
933 let count_bar_link_selector = Selector::parse(".chartlist-count-bar-link").unwrap();
935 if row.select(&count_bar_link_selector).next().is_some() {
936 log::debug!("Found count bar link, skipping aggregated row");
937 continue;
938 }
939
940 let form_selector = Selector::parse("form[data-edit-scrobble]").unwrap();
942 if let Some(form) = row.select(&form_selector).next() {
943 let extract_form_value = |name: &str| -> Option<String> {
945 let selector = Selector::parse(&format!("input[name='{name}']")).unwrap();
946 form.select(&selector)
947 .next()
948 .and_then(|input| input.value().attr("value"))
949 .map(|s| s.to_string())
950 };
951
952 let form_track = extract_form_value("track_name").unwrap_or_default();
954 let form_artist = extract_form_value("artist_name").unwrap_or_default();
955 let form_album = extract_form_value("album_name").unwrap_or_default();
956 let form_album_artist =
957 extract_form_value("album_artist_name").unwrap_or_else(|| form_artist.clone());
958 let form_timestamp = extract_form_value("timestamp").unwrap_or_default();
959
960 log::debug!(
961 "Found scrobble form - Track: '{form_track}', Artist: '{form_artist}', Album: '{form_album}', Timestamp: {form_timestamp}"
962 );
963
964 if form_track == expected_track && form_artist == expected_artist {
966 let timestamp = form_timestamp.parse::<u64>().map_err(|_| {
967 crate::LastFmError::Parse("Invalid timestamp in form".to_string())
968 })?;
969
970 log::debug!(
971 "✅ Found matching scrobble form for '{expected_track}' by '{expected_artist}'"
972 );
973
974 return Ok(crate::ScrobbleEdit::new(
976 form_track.clone(),
977 form_album.clone(),
978 form_artist.clone(),
979 form_album_artist.clone(),
980 form_track,
981 form_album,
982 form_artist,
983 form_album_artist,
984 timestamp,
985 true,
986 ));
987 }
988 }
989 }
990
991 Err(crate::LastFmError::Parse(format!(
992 "No scrobble form found for track '{expected_track}' by '{expected_artist}'"
993 )))
994 }
995
996 pub async fn get_album_tracks(
999 &mut self,
1000 album_name: &str,
1001 artist_name: &str,
1002 ) -> Result<Vec<Track>> {
1003 log::debug!("Getting tracks from album '{album_name}' by '{artist_name}'");
1004
1005 let album_url = format!(
1007 "{}/user/{}/library/music/{}/{}",
1008 self.base_url,
1009 self.username,
1010 urlencoding::encode(artist_name),
1011 urlencoding::encode(album_name)
1012 );
1013
1014 log::debug!("Fetching album page: {album_url}");
1015
1016 let mut response = self.get(&album_url).await?;
1017 let html = response
1018 .body_string()
1019 .await
1020 .map_err(|e| LastFmError::Http(e.to_string()))?;
1021
1022 let document = Html::parse_document(&html);
1023
1024 let tracks = self
1026 .parser
1027 .extract_tracks_from_document(&document, artist_name)?;
1028
1029 log::debug!(
1030 "Successfully parsed {} tracks from album page",
1031 tracks.len()
1032 );
1033 Ok(tracks)
1034 }
1035
1036 pub async fn edit_album(
1039 &mut self,
1040 old_album_name: &str,
1041 new_album_name: &str,
1042 artist_name: &str,
1043 ) -> Result<EditResponse> {
1044 log::debug!("Editing album '{old_album_name}' -> '{new_album_name}' by '{artist_name}'");
1045
1046 let tracks = self.get_album_tracks(old_album_name, artist_name).await?;
1048
1049 if tracks.is_empty() {
1050 return Ok(EditResponse {
1051 success: false,
1052 message: Some(format!(
1053 "No tracks found for album '{old_album_name}' by '{artist_name}'. Make sure the album name matches exactly."
1054 )),
1055 });
1056 }
1057
1058 log::info!(
1059 "Found {} tracks in album '{}'",
1060 tracks.len(),
1061 old_album_name
1062 );
1063
1064 let mut successful_edits = 0;
1065 let mut failed_edits = 0;
1066 let mut error_messages = Vec::new();
1067 let mut skipped_tracks = 0;
1068
1069 for (index, track) in tracks.iter().enumerate() {
1071 log::debug!(
1072 "Processing track {}/{}: '{}'",
1073 index + 1,
1074 tracks.len(),
1075 track.name
1076 );
1077
1078 match self.load_edit_form_values(&track.name, artist_name).await {
1079 Ok(mut edit_data) => {
1080 edit_data.album_name = new_album_name.to_string();
1082
1083 match self.edit_scrobble(&edit_data).await {
1085 Ok(response) => {
1086 if response.success {
1087 successful_edits += 1;
1088 log::info!("✅ Successfully edited track '{}'", track.name);
1089 } else {
1090 failed_edits += 1;
1091 let error_msg = format!(
1092 "Failed to edit track '{}': {}",
1093 track.name,
1094 response
1095 .message
1096 .unwrap_or_else(|| "Unknown error".to_string())
1097 );
1098 error_messages.push(error_msg);
1099 log::debug!("❌ {}", error_messages.last().unwrap());
1100 }
1101 }
1102 Err(e) => {
1103 failed_edits += 1;
1104 let error_msg = format!("Error editing track '{}': {}", track.name, e);
1105 error_messages.push(error_msg);
1106 log::info!("❌ {}", error_messages.last().unwrap());
1107 }
1108 }
1109 }
1110 Err(e) => {
1111 skipped_tracks += 1;
1112 log::debug!("Could not load edit form for track '{}': {e}", track.name);
1113 }
1115 }
1116
1117 }
1119
1120 let total_processed = successful_edits + failed_edits;
1121 let success = successful_edits > 0 && failed_edits == 0;
1122
1123 let message = if success {
1124 Some(format!(
1125 "Successfully renamed album '{old_album_name}' to '{new_album_name}' for all {successful_edits} editable tracks ({skipped_tracks} tracks were not in recent scrobbles)"
1126 ))
1127 } else if successful_edits > 0 {
1128 Some(format!(
1129 "Partially successful: {} of {} editable tracks renamed ({} skipped, {} failed). Errors: {}",
1130 successful_edits,
1131 total_processed,
1132 skipped_tracks,
1133 failed_edits,
1134 error_messages.join("; ")
1135 ))
1136 } else if total_processed == 0 {
1137 Some(format!(
1138 "No editable tracks found for album '{}' by '{}'. All {} tracks were skipped because they're not in recent scrobbles.",
1139 old_album_name, artist_name, tracks.len()
1140 ))
1141 } else {
1142 Some(format!(
1143 "Failed to rename any tracks. Errors: {}",
1144 error_messages.join("; ")
1145 ))
1146 };
1147
1148 Ok(EditResponse { success, message })
1149 }
1150
1151 pub async fn edit_artist(
1154 &mut self,
1155 old_artist_name: &str,
1156 new_artist_name: &str,
1157 ) -> Result<EditResponse> {
1158 log::debug!("Editing artist '{old_artist_name}' -> '{new_artist_name}'");
1159
1160 let mut tracks = Vec::new();
1162 let mut iterator = self.artist_tracks(old_artist_name);
1163
1164 while tracks.len() < 200 {
1166 match iterator.next().await {
1167 Ok(Some(track)) => tracks.push(track),
1168 Ok(None) => break,
1169 Err(e) => {
1170 log::warn!("Error fetching artist tracks: {e}");
1171 break;
1172 }
1173 }
1174 }
1175
1176 if tracks.is_empty() {
1177 return Ok(EditResponse {
1178 success: false,
1179 message: Some(format!(
1180 "No tracks found for artist '{old_artist_name}'. Make sure the artist name matches exactly."
1181 )),
1182 });
1183 }
1184
1185 log::info!(
1186 "Found {} tracks for artist '{}'",
1187 tracks.len(),
1188 old_artist_name
1189 );
1190
1191 let mut successful_edits = 0;
1192 let mut failed_edits = 0;
1193 let mut error_messages = Vec::new();
1194 let mut skipped_tracks = 0;
1195
1196 for (index, track) in tracks.iter().enumerate() {
1198 log::debug!(
1199 "Processing track {}/{}: '{}'",
1200 index + 1,
1201 tracks.len(),
1202 track.name
1203 );
1204
1205 match self
1206 .load_edit_form_values(&track.name, old_artist_name)
1207 .await
1208 {
1209 Ok(mut edit_data) => {
1210 edit_data.artist_name = new_artist_name.to_string();
1212 edit_data.album_artist_name = new_artist_name.to_string();
1213
1214 match self.edit_scrobble(&edit_data).await {
1216 Ok(response) => {
1217 if response.success {
1218 successful_edits += 1;
1219 log::info!("✅ Successfully edited track '{}'", track.name);
1220 } else {
1221 failed_edits += 1;
1222 let error_msg = format!(
1223 "Failed to edit track '{}': {}",
1224 track.name,
1225 response
1226 .message
1227 .unwrap_or_else(|| "Unknown error".to_string())
1228 );
1229 error_messages.push(error_msg);
1230 log::debug!("❌ {}", error_messages.last().unwrap());
1231 }
1232 }
1233 Err(e) => {
1234 failed_edits += 1;
1235 let error_msg = format!("Error editing track '{}': {}", track.name, e);
1236 error_messages.push(error_msg);
1237 log::info!("❌ {}", error_messages.last().unwrap());
1238 }
1239 }
1240 }
1241 Err(e) => {
1242 skipped_tracks += 1;
1243 log::debug!("Could not load edit form for track '{}': {e}", track.name);
1244 }
1246 }
1247
1248 }
1250
1251 let total_processed = successful_edits + failed_edits;
1252 let success = successful_edits > 0 && failed_edits == 0;
1253
1254 let message = if success {
1255 Some(format!(
1256 "Successfully renamed artist '{old_artist_name}' to '{new_artist_name}' for all {successful_edits} editable tracks ({skipped_tracks} tracks were not in recent scrobbles)"
1257 ))
1258 } else if successful_edits > 0 {
1259 Some(format!(
1260 "Partially successful: {} of {} editable tracks renamed ({} skipped, {} failed). Errors: {}",
1261 successful_edits,
1262 total_processed,
1263 skipped_tracks,
1264 failed_edits,
1265 error_messages.join("; ")
1266 ))
1267 } else if total_processed == 0 {
1268 Some(format!(
1269 "No editable tracks found for artist '{}'. All {} tracks were skipped because they're not in recent scrobbles.",
1270 old_artist_name, tracks.len()
1271 ))
1272 } else {
1273 Some(format!(
1274 "Failed to rename any tracks. Errors: {}",
1275 error_messages.join("; ")
1276 ))
1277 };
1278
1279 Ok(EditResponse { success, message })
1280 }
1281
1282 pub async fn edit_artist_for_track(
1285 &mut self,
1286 track_name: &str,
1287 old_artist_name: &str,
1288 new_artist_name: &str,
1289 ) -> Result<EditResponse> {
1290 log::debug!("Editing artist for track '{track_name}' from '{old_artist_name}' -> '{new_artist_name}'");
1291
1292 match self.load_edit_form_values(track_name, old_artist_name).await {
1293 Ok(mut edit_data) => {
1294 edit_data.artist_name = new_artist_name.to_string();
1296 edit_data.album_artist_name = new_artist_name.to_string();
1297
1298 log::info!("Updating artist for track '{track_name}' from '{old_artist_name}' to '{new_artist_name}'");
1299
1300 match self.edit_scrobble(&edit_data).await {
1302 Ok(response) => {
1303 if response.success {
1304 Ok(EditResponse {
1305 success: true,
1306 message: Some(format!(
1307 "Successfully renamed artist for track '{track_name}' from '{old_artist_name}' to '{new_artist_name}'"
1308 )),
1309 })
1310 } else {
1311 Ok(EditResponse {
1312 success: false,
1313 message: Some(format!(
1314 "Failed to rename artist for track '{track_name}': {}",
1315 response.message.unwrap_or_else(|| "Unknown error".to_string())
1316 )),
1317 })
1318 }
1319 }
1320 Err(e) => Ok(EditResponse {
1321 success: false,
1322 message: Some(format!("Error editing track '{track_name}': {e}")),
1323 }),
1324 }
1325 }
1326 Err(e) => Ok(EditResponse {
1327 success: false,
1328 message: Some(format!(
1329 "Could not load edit form for track '{track_name}' by '{old_artist_name}': {e}. The track may not be in your recent scrobbles."
1330 )),
1331 }),
1332 }
1333 }
1334
1335 pub async fn edit_artist_for_album(
1338 &mut self,
1339 album_name: &str,
1340 old_artist_name: &str,
1341 new_artist_name: &str,
1342 ) -> Result<EditResponse> {
1343 log::debug!("Editing artist for album '{album_name}' from '{old_artist_name}' -> '{new_artist_name}'");
1344
1345 let tracks = self.get_album_tracks(album_name, old_artist_name).await?;
1347
1348 if tracks.is_empty() {
1349 return Ok(EditResponse {
1350 success: false,
1351 message: Some(format!(
1352 "No tracks found for album '{album_name}' by '{old_artist_name}'. Make sure the album name matches exactly."
1353 )),
1354 });
1355 }
1356
1357 log::info!(
1358 "Found {} tracks in album '{}' by '{}'",
1359 tracks.len(),
1360 album_name,
1361 old_artist_name
1362 );
1363
1364 let mut successful_edits = 0;
1365 let mut failed_edits = 0;
1366 let mut error_messages = Vec::new();
1367 let mut skipped_tracks = 0;
1368
1369 for (index, track) in tracks.iter().enumerate() {
1371 log::debug!(
1372 "Processing track {}/{}: '{}'",
1373 index + 1,
1374 tracks.len(),
1375 track.name
1376 );
1377
1378 match self
1379 .load_edit_form_values(&track.name, old_artist_name)
1380 .await
1381 {
1382 Ok(mut edit_data) => {
1383 edit_data.artist_name = new_artist_name.to_string();
1385 edit_data.album_artist_name = new_artist_name.to_string();
1386
1387 match self.edit_scrobble(&edit_data).await {
1389 Ok(response) => {
1390 if response.success {
1391 successful_edits += 1;
1392 log::info!("✅ Successfully edited track '{}'", track.name);
1393 } else {
1394 failed_edits += 1;
1395 let error_msg = format!(
1396 "Failed to edit track '{}': {}",
1397 track.name,
1398 response
1399 .message
1400 .unwrap_or_else(|| "Unknown error".to_string())
1401 );
1402 error_messages.push(error_msg);
1403 log::debug!("❌ {}", error_messages.last().unwrap());
1404 }
1405 }
1406 Err(e) => {
1407 failed_edits += 1;
1408 let error_msg = format!("Error editing track '{}': {}", track.name, e);
1409 error_messages.push(error_msg);
1410 log::info!("❌ {}", error_messages.last().unwrap());
1411 }
1412 }
1413 }
1414 Err(e) => {
1415 skipped_tracks += 1;
1416 log::debug!("Could not load edit form for track '{}': {e}", track.name);
1417 }
1419 }
1420
1421 }
1423
1424 let total_processed = successful_edits + failed_edits;
1425 let success = successful_edits > 0 && failed_edits == 0;
1426
1427 let message = if success {
1428 Some(format!(
1429 "Successfully renamed artist for album '{album_name}' from '{old_artist_name}' to '{new_artist_name}' for all {successful_edits} editable tracks ({skipped_tracks} tracks were not in recent scrobbles)"
1430 ))
1431 } else if successful_edits > 0 {
1432 Some(format!(
1433 "Partially successful: {} of {} editable tracks renamed ({} skipped, {} failed). Errors: {}",
1434 successful_edits,
1435 total_processed,
1436 skipped_tracks,
1437 failed_edits,
1438 error_messages.join("; ")
1439 ))
1440 } else if total_processed == 0 {
1441 Some(format!(
1442 "No editable tracks found for album '{album_name}' by '{old_artist_name}'. All {} tracks were skipped because they're not in recent scrobbles.",
1443 tracks.len()
1444 ))
1445 } else {
1446 Some(format!(
1447 "Failed to rename any tracks. Errors: {}",
1448 error_messages.join("; ")
1449 ))
1450 };
1451
1452 Ok(EditResponse { success, message })
1453 }
1454
1455 pub async fn get_artist_tracks_page(&mut self, artist: &str, page: u32) -> Result<TrackPage> {
1456 let url = format!(
1458 "{}/user/{}/library/music/{}/+tracks?page={}&ajax=true",
1459 self.base_url,
1460 self.username,
1461 artist.replace(" ", "+"),
1462 page
1463 );
1464
1465 log::debug!("Fetching tracks page {page} for artist: {artist}");
1466 let mut response = self.get(&url).await?;
1467 let content = response
1468 .body_string()
1469 .await
1470 .map_err(|e| LastFmError::Http(e.to_string()))?;
1471
1472 log::debug!(
1473 "AJAX response: {} status, {} chars",
1474 response.status(),
1475 content.len()
1476 );
1477
1478 if content.trim_start().starts_with("{") || content.trim_start().starts_with("[") {
1480 log::debug!("Parsing JSON response from AJAX endpoint");
1481 self.parse_json_tracks_page(&content, page, artist)
1482 } else {
1483 log::debug!("Parsing HTML response from AJAX endpoint");
1484 let document = Html::parse_document(&content);
1485 self.parser.parse_tracks_page(&document, page, artist)
1486 }
1487 }
1488
1489 fn parse_json_tracks_page(
1491 &self,
1492 _json_content: &str,
1493 page_number: u32,
1494 _artist: &str,
1495 ) -> Result<TrackPage> {
1496 log::debug!("JSON parsing not implemented, returning empty page");
1498 Ok(TrackPage {
1499 tracks: Vec::new(),
1500 page_number,
1501 has_next_page: false,
1502 total_pages: Some(1),
1503 })
1504 }
1505
1506 pub fn extract_tracks_from_document(
1508 &self,
1509 document: &Html,
1510 artist: &str,
1511 ) -> Result<Vec<Track>> {
1512 self.parser.extract_tracks_from_document(document, artist)
1513 }
1514
1515 pub fn parse_tracks_page(
1517 &self,
1518 document: &Html,
1519 page_number: u32,
1520 artist: &str,
1521 ) -> Result<TrackPage> {
1522 self.parser.parse_tracks_page(document, page_number, artist)
1523 }
1524
1525 pub fn parse_recent_scrobbles(&self, document: &Html) -> Result<Vec<Track>> {
1527 self.parser.parse_recent_scrobbles(document)
1528 }
1529
1530 fn extract_csrf_token(&self, document: &Html) -> Result<String> {
1531 let csrf_selector = Selector::parse("input[name=\"csrfmiddlewaretoken\"]").unwrap();
1532
1533 document
1534 .select(&csrf_selector)
1535 .next()
1536 .and_then(|input| input.value().attr("value"))
1537 .map(|token| token.to_string())
1538 .ok_or(LastFmError::CsrfNotFound)
1539 }
1540
1541 pub async fn get(&mut self, url: &str) -> Result<Response> {
1543 self.get_with_retry(url, 3).await
1544 }
1545
1546 async fn get_with_retry(&mut self, url: &str, max_retries: u32) -> Result<Response> {
1548 let mut retries = 0;
1549
1550 loop {
1551 match self.get_with_redirects(url, 0).await {
1552 Ok(mut response) => {
1553 let body = self.extract_response_body(url, &mut response).await?;
1555
1556 if response.status().is_success() && self.is_rate_limit_response(&body) {
1558 log::debug!("Response body contains rate limit patterns");
1559 if retries < max_retries {
1560 let delay = 60 + (retries as u64 * 30); log::info!("Rate limit detected in response body, retrying in {delay}s (attempt {}/{max_retries})", retries + 1);
1562 retries += 1;
1564 continue;
1565 } else {
1566 return Err(crate::LastFmError::RateLimit { retry_after: 60 });
1567 }
1568 }
1569
1570 let mut new_response = http_types::Response::new(response.status());
1572 for (name, values) in response.iter() {
1573 for value in values {
1574 let _ = new_response.insert_header(name.clone(), value.clone());
1575 }
1576 }
1577 new_response.set_body(body);
1578
1579 return Ok(new_response);
1580 }
1581 Err(crate::LastFmError::RateLimit { retry_after }) => {
1582 if retries < max_retries {
1583 let delay = retry_after + (retries as u64 * 30); log::info!(
1585 "Rate limit detected, retrying in {delay}s (attempt {}/{max_retries})",
1586 retries + 1
1587 );
1588 retries += 1;
1590 } else {
1591 return Err(crate::LastFmError::RateLimit { retry_after });
1592 }
1593 }
1594 Err(e) => return Err(e),
1595 }
1596 }
1597 }
1598
1599 async fn get_with_redirects(&mut self, url: &str, redirect_count: u32) -> Result<Response> {
1600 if redirect_count > 5 {
1601 return Err(LastFmError::Http("Too many redirects".to_string()));
1602 }
1603
1604 let mut request = Request::new(Method::Get, url.parse::<Url>().unwrap());
1605 let _ = request.insert_header("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36");
1606
1607 if !self.session_cookies.is_empty() {
1609 let cookie_header = self.session_cookies.join("; ");
1610 let _ = request.insert_header("Cookie", &cookie_header);
1611 } else if url.contains("page=") {
1612 log::debug!("No cookies available for paginated request!");
1613 }
1614
1615 if url.contains("ajax=true") {
1617 let _ = request.insert_header("Accept", "*/*");
1619 let _ = request.insert_header("X-Requested-With", "XMLHttpRequest");
1620 } else {
1621 let _ = request.insert_header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7");
1623 }
1624 let _ = request.insert_header("Accept-Language", "en-US,en;q=0.9");
1625 let _ = request.insert_header("Accept-Encoding", "gzip, deflate, br");
1626 let _ = request.insert_header("DNT", "1");
1627 let _ = request.insert_header("Connection", "keep-alive");
1628 let _ = request.insert_header("Upgrade-Insecure-Requests", "1");
1629
1630 if url.contains("page=") {
1632 let base_url = url.split('?').next().unwrap_or(url);
1633 let _ = request.insert_header("Referer", base_url);
1634 }
1635
1636 let response = self
1637 .client
1638 .send(request)
1639 .await
1640 .map_err(|e| LastFmError::Http(e.to_string()))?;
1641
1642 self.extract_cookies(&response);
1644
1645 if response.status() == 302 || response.status() == 301 {
1647 if let Some(location) = response.header("location") {
1648 if let Some(redirect_url) = location.get(0) {
1649 let redirect_url_str = redirect_url.as_str();
1650 if url.contains("page=") {
1651 log::debug!("Following redirect from {url} to {redirect_url_str}");
1652
1653 if redirect_url_str.contains("/login") {
1655 log::debug!("Redirect to login page - authentication failed for paginated request");
1656 return Err(LastFmError::Auth(
1657 "Session expired or invalid for paginated request".to_string(),
1658 ));
1659 }
1660 }
1661
1662 let full_redirect_url = if redirect_url_str.starts_with('/') {
1664 format!("{}{redirect_url_str}", self.base_url)
1665 } else if redirect_url_str.starts_with("http") {
1666 redirect_url_str.to_string()
1667 } else {
1668 let base_url = url
1670 .rsplit('/')
1671 .skip(1)
1672 .collect::<Vec<_>>()
1673 .into_iter()
1674 .rev()
1675 .collect::<Vec<_>>()
1676 .join("/");
1677 format!("{base_url}/{redirect_url_str}")
1678 };
1679
1680 return Box::pin(
1682 self.get_with_redirects(&full_redirect_url, redirect_count + 1),
1683 )
1684 .await;
1685 }
1686 }
1687 }
1688
1689 if response.status() == 429 {
1691 let retry_after = response
1692 .header("retry-after")
1693 .and_then(|h| h.get(0))
1694 .and_then(|v| v.as_str().parse::<u64>().ok())
1695 .unwrap_or(60);
1696 return Err(LastFmError::RateLimit { retry_after });
1697 }
1698
1699 if response.status() == 403 {
1701 log::debug!("Got 403 response, checking if it's a rate limit");
1702 if !self.session_cookies.is_empty() {
1704 log::debug!("403 on authenticated request - likely rate limit");
1705 return Err(LastFmError::RateLimit { retry_after: 60 });
1706 }
1707 }
1708
1709 Ok(response)
1710 }
1711
1712 fn is_rate_limit_response(&self, response_body: &str) -> bool {
1714 let body_lower = response_body.to_lowercase();
1715
1716 for pattern in &self.rate_limit_patterns {
1718 if body_lower.contains(&pattern.to_lowercase()) {
1719 return true;
1720 }
1721 }
1722
1723 false
1724 }
1725
1726 fn extract_cookies(&mut self, response: &Response) {
1727 if let Some(cookie_headers) = response.header("set-cookie") {
1729 let mut new_cookies = 0;
1730 for cookie_header in cookie_headers {
1731 let cookie_str = cookie_header.as_str();
1732 if let Some(cookie_value) = cookie_str.split(';').next() {
1734 let cookie_name = cookie_value.split('=').next().unwrap_or("");
1735
1736 self.session_cookies
1738 .retain(|existing| !existing.starts_with(&format!("{cookie_name}=")));
1739
1740 self.session_cookies.push(cookie_value.to_string());
1741 new_cookies += 1;
1742 }
1743 }
1744 if new_cookies > 0 {
1745 log::trace!(
1746 "Extracted {} new cookies, total: {}",
1747 new_cookies,
1748 self.session_cookies.len()
1749 );
1750 log::trace!("Updated cookies: {:?}", &self.session_cookies);
1751
1752 for cookie in &self.session_cookies {
1754 if cookie.starts_with("sessionid=") {
1755 log::trace!("Current sessionid: {}", &cookie[10..50.min(cookie.len())]);
1756 break;
1757 }
1758 }
1759 }
1760 }
1761 }
1762
1763 async fn extract_response_body(&self, url: &str, response: &mut Response) -> Result<String> {
1765 let body = response
1766 .body_string()
1767 .await
1768 .map_err(|e| LastFmError::Http(e.to_string()))?;
1769
1770 if self.debug_save_responses {
1771 self.save_debug_response(url, response.status().into(), &body);
1772 }
1773
1774 Ok(body)
1775 }
1776
1777 fn save_debug_response(&self, url: &str, status_code: u16, body: &str) {
1779 if let Err(e) = self.try_save_debug_response(url, status_code, body) {
1780 log::warn!("Failed to save debug response: {e}");
1781 }
1782 }
1783
1784 fn try_save_debug_response(&self, url: &str, status_code: u16, body: &str) -> Result<()> {
1786 let debug_dir = Path::new("debug_responses");
1788 if !debug_dir.exists() {
1789 fs::create_dir_all(debug_dir)
1790 .map_err(|e| LastFmError::Http(format!("Failed to create debug directory: {e}")))?;
1791 }
1792
1793 let url_path = if url.starts_with(&self.base_url) {
1795 &url[self.base_url.len()..]
1796 } else {
1797 url
1798 };
1799
1800 let now = chrono::Utc::now();
1802 let timestamp = now.format("%Y%m%d_%H%M%S_%3f");
1803 let safe_path = url_path.replace(['/', '?', '&', '=', '%', '+'], "_");
1804
1805 let filename = format!("{timestamp}_{safe_path}_status{status_code}.html");
1806 let file_path = debug_dir.join(filename);
1807
1808 fs::write(&file_path, body)
1810 .map_err(|e| LastFmError::Http(format!("Failed to write debug file: {e}")))?;
1811
1812 log::debug!(
1813 "Saved HTTP response to {file_path:?} (status: {status_code}, url: {url_path})"
1814 );
1815
1816 Ok(())
1817 }
1818
1819 pub async fn get_artist_albums_page(&mut self, artist: &str, page: u32) -> Result<AlbumPage> {
1820 let url = format!(
1822 "{}/user/{}/library/music/{}/+albums?page={}&ajax=true",
1823 self.base_url,
1824 self.username,
1825 artist.replace(" ", "+"),
1826 page
1827 );
1828
1829 log::debug!("Fetching albums page {page} for artist: {artist}");
1830 let mut response = self.get(&url).await?;
1831 let content = response
1832 .body_string()
1833 .await
1834 .map_err(|e| LastFmError::Http(e.to_string()))?;
1835
1836 log::debug!(
1837 "AJAX response: {} status, {} chars",
1838 response.status(),
1839 content.len()
1840 );
1841
1842 if content.trim_start().starts_with("{") || content.trim_start().starts_with("[") {
1844 log::debug!("Parsing JSON response from AJAX endpoint");
1845 self.parse_json_albums_page(&content, page, artist)
1846 } else {
1847 log::debug!("Parsing HTML response from AJAX endpoint");
1848 let document = Html::parse_document(&content);
1849 self.parser.parse_albums_page(&document, page, artist)
1850 }
1851 }
1852
1853 fn parse_json_albums_page(
1854 &self,
1855 _json_content: &str,
1856 page_number: u32,
1857 _artist: &str,
1858 ) -> Result<AlbumPage> {
1859 log::debug!("JSON parsing not implemented, returning empty page");
1861 Ok(AlbumPage {
1862 albums: Vec::new(),
1863 page_number,
1864 has_next_page: false,
1865 total_pages: Some(1),
1866 })
1867 }
1868}