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