1use crate::parsing::LastFmParser;
2use crate::session::LastFmEditSession;
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;
13use std::sync::{Arc, Mutex};
14
15pub struct LastFmEditClient {
41 client: Box<dyn HttpClient + Send + Sync>,
42 session: Arc<Mutex<LastFmEditSession>>,
43 rate_limit_patterns: Vec<String>,
44 debug_save_responses: bool,
45 parser: LastFmParser,
46}
47
48impl LastFmEditClient {
49 pub fn new(client: Box<dyn HttpClient + Send + Sync>) -> Self {
72 Self::with_base_url(client, "https://www.last.fm".to_string())
73 }
74
75 pub fn with_base_url(client: Box<dyn HttpClient + Send + Sync>, base_url: String) -> Self {
87 Self::with_rate_limit_patterns(
88 client,
89 base_url,
90 vec![
91 "you've tried to log in too many times".to_string(),
92 "you're requesting too many pages".to_string(),
93 "slow down".to_string(),
94 "too fast".to_string(),
95 "rate limit".to_string(),
96 "throttled".to_string(),
97 "temporarily blocked".to_string(),
98 "temporarily restricted".to_string(),
99 "captcha".to_string(),
100 "verify you're human".to_string(),
101 "prove you're not a robot".to_string(),
102 "security check".to_string(),
103 "service temporarily unavailable".to_string(),
104 "quota exceeded".to_string(),
105 "limit exceeded".to_string(),
106 "daily limit".to_string(),
107 ],
108 )
109 }
110
111 pub fn with_rate_limit_patterns(
119 client: Box<dyn HttpClient + Send + Sync>,
120 base_url: String,
121 rate_limit_patterns: Vec<String>,
122 ) -> Self {
123 Self {
124 client,
125 session: Arc::new(Mutex::new(LastFmEditSession::new(
126 String::new(),
127 Vec::new(),
128 None,
129 base_url,
130 ))),
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 new_client = Self::new(client);
173 new_client.login(username, password).await?;
174 Ok(new_client)
175 }
176
177 pub fn from_session(
209 client: Box<dyn HttpClient + Send + Sync>,
210 session: LastFmEditSession,
211 ) -> Self {
212 Self {
213 client,
214 session: Arc::new(Mutex::new(session)),
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) -> LastFmEditSession {
265 self.session.lock().unwrap().clone()
266 }
267
268 pub fn restore_session(&self, session: LastFmEditSession) {
294 *self.session.lock().unwrap() = session;
295 }
296
297 pub async fn login(&self, username: &str, password: &str) -> Result<()> {
326 let login_url = {
328 let session = self.session.lock().unwrap();
329 format!("{}/login", session.base_url)
330 };
331 let mut response = self.get(&login_url).await?;
332
333 self.extract_cookies(&response);
335
336 let html = response
337 .body_string()
338 .await
339 .map_err(|e| LastFmError::Http(e.to_string()))?;
340
341 let (csrf_token, next_field) = self.extract_login_form_data(&html)?;
343
344 let mut form_data = HashMap::new();
346 form_data.insert("csrfmiddlewaretoken", csrf_token.as_str());
347 form_data.insert("username_or_email", username);
348 form_data.insert("password", password);
349
350 if let Some(ref next_value) = next_field {
352 form_data.insert("next", next_value);
353 }
354
355 let mut request = Request::new(Method::Post, login_url.parse::<Url>().unwrap());
356 let _ = request.insert_header("Referer", &login_url);
357 {
358 let session = self.session.lock().unwrap();
359 let _ = request.insert_header("Origin", &session.base_url);
360 }
361 let _ = request.insert_header("Content-Type", "application/x-www-form-urlencoded");
362 let _ = request.insert_header(
363 "User-Agent",
364 "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36"
365 );
366 let _ = request.insert_header(
367 "Accept",
368 "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"
369 );
370 let _ = request.insert_header("Accept-Language", "en-US,en;q=0.9");
371 let _ = request.insert_header("Accept-Encoding", "gzip, deflate, br");
372 let _ = request.insert_header("DNT", "1");
373 let _ = request.insert_header("Connection", "keep-alive");
374 let _ = request.insert_header("Upgrade-Insecure-Requests", "1");
375 let _ = request.insert_header(
376 "sec-ch-ua",
377 "\"Not)A;Brand\";v=\"8\", \"Chromium\";v=\"138\", \"Google Chrome\";v=\"138\"",
378 );
379 let _ = request.insert_header("sec-ch-ua-mobile", "?0");
380 let _ = request.insert_header("sec-ch-ua-platform", "\"Linux\"");
381 let _ = request.insert_header("Sec-Fetch-Dest", "document");
382 let _ = request.insert_header("Sec-Fetch-Mode", "navigate");
383 let _ = request.insert_header("Sec-Fetch-Site", "same-origin");
384 let _ = request.insert_header("Sec-Fetch-User", "?1");
385
386 {
388 let session = self.session.lock().unwrap();
389 if !session.cookies.is_empty() {
390 let cookie_header = session.cookies.join("; ");
391 let _ = request.insert_header("Cookie", &cookie_header);
392 }
393 }
394
395 let form_string: String = form_data
397 .iter()
398 .map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v)))
399 .collect::<Vec<_>>()
400 .join("&");
401
402 request.set_body(form_string);
403
404 let mut response = self
405 .client
406 .send(request)
407 .await
408 .map_err(|e| LastFmError::Http(e.to_string()))?;
409
410 self.extract_cookies(&response);
412
413 log::debug!("Login response status: {}", response.status());
414
415 if response.status() == 403 {
417 let response_html = response
419 .body_string()
420 .await
421 .map_err(|e| LastFmError::Http(e.to_string()))?;
422
423 if self.is_rate_limit_response(&response_html) {
425 log::debug!("403 response appears to be rate limiting");
426 return Err(LastFmError::RateLimit { retry_after: 60 });
427 }
428 log::debug!("403 response appears to be authentication failure");
429
430 let login_error = self.parse_login_error(&response_html);
432 return Err(LastFmError::Auth(login_error));
433 }
434
435 let has_real_session = {
437 let session = self.session.lock().unwrap();
438 session
439 .cookies
440 .iter()
441 .any(|cookie| cookie.starts_with("sessionid=.") && cookie.len() > 50)
442 };
443
444 if has_real_session && (response.status() == 302 || response.status() == 200) {
445 {
447 let mut session = self.session.lock().unwrap();
448 session.username = username.to_string();
449 session.csrf_token = Some(csrf_token);
450 }
451 log::debug!("Login successful - authenticated session established");
452 return Ok(());
453 }
454
455 let response_html = response
457 .body_string()
458 .await
459 .map_err(|e| LastFmError::Http(e.to_string()))?;
460
461 let has_login_form = self.check_for_login_form(&response_html);
463
464 if !has_login_form && response.status() == 200 {
465 {
466 let mut session = self.session.lock().unwrap();
467 session.username = username.to_string();
468 session.csrf_token = Some(csrf_token);
469 }
470 Ok(())
471 } else {
472 let error_msg = self.parse_login_error(&response_html);
474 Err(LastFmError::Auth(error_msg))
475 }
476 }
477
478 pub fn username(&self) -> String {
482 self.session.lock().unwrap().username.clone()
483 }
484
485 pub fn is_logged_in(&self) -> bool {
489 self.session.lock().unwrap().is_valid()
490 }
491
492 pub fn artist_tracks<'a>(&'a self, artist: &str) -> ArtistTracksIterator<'a> {
502 ArtistTracksIterator::new(self, artist.to_string())
503 }
504
505 pub fn artist_albums<'a>(&'a self, artist: &str) -> ArtistAlbumsIterator<'a> {
515 ArtistAlbumsIterator::new(self, artist.to_string())
516 }
517
518 pub fn recent_tracks<'a>(&'a self) -> RecentTracksIterator<'a> {
527 RecentTracksIterator::new(self)
528 }
529
530 pub fn recent_tracks_from_page<'a>(&'a self, starting_page: u32) -> RecentTracksIterator<'a> {
558 RecentTracksIterator::with_starting_page(self, starting_page)
559 }
560
561 pub async fn get_recent_scrobbles(&self, page: u32) -> Result<Vec<Track>> {
564 let url = {
565 let session = self.session.lock().unwrap();
566 format!(
567 "{}/user/{}/library?page={}",
568 session.base_url, session.username, page
569 )
570 };
571
572 log::debug!("Fetching recent scrobbles page {page}");
573 let mut response = self.get(&url).await?;
574 let content = response
575 .body_string()
576 .await
577 .map_err(|e| LastFmError::Http(e.to_string()))?;
578
579 log::debug!(
580 "Recent scrobbles response: {} status, {} chars",
581 response.status(),
582 content.len()
583 );
584
585 let document = Html::parse_document(&content);
586 self.parser.parse_recent_scrobbles(&document)
587 }
588
589 pub async fn find_recent_scrobble_for_track(
592 &self,
593 track_name: &str,
594 artist_name: &str,
595 max_pages: u32,
596 ) -> Result<Option<Track>> {
597 log::debug!("Searching for recent scrobble: '{track_name}' by '{artist_name}'");
598
599 for page in 1..=max_pages {
600 let scrobbles = self.get_recent_scrobbles(page).await?;
601
602 for scrobble in scrobbles {
603 if scrobble.name == track_name && scrobble.artist == artist_name {
604 log::debug!(
605 "Found recent scrobble: '{}' with timestamp {:?}",
606 scrobble.name,
607 scrobble.timestamp
608 );
609 return Ok(Some(scrobble));
610 }
611 }
612
613 }
615
616 log::debug!(
617 "No recent scrobble found for '{track_name}' by '{artist_name}' in {max_pages} pages"
618 );
619 Ok(None)
620 }
621
622 pub async fn edit_scrobble(&self, edit: &ScrobbleEdit) -> Result<EditResponse> {
623 let enriched_edit = self.enrich_edit_metadata(edit).await.unwrap_or_else(|e| {
625 log::debug!("Could not enrich metadata ({e}), using original edit");
626 edit.clone()
627 });
628
629 self.edit_scrobble_with_retry(&enriched_edit, 3).await
630 }
631
632 async fn enrich_edit_metadata(&self, edit: &ScrobbleEdit) -> Result<ScrobbleEdit> {
634 let needs_lookup = edit.track_name_original.is_none()
636 || edit.album_name_original.is_none()
637 || edit.artist_name_original.is_none()
638 || edit.album_artist_name_original.is_none();
639
640 if !needs_lookup {
641 return Ok(edit.clone());
643 }
644
645 log::debug!(
646 "Looking up missing original metadata for scrobble with timestamp {}",
647 edit.timestamp
648 );
649
650 let found_scrobble = self.find_scrobble_by_timestamp(edit.timestamp).await?;
652
653 Ok(ScrobbleEdit {
654 track_name_original: edit
655 .track_name_original
656 .clone()
657 .or_else(|| Some(found_scrobble.name.clone())),
658 album_name_original: edit
659 .album_name_original
660 .clone()
661 .or_else(|| found_scrobble.album.clone()),
662 artist_name_original: edit
663 .artist_name_original
664 .clone()
665 .or_else(|| Some(found_scrobble.artist.clone())),
666 album_artist_name_original: edit
667 .album_artist_name_original
668 .clone()
669 .or_else(|| found_scrobble.album_artist.clone())
670 .or_else(|| Some(found_scrobble.artist.clone())), track_name: edit.track_name.clone(),
672 album_name: edit.album_name.clone(),
673 artist_name: edit.artist_name.clone(),
674 album_artist_name: edit.album_artist_name.clone(),
675 timestamp: edit.timestamp,
676 edit_all: edit.edit_all,
677 })
678 }
679
680 pub async fn find_scrobble_by_timestamp(&self, timestamp: u64) -> Result<Track> {
682 log::debug!("Searching for scrobble with timestamp {timestamp}");
683
684 for page in 1..=10 {
686 let scrobbles = self.get_recent_scrobbles(page).await?;
688
689 for scrobble in scrobbles {
690 if let Some(scrobble_timestamp) = scrobble.timestamp {
691 if scrobble_timestamp == timestamp {
692 log::debug!(
693 "Found scrobble: '{}' by '{}' with album: '{:?}', album_artist: '{:?}'",
694 scrobble.name,
695 scrobble.artist,
696 scrobble.album,
697 scrobble.album_artist
698 );
699 return Ok(scrobble);
700 }
701 }
702 }
703 }
704
705 Err(LastFmError::Parse(format!(
706 "Could not find scrobble with timestamp {timestamp}"
707 )))
708 }
709
710 pub async fn edit_scrobble_with_retry(
711 &self,
712 edit: &ScrobbleEdit,
713 max_retries: u32,
714 ) -> Result<EditResponse> {
715 let mut retries = 0;
716
717 loop {
718 match self.edit_scrobble_impl(edit).await {
719 Ok(result) => return Ok(result),
720 Err(LastFmError::RateLimit { retry_after }) => {
721 if retries >= max_retries {
722 log::warn!("Max retries ({max_retries}) exceeded for edit operation");
723 return Err(LastFmError::RateLimit { retry_after });
724 }
725
726 let delay = std::cmp::min(retry_after, 2_u64.pow(retries + 1) * 5);
727 log::info!(
728 "Edit rate limited. Waiting {} seconds before retry {} of {}",
729 delay,
730 retries + 1,
731 max_retries
732 );
733 retries += 1;
735 }
736 Err(other_error) => return Err(other_error),
737 }
738 }
739 }
740
741 async fn edit_scrobble_impl(&self, edit: &ScrobbleEdit) -> Result<EditResponse> {
742 if !self.is_logged_in() {
743 return Err(LastFmError::Auth(
744 "Must be logged in to edit scrobbles".to_string(),
745 ));
746 }
747
748 let edit_url = {
749 let session = self.session.lock().unwrap();
750 format!(
751 "{}/user/{}/library/edit?edited-variation=library-track-scrobble",
752 session.base_url, session.username
753 )
754 };
755
756 log::debug!("Getting fresh CSRF token for edit");
757
758 let form_html = self.get_edit_form_html(&edit_url).await?;
760
761 let form_document = Html::parse_document(&form_html);
763 let fresh_csrf_token = self.extract_csrf_token(&form_document)?;
764
765 log::debug!("Submitting edit with fresh token");
766
767 let mut form_data = HashMap::new();
768
769 form_data.insert("csrfmiddlewaretoken", fresh_csrf_token.as_str());
771
772 let track_name_original = edit.track_name_original.as_deref().unwrap_or("");
775 let artist_name_original = edit.artist_name_original.as_deref().unwrap_or("");
776 let album_name_original = edit.album_name_original.as_deref().unwrap_or("");
777 let album_artist_name_original = edit.album_artist_name_original.as_deref().unwrap_or("");
778
779 form_data.insert("track_name_original", track_name_original);
780 form_data.insert("track_name", &edit.track_name);
781 form_data.insert("artist_name_original", artist_name_original);
782 form_data.insert("artist_name", &edit.artist_name);
783 form_data.insert("album_name_original", album_name_original);
784 form_data.insert("album_name", &edit.album_name);
785 form_data.insert("album_artist_name_original", album_artist_name_original);
786 form_data.insert("album_artist_name", &edit.album_artist_name);
787
788 let timestamp_str = edit.timestamp.to_string();
790 form_data.insert("timestamp", ×tamp_str);
791
792 if edit.edit_all {
794 form_data.insert("edit_all", "1");
795 }
796 form_data.insert("submit", "edit-scrobble");
797 form_data.insert("ajax", "1");
798
799 log::debug!(
800 "Editing scrobble: '{}' -> '{}'",
801 edit.track_name_original.as_deref().unwrap_or("unknown"),
802 edit.track_name
803 );
804 {
805 let session = self.session.lock().unwrap();
806 log::trace!("Session cookies count: {}", session.cookies.len());
807 }
808
809 let mut request = Request::new(Method::Post, edit_url.parse::<Url>().unwrap());
810
811 let _ = request.insert_header("Accept", "*/*");
813 let _ = request.insert_header("Accept-Language", "en-US,en;q=0.9");
814 let _ = request.insert_header(
815 "Content-Type",
816 "application/x-www-form-urlencoded;charset=UTF-8",
817 );
818 let _ = request.insert_header("Priority", "u=1, i");
819 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");
820 let _ = request.insert_header("X-Requested-With", "XMLHttpRequest");
821 let _ = request.insert_header("Sec-Fetch-Dest", "empty");
822 let _ = request.insert_header("Sec-Fetch-Mode", "cors");
823 let _ = request.insert_header("Sec-Fetch-Site", "same-origin");
824 let _ = request.insert_header(
825 "sec-ch-ua",
826 "\"Not)A;Brand\";v=\"8\", \"Chromium\";v=\"138\", \"Google Chrome\";v=\"138\"",
827 );
828 let _ = request.insert_header("sec-ch-ua-mobile", "?0");
829 let _ = request.insert_header("sec-ch-ua-platform", "\"Linux\"");
830
831 {
833 let session = self.session.lock().unwrap();
834 if !session.cookies.is_empty() {
835 let cookie_header = session.cookies.join("; ");
836 let _ = request.insert_header("Cookie", &cookie_header);
837 }
838 }
839
840 {
842 let session = self.session.lock().unwrap();
843 let _ = request.insert_header(
844 "Referer",
845 format!("{}/user/{}/library", session.base_url, session.username),
846 );
847 }
848
849 let form_string: String = form_data
851 .iter()
852 .map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v)))
853 .collect::<Vec<_>>()
854 .join("&");
855
856 request.set_body(form_string);
857
858 let mut response = self
859 .client
860 .send(request)
861 .await
862 .map_err(|e| LastFmError::Http(e.to_string()))?;
863
864 log::debug!("Edit response status: {}", response.status());
865
866 let response_text = response
867 .body_string()
868 .await
869 .map_err(|e| LastFmError::Http(e.to_string()))?;
870
871 let document = Html::parse_document(&response_text);
873
874 let success_selector = Selector::parse(".alert-success").unwrap();
876 let error_selector = Selector::parse(".alert-danger, .alert-error, .error").unwrap();
877
878 let has_success_alert = document.select(&success_selector).next().is_some();
879 let has_error_alert = document.select(&error_selector).next().is_some();
880
881 let mut actual_track_name = None;
884 let mut actual_album_name = None;
885
886 let track_name_selector = Selector::parse("td.chartlist-name a").unwrap();
888 let album_name_selector = Selector::parse("td.chartlist-album a").unwrap();
889
890 if let Some(track_element) = document.select(&track_name_selector).next() {
891 actual_track_name = Some(track_element.text().collect::<String>().trim().to_string());
892 }
893
894 if let Some(album_element) = document.select(&album_name_selector).next() {
895 actual_album_name = Some(album_element.text().collect::<String>().trim().to_string());
896 }
897
898 if actual_track_name.is_none() || actual_album_name.is_none() {
900 let track_pattern = regex::Regex::new(r#"href="/music/[^"]+/_/([^"]+)""#).unwrap();
903 if let Some(captures) = track_pattern.captures(&response_text) {
904 if let Some(track_match) = captures.get(1) {
905 let raw_track = track_match.as_str();
906 let decoded_track = urlencoding::decode(raw_track)
908 .unwrap_or_else(|_| raw_track.into())
909 .replace("+", " ");
910 actual_track_name = Some(decoded_track);
911 }
912 }
913
914 let album_pattern =
917 regex::Regex::new(r#"href="/music/[^"]+/([^"/_]+)"[^>]*>[^<]*</a>"#).unwrap();
918 if let Some(captures) = album_pattern.captures(&response_text) {
919 if let Some(album_match) = captures.get(1) {
920 let raw_album = album_match.as_str();
921 let decoded_album = urlencoding::decode(raw_album)
923 .unwrap_or_else(|_| raw_album.into())
924 .replace("+", " ");
925 actual_album_name = Some(decoded_album);
926 }
927 }
928 }
929
930 log::debug!(
931 "Response analysis: success_alert={}, error_alert={}, track='{}', album='{}'",
932 has_success_alert,
933 has_error_alert,
934 actual_track_name.as_deref().unwrap_or("not found"),
935 actual_album_name.as_deref().unwrap_or("not found")
936 );
937
938 let final_success = response.status().is_success() && has_success_alert && !has_error_alert;
940
941 let message = if has_error_alert {
943 if let Some(error_element) = document.select(&error_selector).next() {
945 Some(format!(
946 "Edit failed: {}",
947 error_element.text().collect::<String>().trim()
948 ))
949 } else {
950 Some("Edit failed with unknown error".to_string())
951 }
952 } else if final_success {
953 Some(format!(
954 "Edit successful - Track: '{}', Album: '{}'",
955 actual_track_name.as_deref().unwrap_or("unknown"),
956 actual_album_name.as_deref().unwrap_or("unknown")
957 ))
958 } else {
959 Some(format!("Edit failed with status: {}", response.status()))
960 };
961
962 Ok(EditResponse {
963 success: final_success,
964 message,
965 })
966 }
967
968 async fn get_edit_form_html(&self, edit_url: &str) -> Result<String> {
971 let mut form_response = self.get(edit_url).await?;
972 let form_html = form_response
973 .body_string()
974 .await
975 .map_err(|e| LastFmError::Http(e.to_string()))?;
976
977 log::debug!("Edit form response status: {}", form_response.status());
978 Ok(form_html)
979 }
980
981 pub async fn load_edit_form_values(
984 &self,
985 track_name: &str,
986 artist_name: &str,
987 ) -> Result<crate::ScrobbleEdit> {
988 log::debug!("Loading edit form values for '{track_name}' by '{artist_name}'");
989
990 let track_url = {
994 let session = self.session.lock().unwrap();
995 format!(
996 "{}/user/{}/library/music/+noredirect/{}/_/{}",
997 session.base_url,
998 session.username,
999 urlencoding::encode(artist_name),
1000 urlencoding::encode(track_name)
1001 )
1002 };
1003
1004 log::debug!("Fetching track page: {track_url}");
1005
1006 let mut response = self.get(&track_url).await?;
1007 let html = response
1008 .body_string()
1009 .await
1010 .map_err(|e| crate::LastFmError::Http(e.to_string()))?;
1011
1012 let document = Html::parse_document(&html);
1013
1014 self.extract_scrobble_data_from_track_page(&document, track_name, artist_name)
1016 }
1017
1018 fn extract_scrobble_data_from_track_page(
1021 &self,
1022 document: &Html,
1023 expected_track: &str,
1024 expected_artist: &str,
1025 ) -> Result<crate::ScrobbleEdit> {
1026 let table_selector =
1028 Selector::parse("table.chartlist:not(.chartlist__placeholder)").unwrap();
1029 let table = document.select(&table_selector).next().ok_or_else(|| {
1030 crate::LastFmError::Parse("No chartlist table found on track page".to_string())
1031 })?;
1032
1033 let row_selector = Selector::parse("tr").unwrap();
1035 for row in table.select(&row_selector) {
1036 let count_bar_link_selector = Selector::parse(".chartlist-count-bar-link").unwrap();
1038 if row.select(&count_bar_link_selector).next().is_some() {
1039 log::debug!("Found count bar link, skipping aggregated row");
1040 continue;
1041 }
1042
1043 let form_selector = Selector::parse("form[data-edit-scrobble]").unwrap();
1045 if let Some(form) = row.select(&form_selector).next() {
1046 let extract_form_value = |name: &str| -> Option<String> {
1048 let selector = Selector::parse(&format!("input[name='{name}']")).unwrap();
1049 form.select(&selector)
1050 .next()
1051 .and_then(|input| input.value().attr("value"))
1052 .map(|s| s.to_string())
1053 };
1054
1055 let form_track = extract_form_value("track_name").unwrap_or_default();
1057 let form_artist = extract_form_value("artist_name").unwrap_or_default();
1058 let form_album = extract_form_value("album_name").unwrap_or_default();
1059 let form_album_artist =
1060 extract_form_value("album_artist_name").unwrap_or_else(|| form_artist.clone());
1061 let form_timestamp = extract_form_value("timestamp").unwrap_or_default();
1062
1063 log::debug!(
1064 "Found scrobble form - Track: '{form_track}', Artist: '{form_artist}', Album: '{form_album}', Timestamp: {form_timestamp}"
1065 );
1066
1067 if form_track == expected_track && form_artist == expected_artist {
1069 let timestamp = form_timestamp.parse::<u64>().map_err(|_| {
1070 crate::LastFmError::Parse("Invalid timestamp in form".to_string())
1071 })?;
1072
1073 log::debug!(
1074 "✅ Found matching scrobble form for '{expected_track}' by '{expected_artist}'"
1075 );
1076
1077 return Ok(crate::ScrobbleEdit::new(
1079 Some(form_track.clone()),
1080 Some(form_album.clone()),
1081 Some(form_artist.clone()),
1082 Some(form_album_artist.clone()),
1083 form_track,
1084 form_album,
1085 form_artist,
1086 form_album_artist,
1087 timestamp,
1088 true,
1089 ));
1090 }
1091 }
1092 }
1093
1094 Err(crate::LastFmError::Parse(format!(
1095 "No scrobble form found for track '{expected_track}' by '{expected_artist}'"
1096 )))
1097 }
1098
1099 pub async fn get_album_tracks(
1102 &self,
1103 album_name: &str,
1104 artist_name: &str,
1105 ) -> Result<Vec<Track>> {
1106 log::debug!("Getting tracks from album '{album_name}' by '{artist_name}'");
1107
1108 let album_url = {
1110 let session = self.session.lock().unwrap();
1111 format!(
1112 "{}/user/{}/library/music/{}/{}",
1113 session.base_url,
1114 session.username,
1115 urlencoding::encode(artist_name),
1116 urlencoding::encode(album_name)
1117 )
1118 };
1119
1120 log::debug!("Fetching album page: {album_url}");
1121
1122 let mut response = self.get(&album_url).await?;
1123 let html = response
1124 .body_string()
1125 .await
1126 .map_err(|e| LastFmError::Http(e.to_string()))?;
1127
1128 let document = Html::parse_document(&html);
1129
1130 let tracks =
1132 self.parser
1133 .extract_tracks_from_document(&document, artist_name, Some(album_name))?;
1134
1135 log::debug!(
1136 "Successfully parsed {} tracks from album page",
1137 tracks.len()
1138 );
1139 Ok(tracks)
1140 }
1141
1142 pub async fn edit_album(
1145 &self,
1146 old_album_name: &str,
1147 new_album_name: &str,
1148 artist_name: &str,
1149 ) -> Result<EditResponse> {
1150 log::debug!("Editing album '{old_album_name}' -> '{new_album_name}' by '{artist_name}'");
1151
1152 let tracks = self.get_album_tracks(old_album_name, artist_name).await?;
1154
1155 if tracks.is_empty() {
1156 return Ok(EditResponse {
1157 success: false,
1158 message: Some(format!(
1159 "No tracks found for album '{old_album_name}' by '{artist_name}'. Make sure the album name matches exactly."
1160 )),
1161 });
1162 }
1163
1164 log::info!(
1165 "Found {} tracks in album '{}'",
1166 tracks.len(),
1167 old_album_name
1168 );
1169
1170 let mut successful_edits = 0;
1171 let mut failed_edits = 0;
1172 let mut error_messages = Vec::new();
1173 let mut skipped_tracks = 0;
1174
1175 for (index, track) in tracks.iter().enumerate() {
1177 log::debug!(
1178 "Processing track {}/{}: '{}'",
1179 index + 1,
1180 tracks.len(),
1181 track.name
1182 );
1183
1184 match self.load_edit_form_values(&track.name, artist_name).await {
1185 Ok(mut edit_data) => {
1186 edit_data.album_name = new_album_name.to_string();
1188
1189 match self.edit_scrobble(&edit_data).await {
1191 Ok(response) => {
1192 if response.success {
1193 successful_edits += 1;
1194 log::info!("✅ Successfully edited track '{}'", track.name);
1195 } else {
1196 failed_edits += 1;
1197 let error_msg = format!(
1198 "Failed to edit track '{}': {}",
1199 track.name,
1200 response
1201 .message
1202 .unwrap_or_else(|| "Unknown error".to_string())
1203 );
1204 error_messages.push(error_msg);
1205 log::debug!("❌ {}", error_messages.last().unwrap());
1206 }
1207 }
1208 Err(e) => {
1209 failed_edits += 1;
1210 let error_msg = format!("Error editing track '{}': {}", track.name, e);
1211 error_messages.push(error_msg);
1212 log::info!("❌ {}", error_messages.last().unwrap());
1213 }
1214 }
1215 }
1216 Err(e) => {
1217 skipped_tracks += 1;
1218 log::debug!("Could not load edit form for track '{}': {e}", track.name);
1219 }
1221 }
1222
1223 }
1225
1226 let total_processed = successful_edits + failed_edits;
1227 let success = successful_edits > 0 && failed_edits == 0;
1228
1229 let message = if success {
1230 Some(format!(
1231 "Successfully renamed album '{old_album_name}' to '{new_album_name}' for all {successful_edits} editable tracks ({skipped_tracks} tracks were not in recent scrobbles)"
1232 ))
1233 } else if successful_edits > 0 {
1234 Some(format!(
1235 "Partially successful: {} of {} editable tracks renamed ({} skipped, {} failed). Errors: {}",
1236 successful_edits,
1237 total_processed,
1238 skipped_tracks,
1239 failed_edits,
1240 error_messages.join("; ")
1241 ))
1242 } else if total_processed == 0 {
1243 Some(format!(
1244 "No editable tracks found for album '{}' by '{}'. All {} tracks were skipped because they're not in recent scrobbles.",
1245 old_album_name, artist_name, tracks.len()
1246 ))
1247 } else {
1248 Some(format!(
1249 "Failed to rename any tracks. Errors: {}",
1250 error_messages.join("; ")
1251 ))
1252 };
1253
1254 Ok(EditResponse { success, message })
1255 }
1256
1257 pub async fn edit_artist(
1260 &self,
1261 old_artist_name: &str,
1262 new_artist_name: &str,
1263 ) -> Result<EditResponse> {
1264 log::debug!("Editing artist '{old_artist_name}' -> '{new_artist_name}'");
1265
1266 let mut tracks = Vec::new();
1268 let mut iterator = self.artist_tracks(old_artist_name);
1269
1270 while tracks.len() < 200 {
1272 match iterator.next().await {
1273 Ok(Some(track)) => tracks.push(track),
1274 Ok(None) => break,
1275 Err(e) => {
1276 log::warn!("Error fetching artist tracks: {e}");
1277 break;
1278 }
1279 }
1280 }
1281
1282 if tracks.is_empty() {
1283 return Ok(EditResponse {
1284 success: false,
1285 message: Some(format!(
1286 "No tracks found for artist '{old_artist_name}'. Make sure the artist name matches exactly."
1287 )),
1288 });
1289 }
1290
1291 log::info!(
1292 "Found {} tracks for artist '{}'",
1293 tracks.len(),
1294 old_artist_name
1295 );
1296
1297 let mut successful_edits = 0;
1298 let mut failed_edits = 0;
1299 let mut error_messages = Vec::new();
1300 let mut skipped_tracks = 0;
1301
1302 for (index, track) in tracks.iter().enumerate() {
1304 log::debug!(
1305 "Processing track {}/{}: '{}'",
1306 index + 1,
1307 tracks.len(),
1308 track.name
1309 );
1310
1311 match self
1312 .load_edit_form_values(&track.name, old_artist_name)
1313 .await
1314 {
1315 Ok(mut edit_data) => {
1316 edit_data.artist_name = new_artist_name.to_string();
1318 edit_data.album_artist_name = new_artist_name.to_string();
1319
1320 match self.edit_scrobble(&edit_data).await {
1322 Ok(response) => {
1323 if response.success {
1324 successful_edits += 1;
1325 log::info!("✅ Successfully edited track '{}'", track.name);
1326 } else {
1327 failed_edits += 1;
1328 let error_msg = format!(
1329 "Failed to edit track '{}': {}",
1330 track.name,
1331 response
1332 .message
1333 .unwrap_or_else(|| "Unknown error".to_string())
1334 );
1335 error_messages.push(error_msg);
1336 log::debug!("❌ {}", error_messages.last().unwrap());
1337 }
1338 }
1339 Err(e) => {
1340 failed_edits += 1;
1341 let error_msg = format!("Error editing track '{}': {}", track.name, e);
1342 error_messages.push(error_msg);
1343 log::info!("❌ {}", error_messages.last().unwrap());
1344 }
1345 }
1346 }
1347 Err(e) => {
1348 skipped_tracks += 1;
1349 log::debug!("Could not load edit form for track '{}': {e}", track.name);
1350 }
1352 }
1353
1354 }
1356
1357 let total_processed = successful_edits + failed_edits;
1358 let success = successful_edits > 0 && failed_edits == 0;
1359
1360 let message = if success {
1361 Some(format!(
1362 "Successfully renamed artist '{old_artist_name}' to '{new_artist_name}' for all {successful_edits} editable tracks ({skipped_tracks} tracks were not in recent scrobbles)"
1363 ))
1364 } else if successful_edits > 0 {
1365 Some(format!(
1366 "Partially successful: {} of {} editable tracks renamed ({} skipped, {} failed). Errors: {}",
1367 successful_edits,
1368 total_processed,
1369 skipped_tracks,
1370 failed_edits,
1371 error_messages.join("; ")
1372 ))
1373 } else if total_processed == 0 {
1374 Some(format!(
1375 "No editable tracks found for artist '{}'. All {} tracks were skipped because they're not in recent scrobbles.",
1376 old_artist_name, tracks.len()
1377 ))
1378 } else {
1379 Some(format!(
1380 "Failed to rename any tracks. Errors: {}",
1381 error_messages.join("; ")
1382 ))
1383 };
1384
1385 Ok(EditResponse { success, message })
1386 }
1387
1388 pub async fn edit_artist_for_track(
1391 &self,
1392 track_name: &str,
1393 old_artist_name: &str,
1394 new_artist_name: &str,
1395 ) -> Result<EditResponse> {
1396 log::debug!("Editing artist for track '{track_name}' from '{old_artist_name}' -> '{new_artist_name}'");
1397
1398 match self.load_edit_form_values(track_name, old_artist_name).await {
1399 Ok(mut edit_data) => {
1400 edit_data.artist_name = new_artist_name.to_string();
1402 edit_data.album_artist_name = new_artist_name.to_string();
1403
1404 log::info!("Updating artist for track '{track_name}' from '{old_artist_name}' to '{new_artist_name}'");
1405
1406 match self.edit_scrobble(&edit_data).await {
1408 Ok(response) => {
1409 if response.success {
1410 Ok(EditResponse {
1411 success: true,
1412 message: Some(format!(
1413 "Successfully renamed artist for track '{track_name}' from '{old_artist_name}' to '{new_artist_name}'"
1414 )),
1415 })
1416 } else {
1417 Ok(EditResponse {
1418 success: false,
1419 message: Some(format!(
1420 "Failed to rename artist for track '{track_name}': {}",
1421 response.message.unwrap_or_else(|| "Unknown error".to_string())
1422 )),
1423 })
1424 }
1425 }
1426 Err(e) => Ok(EditResponse {
1427 success: false,
1428 message: Some(format!("Error editing track '{track_name}': {e}")),
1429 }),
1430 }
1431 }
1432 Err(e) => Ok(EditResponse {
1433 success: false,
1434 message: Some(format!(
1435 "Could not load edit form for track '{track_name}' by '{old_artist_name}': {e}. The track may not be in your recent scrobbles."
1436 )),
1437 }),
1438 }
1439 }
1440
1441 pub async fn edit_artist_for_album(
1444 &self,
1445 album_name: &str,
1446 old_artist_name: &str,
1447 new_artist_name: &str,
1448 ) -> Result<EditResponse> {
1449 log::debug!("Editing artist for album '{album_name}' from '{old_artist_name}' -> '{new_artist_name}'");
1450
1451 let tracks = self.get_album_tracks(album_name, old_artist_name).await?;
1453
1454 if tracks.is_empty() {
1455 return Ok(EditResponse {
1456 success: false,
1457 message: Some(format!(
1458 "No tracks found for album '{album_name}' by '{old_artist_name}'. Make sure the album name matches exactly."
1459 )),
1460 });
1461 }
1462
1463 log::info!(
1464 "Found {} tracks in album '{}' by '{}'",
1465 tracks.len(),
1466 album_name,
1467 old_artist_name
1468 );
1469
1470 let mut successful_edits = 0;
1471 let mut failed_edits = 0;
1472 let mut error_messages = Vec::new();
1473 let mut skipped_tracks = 0;
1474
1475 for (index, track) in tracks.iter().enumerate() {
1477 log::debug!(
1478 "Processing track {}/{}: '{}'",
1479 index + 1,
1480 tracks.len(),
1481 track.name
1482 );
1483
1484 match self
1485 .load_edit_form_values(&track.name, old_artist_name)
1486 .await
1487 {
1488 Ok(mut edit_data) => {
1489 edit_data.artist_name = new_artist_name.to_string();
1491 edit_data.album_artist_name = new_artist_name.to_string();
1492
1493 match self.edit_scrobble(&edit_data).await {
1495 Ok(response) => {
1496 if response.success {
1497 successful_edits += 1;
1498 log::info!("✅ Successfully edited track '{}'", track.name);
1499 } else {
1500 failed_edits += 1;
1501 let error_msg = format!(
1502 "Failed to edit track '{}': {}",
1503 track.name,
1504 response
1505 .message
1506 .unwrap_or_else(|| "Unknown error".to_string())
1507 );
1508 error_messages.push(error_msg);
1509 log::debug!("❌ {}", error_messages.last().unwrap());
1510 }
1511 }
1512 Err(e) => {
1513 failed_edits += 1;
1514 let error_msg = format!("Error editing track '{}': {}", track.name, e);
1515 error_messages.push(error_msg);
1516 log::info!("❌ {}", error_messages.last().unwrap());
1517 }
1518 }
1519 }
1520 Err(e) => {
1521 skipped_tracks += 1;
1522 log::debug!("Could not load edit form for track '{}': {e}", track.name);
1523 }
1525 }
1526
1527 }
1529
1530 let total_processed = successful_edits + failed_edits;
1531 let success = successful_edits > 0 && failed_edits == 0;
1532
1533 let message = if success {
1534 Some(format!(
1535 "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)"
1536 ))
1537 } else if successful_edits > 0 {
1538 Some(format!(
1539 "Partially successful: {} of {} editable tracks renamed ({} skipped, {} failed). Errors: {}",
1540 successful_edits,
1541 total_processed,
1542 skipped_tracks,
1543 failed_edits,
1544 error_messages.join("; ")
1545 ))
1546 } else if total_processed == 0 {
1547 Some(format!(
1548 "No editable tracks found for album '{album_name}' by '{old_artist_name}'. All {} tracks were skipped because they're not in recent scrobbles.",
1549 tracks.len()
1550 ))
1551 } else {
1552 Some(format!(
1553 "Failed to rename any tracks. Errors: {}",
1554 error_messages.join("; ")
1555 ))
1556 };
1557
1558 Ok(EditResponse { success, message })
1559 }
1560
1561 pub async fn get_artist_tracks_page(&self, artist: &str, page: u32) -> Result<TrackPage> {
1562 let url = {
1564 let session = self.session.lock().unwrap();
1565 format!(
1566 "{}/user/{}/library/music/{}/+tracks?page={}&ajax=true",
1567 session.base_url,
1568 session.username,
1569 artist.replace(" ", "+"),
1570 page
1571 )
1572 };
1573
1574 log::debug!("Fetching tracks page {page} for artist: {artist}");
1575 let mut response = self.get(&url).await?;
1576 let content = response
1577 .body_string()
1578 .await
1579 .map_err(|e| LastFmError::Http(e.to_string()))?;
1580
1581 log::debug!(
1582 "AJAX response: {} status, {} chars",
1583 response.status(),
1584 content.len()
1585 );
1586
1587 if content.trim_start().starts_with("{") || content.trim_start().starts_with("[") {
1589 log::debug!("Parsing JSON response from AJAX endpoint");
1590 self.parse_json_tracks_page(&content, page, artist)
1591 } else {
1592 log::debug!("Parsing HTML response from AJAX endpoint");
1593 let document = Html::parse_document(&content);
1594 self.parser.parse_tracks_page(&document, page, artist, None)
1595 }
1596 }
1597
1598 fn parse_json_tracks_page(
1600 &self,
1601 _json_content: &str,
1602 page_number: u32,
1603 _artist: &str,
1604 ) -> Result<TrackPage> {
1605 log::debug!("JSON parsing not implemented, returning empty page");
1607 Ok(TrackPage {
1608 tracks: Vec::new(),
1609 page_number,
1610 has_next_page: false,
1611 total_pages: Some(1),
1612 })
1613 }
1614
1615 pub fn extract_tracks_from_document(
1617 &self,
1618 document: &Html,
1619 artist: &str,
1620 album: Option<&str>,
1621 ) -> Result<Vec<Track>> {
1622 self.parser
1623 .extract_tracks_from_document(document, artist, album)
1624 }
1625
1626 pub fn parse_tracks_page(
1628 &self,
1629 document: &Html,
1630 page_number: u32,
1631 artist: &str,
1632 album: Option<&str>,
1633 ) -> Result<TrackPage> {
1634 self.parser
1635 .parse_tracks_page(document, page_number, artist, album)
1636 }
1637
1638 pub fn parse_recent_scrobbles(&self, document: &Html) -> Result<Vec<Track>> {
1640 self.parser.parse_recent_scrobbles(document)
1641 }
1642
1643 fn extract_csrf_token(&self, document: &Html) -> Result<String> {
1644 let csrf_selector = Selector::parse("input[name=\"csrfmiddlewaretoken\"]").unwrap();
1645
1646 document
1647 .select(&csrf_selector)
1648 .next()
1649 .and_then(|input| input.value().attr("value"))
1650 .map(|token| token.to_string())
1651 .ok_or(LastFmError::CsrfNotFound)
1652 }
1653
1654 fn extract_login_form_data(&self, html: &str) -> Result<(String, Option<String>)> {
1656 let document = Html::parse_document(html);
1657
1658 let csrf_token = self.extract_csrf_token(&document)?;
1659
1660 let next_selector = Selector::parse("input[name=\"next\"]").unwrap();
1662 let next_field = document
1663 .select(&next_selector)
1664 .next()
1665 .and_then(|input| input.value().attr("value"))
1666 .map(|s| s.to_string());
1667
1668 Ok((csrf_token, next_field))
1669 }
1670
1671 fn parse_login_error(&self, html: &str) -> String {
1673 let document = Html::parse_document(html);
1674
1675 let error_selector = Selector::parse(".alert-danger, .form-error, .error-message").unwrap();
1676
1677 let mut error_messages = Vec::new();
1678 for error in document.select(&error_selector) {
1679 let error_text = error.text().collect::<String>().trim().to_string();
1680 if !error_text.is_empty() {
1681 error_messages.push(error_text);
1682 }
1683 }
1684
1685 if error_messages.is_empty() {
1686 "Login failed - please check your credentials".to_string()
1687 } else {
1688 format!("Login failed: {}", error_messages.join("; "))
1689 }
1690 }
1691
1692 fn check_for_login_form(&self, html: &str) -> bool {
1694 let document = Html::parse_document(html);
1695 let login_form_selector =
1696 Selector::parse("form[action*=\"login\"], input[name=\"username_or_email\"]").unwrap();
1697 document.select(&login_form_selector).next().is_some()
1698 }
1699
1700 pub async fn get(&self, url: &str) -> Result<Response> {
1702 self.get_with_retry(url, 3).await
1703 }
1704
1705 async fn get_with_retry(&self, url: &str, max_retries: u32) -> Result<Response> {
1707 let mut retries = 0;
1708
1709 loop {
1710 match self.get_with_redirects(url, 0).await {
1711 Ok(mut response) => {
1712 let body = self.extract_response_body(url, &mut response).await?;
1714
1715 if response.status().is_success() && self.is_rate_limit_response(&body) {
1717 log::debug!("Response body contains rate limit patterns");
1718 if retries < max_retries {
1719 let delay = 60 + (retries as u64 * 30); log::info!("Rate limit detected in response body, retrying in {delay}s (attempt {}/{max_retries})", retries + 1);
1721 retries += 1;
1723 continue;
1724 }
1725 return Err(crate::LastFmError::RateLimit { retry_after: 60 });
1726 }
1727
1728 let mut new_response = http_types::Response::new(response.status());
1730 for (name, values) in response.iter() {
1731 for value in values {
1732 let _ = new_response.insert_header(name.clone(), value.clone());
1733 }
1734 }
1735 new_response.set_body(body);
1736
1737 return Ok(new_response);
1738 }
1739 Err(crate::LastFmError::RateLimit { retry_after }) => {
1740 if retries < max_retries {
1741 let delay = retry_after + (retries as u64 * 30); log::info!(
1743 "Rate limit detected, retrying in {delay}s (attempt {}/{max_retries})",
1744 retries + 1
1745 );
1746 retries += 1;
1748 } else {
1749 return Err(crate::LastFmError::RateLimit { retry_after });
1750 }
1751 }
1752 Err(e) => return Err(e),
1753 }
1754 }
1755 }
1756
1757 async fn get_with_redirects(&self, url: &str, redirect_count: u32) -> Result<Response> {
1758 if redirect_count > 5 {
1759 return Err(LastFmError::Http("Too many redirects".to_string()));
1760 }
1761
1762 let mut request = Request::new(Method::Get, url.parse::<Url>().unwrap());
1763 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");
1764
1765 {
1767 let session = self.session.lock().unwrap();
1768 if !session.cookies.is_empty() {
1769 let cookie_header = session.cookies.join("; ");
1770 let _ = request.insert_header("Cookie", &cookie_header);
1771 } else if url.contains("page=") {
1772 log::debug!("No cookies available for paginated request!");
1773 }
1774 }
1775
1776 if url.contains("ajax=true") {
1778 let _ = request.insert_header("Accept", "*/*");
1780 let _ = request.insert_header("X-Requested-With", "XMLHttpRequest");
1781 } else {
1782 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");
1784 }
1785 let _ = request.insert_header("Accept-Language", "en-US,en;q=0.9");
1786 let _ = request.insert_header("Accept-Encoding", "gzip, deflate, br");
1787 let _ = request.insert_header("DNT", "1");
1788 let _ = request.insert_header("Connection", "keep-alive");
1789 let _ = request.insert_header("Upgrade-Insecure-Requests", "1");
1790
1791 if url.contains("page=") {
1793 let base_url = url.split('?').next().unwrap_or(url);
1794 let _ = request.insert_header("Referer", base_url);
1795 }
1796
1797 let response = self
1798 .client
1799 .send(request)
1800 .await
1801 .map_err(|e| LastFmError::Http(e.to_string()))?;
1802
1803 self.extract_cookies(&response);
1805
1806 if response.status() == 302 || response.status() == 301 {
1808 if let Some(location) = response.header("location") {
1809 if let Some(redirect_url) = location.get(0) {
1810 let redirect_url_str = redirect_url.as_str();
1811 if url.contains("page=") {
1812 log::debug!("Following redirect from {url} to {redirect_url_str}");
1813
1814 if redirect_url_str.contains("/login") {
1816 log::debug!("Redirect to login page - authentication failed for paginated request");
1817 return Err(LastFmError::Auth(
1818 "Session expired or invalid for paginated request".to_string(),
1819 ));
1820 }
1821 }
1822
1823 let full_redirect_url = if redirect_url_str.starts_with('/') {
1825 let base_url = self.session.lock().unwrap().base_url.clone();
1826 format!("{base_url}{redirect_url_str}")
1827 } else if redirect_url_str.starts_with("http") {
1828 redirect_url_str.to_string()
1829 } else {
1830 let base_url = url
1832 .rsplit('/')
1833 .skip(1)
1834 .collect::<Vec<_>>()
1835 .into_iter()
1836 .rev()
1837 .collect::<Vec<_>>()
1838 .join("/");
1839 format!("{base_url}/{redirect_url_str}")
1840 };
1841
1842 return Box::pin(
1844 self.get_with_redirects(&full_redirect_url, redirect_count + 1),
1845 )
1846 .await;
1847 }
1848 }
1849 }
1850
1851 if response.status() == 429 {
1853 let retry_after = response
1854 .header("retry-after")
1855 .and_then(|h| h.get(0))
1856 .and_then(|v| v.as_str().parse::<u64>().ok())
1857 .unwrap_or(60);
1858 return Err(LastFmError::RateLimit { retry_after });
1859 }
1860
1861 if response.status() == 403 {
1863 log::debug!("Got 403 response, checking if it's a rate limit");
1864 {
1866 let session = self.session.lock().unwrap();
1867 if !session.cookies.is_empty() {
1868 log::debug!("403 on authenticated request - likely rate limit");
1869 return Err(LastFmError::RateLimit { retry_after: 60 });
1870 }
1871 }
1872 }
1873
1874 Ok(response)
1875 }
1876
1877 fn is_rate_limit_response(&self, response_body: &str) -> bool {
1879 let body_lower = response_body.to_lowercase();
1880
1881 for pattern in &self.rate_limit_patterns {
1883 if body_lower.contains(&pattern.to_lowercase()) {
1884 return true;
1885 }
1886 }
1887
1888 false
1889 }
1890
1891 fn extract_cookies(&self, response: &Response) {
1892 if let Some(cookie_headers) = response.header("set-cookie") {
1894 let mut new_cookies = 0;
1895 for cookie_header in cookie_headers {
1896 let cookie_str = cookie_header.as_str();
1897 if let Some(cookie_value) = cookie_str.split(';').next() {
1899 let cookie_name = cookie_value.split('=').next().unwrap_or("");
1900
1901 {
1903 let mut session = self.session.lock().unwrap();
1904 session
1905 .cookies
1906 .retain(|existing| !existing.starts_with(&format!("{cookie_name}=")));
1907 session.cookies.push(cookie_value.to_string());
1908 }
1909 new_cookies += 1;
1910 }
1911 }
1912 if new_cookies > 0 {
1913 {
1914 let session = self.session.lock().unwrap();
1915 log::trace!(
1916 "Extracted {} new cookies, total: {}",
1917 new_cookies,
1918 session.cookies.len()
1919 );
1920 log::trace!("Updated cookies: {:?}", &session.cookies);
1921
1922 for cookie in &session.cookies {
1924 if cookie.starts_with("sessionid=") {
1925 log::trace!("Current sessionid: {}", &cookie[10..50.min(cookie.len())]);
1926 break;
1927 }
1928 }
1929 }
1930 }
1931 }
1932 }
1933
1934 async fn extract_response_body(&self, url: &str, response: &mut Response) -> Result<String> {
1936 let body = response
1937 .body_string()
1938 .await
1939 .map_err(|e| LastFmError::Http(e.to_string()))?;
1940
1941 if self.debug_save_responses {
1942 self.save_debug_response(url, response.status().into(), &body);
1943 }
1944
1945 Ok(body)
1946 }
1947
1948 fn save_debug_response(&self, url: &str, status_code: u16, body: &str) {
1950 if let Err(e) = self.try_save_debug_response(url, status_code, body) {
1951 log::warn!("Failed to save debug response: {e}");
1952 }
1953 }
1954
1955 fn try_save_debug_response(&self, url: &str, status_code: u16, body: &str) -> Result<()> {
1957 let debug_dir = Path::new("debug_responses");
1959 if !debug_dir.exists() {
1960 fs::create_dir_all(debug_dir)
1961 .map_err(|e| LastFmError::Http(format!("Failed to create debug directory: {e}")))?;
1962 }
1963
1964 let url_path = {
1966 let session = self.session.lock().unwrap();
1967 if url.starts_with(&session.base_url) {
1968 &url[session.base_url.len()..]
1969 } else {
1970 url
1971 }
1972 };
1973
1974 let now = chrono::Utc::now();
1976 let timestamp = now.format("%Y%m%d_%H%M%S_%3f");
1977 let safe_path = url_path.replace(['/', '?', '&', '=', '%', '+'], "_");
1978
1979 let filename = format!("{timestamp}_{safe_path}_status{status_code}.html");
1980 let file_path = debug_dir.join(filename);
1981
1982 fs::write(&file_path, body)
1984 .map_err(|e| LastFmError::Http(format!("Failed to write debug file: {e}")))?;
1985
1986 log::debug!(
1987 "Saved HTTP response to {file_path:?} (status: {status_code}, url: {url_path})"
1988 );
1989
1990 Ok(())
1991 }
1992
1993 pub async fn get_artist_albums_page(&self, artist: &str, page: u32) -> Result<AlbumPage> {
1994 let url = {
1996 let session = self.session.lock().unwrap();
1997 format!(
1998 "{}/user/{}/library/music/{}/+albums?page={}&ajax=true",
1999 session.base_url,
2000 session.username,
2001 artist.replace(" ", "+"),
2002 page
2003 )
2004 };
2005
2006 log::debug!("Fetching albums page {page} for artist: {artist}");
2007 let mut response = self.get(&url).await?;
2008 let content = response
2009 .body_string()
2010 .await
2011 .map_err(|e| LastFmError::Http(e.to_string()))?;
2012
2013 log::debug!(
2014 "AJAX response: {} status, {} chars",
2015 response.status(),
2016 content.len()
2017 );
2018
2019 if content.trim_start().starts_with("{") || content.trim_start().starts_with("[") {
2021 log::debug!("Parsing JSON response from AJAX endpoint");
2022 self.parse_json_albums_page(&content, page, artist)
2023 } else {
2024 log::debug!("Parsing HTML response from AJAX endpoint");
2025 let document = Html::parse_document(&content);
2026 self.parser.parse_albums_page(&document, page, artist)
2027 }
2028 }
2029
2030 fn parse_json_albums_page(
2031 &self,
2032 _json_content: &str,
2033 page_number: u32,
2034 _artist: &str,
2035 ) -> Result<AlbumPage> {
2036 log::debug!("JSON parsing not implemented, returning empty page");
2038 Ok(AlbumPage {
2039 albums: Vec::new(),
2040 page_number,
2041 has_next_page: false,
2042 total_pages: Some(1),
2043 })
2044 }
2045}