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 async fn get_recent_scrobbles(&self, page: u32) -> Result<Vec<Track>> {
533 let url = {
534 let session = self.session.lock().unwrap();
535 format!(
536 "{}/user/{}/library?page={}",
537 session.base_url, session.username, page
538 )
539 };
540
541 log::debug!("Fetching recent scrobbles page {page}");
542 let mut response = self.get(&url).await?;
543 let content = response
544 .body_string()
545 .await
546 .map_err(|e| LastFmError::Http(e.to_string()))?;
547
548 log::debug!(
549 "Recent scrobbles response: {} status, {} chars",
550 response.status(),
551 content.len()
552 );
553
554 let document = Html::parse_document(&content);
555 self.parser.parse_recent_scrobbles(&document)
556 }
557
558 pub async fn find_recent_scrobble_for_track(
561 &self,
562 track_name: &str,
563 artist_name: &str,
564 max_pages: u32,
565 ) -> Result<Option<Track>> {
566 log::debug!("Searching for recent scrobble: '{track_name}' by '{artist_name}'");
567
568 for page in 1..=max_pages {
569 let scrobbles = self.get_recent_scrobbles(page).await?;
570
571 for scrobble in scrobbles {
572 if scrobble.name == track_name && scrobble.artist == artist_name {
573 log::debug!(
574 "Found recent scrobble: '{}' with timestamp {:?}",
575 scrobble.name,
576 scrobble.timestamp
577 );
578 return Ok(Some(scrobble));
579 }
580 }
581
582 }
584
585 log::debug!(
586 "No recent scrobble found for '{track_name}' by '{artist_name}' in {max_pages} pages"
587 );
588 Ok(None)
589 }
590
591 pub async fn edit_scrobble(&self, edit: &ScrobbleEdit) -> Result<EditResponse> {
592 self.edit_scrobble_with_retry(edit, 3).await
593 }
594
595 pub async fn edit_scrobble_with_retry(
596 &self,
597 edit: &ScrobbleEdit,
598 max_retries: u32,
599 ) -> Result<EditResponse> {
600 let mut retries = 0;
601
602 loop {
603 match self.edit_scrobble_impl(edit).await {
604 Ok(result) => return Ok(result),
605 Err(LastFmError::RateLimit { retry_after }) => {
606 if retries >= max_retries {
607 log::warn!("Max retries ({max_retries}) exceeded for edit operation");
608 return Err(LastFmError::RateLimit { retry_after });
609 }
610
611 let delay = std::cmp::min(retry_after, 2_u64.pow(retries + 1) * 5);
612 log::info!(
613 "Edit rate limited. Waiting {} seconds before retry {} of {}",
614 delay,
615 retries + 1,
616 max_retries
617 );
618 retries += 1;
620 }
621 Err(other_error) => return Err(other_error),
622 }
623 }
624 }
625
626 async fn edit_scrobble_impl(&self, edit: &ScrobbleEdit) -> Result<EditResponse> {
627 if !self.is_logged_in() {
628 return Err(LastFmError::Auth(
629 "Must be logged in to edit scrobbles".to_string(),
630 ));
631 }
632
633 let edit_url = {
634 let session = self.session.lock().unwrap();
635 format!(
636 "{}/user/{}/library/edit?edited-variation=library-track-scrobble",
637 session.base_url, session.username
638 )
639 };
640
641 log::debug!("Getting fresh CSRF token for edit");
642
643 let form_html = self.get_edit_form_html(&edit_url).await?;
645
646 let form_document = Html::parse_document(&form_html);
648 let fresh_csrf_token = self.extract_csrf_token(&form_document)?;
649
650 log::debug!("Submitting edit with fresh token");
651
652 let mut form_data = HashMap::new();
653
654 form_data.insert("csrfmiddlewaretoken", fresh_csrf_token.as_str());
656
657 form_data.insert("track_name_original", &edit.track_name_original);
659 form_data.insert("track_name", &edit.track_name);
660 form_data.insert("artist_name_original", &edit.artist_name_original);
661 form_data.insert("artist_name", &edit.artist_name);
662 form_data.insert("album_name_original", &edit.album_name_original);
663 form_data.insert("album_name", &edit.album_name);
664 form_data.insert(
665 "album_artist_name_original",
666 &edit.album_artist_name_original,
667 );
668 form_data.insert("album_artist_name", &edit.album_artist_name);
669
670 let timestamp_str = edit.timestamp.to_string();
672 form_data.insert("timestamp", ×tamp_str);
673
674 if edit.edit_all {
676 form_data.insert("edit_all", "1");
677 }
678 form_data.insert("submit", "edit-scrobble");
679 form_data.insert("ajax", "1");
680
681 log::debug!(
682 "Editing scrobble: '{}' -> '{}'",
683 edit.track_name_original,
684 edit.track_name
685 );
686 {
687 let session = self.session.lock().unwrap();
688 log::trace!("Session cookies count: {}", session.cookies.len());
689 }
690
691 let mut request = Request::new(Method::Post, edit_url.parse::<Url>().unwrap());
692
693 let _ = request.insert_header("Accept", "*/*");
695 let _ = request.insert_header("Accept-Language", "en-US,en;q=0.9");
696 let _ = request.insert_header(
697 "Content-Type",
698 "application/x-www-form-urlencoded;charset=UTF-8",
699 );
700 let _ = request.insert_header("Priority", "u=1, i");
701 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");
702 let _ = request.insert_header("X-Requested-With", "XMLHttpRequest");
703 let _ = request.insert_header("Sec-Fetch-Dest", "empty");
704 let _ = request.insert_header("Sec-Fetch-Mode", "cors");
705 let _ = request.insert_header("Sec-Fetch-Site", "same-origin");
706 let _ = request.insert_header(
707 "sec-ch-ua",
708 "\"Not)A;Brand\";v=\"8\", \"Chromium\";v=\"138\", \"Google Chrome\";v=\"138\"",
709 );
710 let _ = request.insert_header("sec-ch-ua-mobile", "?0");
711 let _ = request.insert_header("sec-ch-ua-platform", "\"Linux\"");
712
713 {
715 let session = self.session.lock().unwrap();
716 if !session.cookies.is_empty() {
717 let cookie_header = session.cookies.join("; ");
718 let _ = request.insert_header("Cookie", &cookie_header);
719 }
720 }
721
722 {
724 let session = self.session.lock().unwrap();
725 let _ = request.insert_header(
726 "Referer",
727 format!("{}/user/{}/library", session.base_url, session.username),
728 );
729 }
730
731 let form_string: String = form_data
733 .iter()
734 .map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v)))
735 .collect::<Vec<_>>()
736 .join("&");
737
738 request.set_body(form_string);
739
740 let mut response = self
741 .client
742 .send(request)
743 .await
744 .map_err(|e| LastFmError::Http(e.to_string()))?;
745
746 log::debug!("Edit response status: {}", response.status());
747
748 let response_text = response
749 .body_string()
750 .await
751 .map_err(|e| LastFmError::Http(e.to_string()))?;
752
753 let document = Html::parse_document(&response_text);
755
756 let success_selector = Selector::parse(".alert-success").unwrap();
758 let error_selector = Selector::parse(".alert-danger, .alert-error, .error").unwrap();
759
760 let has_success_alert = document.select(&success_selector).next().is_some();
761 let has_error_alert = document.select(&error_selector).next().is_some();
762
763 let mut actual_track_name = None;
766 let mut actual_album_name = None;
767
768 let track_name_selector = Selector::parse("td.chartlist-name a").unwrap();
770 let album_name_selector = Selector::parse("td.chartlist-album a").unwrap();
771
772 if let Some(track_element) = document.select(&track_name_selector).next() {
773 actual_track_name = Some(track_element.text().collect::<String>().trim().to_string());
774 }
775
776 if let Some(album_element) = document.select(&album_name_selector).next() {
777 actual_album_name = Some(album_element.text().collect::<String>().trim().to_string());
778 }
779
780 if actual_track_name.is_none() || actual_album_name.is_none() {
782 let track_pattern = regex::Regex::new(r#"href="/music/[^"]+/_/([^"]+)""#).unwrap();
785 if let Some(captures) = track_pattern.captures(&response_text) {
786 if let Some(track_match) = captures.get(1) {
787 let raw_track = track_match.as_str();
788 let decoded_track = urlencoding::decode(raw_track)
790 .unwrap_or_else(|_| raw_track.into())
791 .replace("+", " ");
792 actual_track_name = Some(decoded_track);
793 }
794 }
795
796 let album_pattern =
799 regex::Regex::new(r#"href="/music/[^"]+/([^"/_]+)"[^>]*>[^<]*</a>"#).unwrap();
800 if let Some(captures) = album_pattern.captures(&response_text) {
801 if let Some(album_match) = captures.get(1) {
802 let raw_album = album_match.as_str();
803 let decoded_album = urlencoding::decode(raw_album)
805 .unwrap_or_else(|_| raw_album.into())
806 .replace("+", " ");
807 actual_album_name = Some(decoded_album);
808 }
809 }
810 }
811
812 log::debug!(
813 "Response analysis: success_alert={}, error_alert={}, track='{}', album='{}'",
814 has_success_alert,
815 has_error_alert,
816 actual_track_name.as_deref().unwrap_or("not found"),
817 actual_album_name.as_deref().unwrap_or("not found")
818 );
819
820 let final_success = response.status().is_success() && has_success_alert && !has_error_alert;
822
823 let message = if has_error_alert {
825 if let Some(error_element) = document.select(&error_selector).next() {
827 Some(format!(
828 "Edit failed: {}",
829 error_element.text().collect::<String>().trim()
830 ))
831 } else {
832 Some("Edit failed with unknown error".to_string())
833 }
834 } else if final_success {
835 Some(format!(
836 "Edit successful - Track: '{}', Album: '{}'",
837 actual_track_name.as_deref().unwrap_or("unknown"),
838 actual_album_name.as_deref().unwrap_or("unknown")
839 ))
840 } else {
841 Some(format!("Edit failed with status: {}", response.status()))
842 };
843
844 Ok(EditResponse {
845 success: final_success,
846 message,
847 })
848 }
849
850 async fn get_edit_form_html(&self, edit_url: &str) -> Result<String> {
853 let mut form_response = self.get(edit_url).await?;
854 let form_html = form_response
855 .body_string()
856 .await
857 .map_err(|e| LastFmError::Http(e.to_string()))?;
858
859 log::debug!("Edit form response status: {}", form_response.status());
860 Ok(form_html)
861 }
862
863 pub async fn load_edit_form_values(
866 &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 = {
876 let session = self.session.lock().unwrap();
877 format!(
878 "{}/user/{}/library/music/+noredirect/{}/_/{}",
879 session.base_url,
880 session.username,
881 urlencoding::encode(artist_name),
882 urlencoding::encode(track_name)
883 )
884 };
885
886 log::debug!("Fetching track page: {track_url}");
887
888 let mut response = self.get(&track_url).await?;
889 let html = response
890 .body_string()
891 .await
892 .map_err(|e| crate::LastFmError::Http(e.to_string()))?;
893
894 let document = Html::parse_document(&html);
895
896 self.extract_scrobble_data_from_track_page(&document, track_name, artist_name)
898 }
899
900 fn extract_scrobble_data_from_track_page(
903 &self,
904 document: &Html,
905 expected_track: &str,
906 expected_artist: &str,
907 ) -> Result<crate::ScrobbleEdit> {
908 let table_selector =
910 Selector::parse("table.chartlist:not(.chartlist__placeholder)").unwrap();
911 let table = document.select(&table_selector).next().ok_or_else(|| {
912 crate::LastFmError::Parse("No chartlist table found on track page".to_string())
913 })?;
914
915 let row_selector = Selector::parse("tr").unwrap();
917 for row in table.select(&row_selector) {
918 let count_bar_link_selector = Selector::parse(".chartlist-count-bar-link").unwrap();
920 if row.select(&count_bar_link_selector).next().is_some() {
921 log::debug!("Found count bar link, skipping aggregated row");
922 continue;
923 }
924
925 let form_selector = Selector::parse("form[data-edit-scrobble]").unwrap();
927 if let Some(form) = row.select(&form_selector).next() {
928 let extract_form_value = |name: &str| -> Option<String> {
930 let selector = Selector::parse(&format!("input[name='{name}']")).unwrap();
931 form.select(&selector)
932 .next()
933 .and_then(|input| input.value().attr("value"))
934 .map(|s| s.to_string())
935 };
936
937 let form_track = extract_form_value("track_name").unwrap_or_default();
939 let form_artist = extract_form_value("artist_name").unwrap_or_default();
940 let form_album = extract_form_value("album_name").unwrap_or_default();
941 let form_album_artist =
942 extract_form_value("album_artist_name").unwrap_or_else(|| form_artist.clone());
943 let form_timestamp = extract_form_value("timestamp").unwrap_or_default();
944
945 log::debug!(
946 "Found scrobble form - Track: '{form_track}', Artist: '{form_artist}', Album: '{form_album}', Timestamp: {form_timestamp}"
947 );
948
949 if form_track == expected_track && form_artist == expected_artist {
951 let timestamp = form_timestamp.parse::<u64>().map_err(|_| {
952 crate::LastFmError::Parse("Invalid timestamp in form".to_string())
953 })?;
954
955 log::debug!(
956 "✅ Found matching scrobble form for '{expected_track}' by '{expected_artist}'"
957 );
958
959 return Ok(crate::ScrobbleEdit::new(
961 form_track.clone(),
962 form_album.clone(),
963 form_artist.clone(),
964 form_album_artist.clone(),
965 form_track,
966 form_album,
967 form_artist,
968 form_album_artist,
969 timestamp,
970 true,
971 ));
972 }
973 }
974 }
975
976 Err(crate::LastFmError::Parse(format!(
977 "No scrobble form found for track '{expected_track}' by '{expected_artist}'"
978 )))
979 }
980
981 pub async fn get_album_tracks(
984 &self,
985 album_name: &str,
986 artist_name: &str,
987 ) -> Result<Vec<Track>> {
988 log::debug!("Getting tracks from album '{album_name}' by '{artist_name}'");
989
990 let album_url = {
992 let session = self.session.lock().unwrap();
993 format!(
994 "{}/user/{}/library/music/{}/{}",
995 session.base_url,
996 session.username,
997 urlencoding::encode(artist_name),
998 urlencoding::encode(album_name)
999 )
1000 };
1001
1002 log::debug!("Fetching album page: {album_url}");
1003
1004 let mut response = self.get(&album_url).await?;
1005 let html = response
1006 .body_string()
1007 .await
1008 .map_err(|e| LastFmError::Http(e.to_string()))?;
1009
1010 let document = Html::parse_document(&html);
1011
1012 let tracks = self
1014 .parser
1015 .extract_tracks_from_document(&document, artist_name)?;
1016
1017 log::debug!(
1018 "Successfully parsed {} tracks from album page",
1019 tracks.len()
1020 );
1021 Ok(tracks)
1022 }
1023
1024 pub async fn edit_album(
1027 &self,
1028 old_album_name: &str,
1029 new_album_name: &str,
1030 artist_name: &str,
1031 ) -> Result<EditResponse> {
1032 log::debug!("Editing album '{old_album_name}' -> '{new_album_name}' by '{artist_name}'");
1033
1034 let tracks = self.get_album_tracks(old_album_name, artist_name).await?;
1036
1037 if tracks.is_empty() {
1038 return Ok(EditResponse {
1039 success: false,
1040 message: Some(format!(
1041 "No tracks found for album '{old_album_name}' by '{artist_name}'. Make sure the album name matches exactly."
1042 )),
1043 });
1044 }
1045
1046 log::info!(
1047 "Found {} tracks in album '{}'",
1048 tracks.len(),
1049 old_album_name
1050 );
1051
1052 let mut successful_edits = 0;
1053 let mut failed_edits = 0;
1054 let mut error_messages = Vec::new();
1055 let mut skipped_tracks = 0;
1056
1057 for (index, track) in tracks.iter().enumerate() {
1059 log::debug!(
1060 "Processing track {}/{}: '{}'",
1061 index + 1,
1062 tracks.len(),
1063 track.name
1064 );
1065
1066 match self.load_edit_form_values(&track.name, artist_name).await {
1067 Ok(mut edit_data) => {
1068 edit_data.album_name = new_album_name.to_string();
1070
1071 match self.edit_scrobble(&edit_data).await {
1073 Ok(response) => {
1074 if response.success {
1075 successful_edits += 1;
1076 log::info!("✅ Successfully edited track '{}'", track.name);
1077 } else {
1078 failed_edits += 1;
1079 let error_msg = format!(
1080 "Failed to edit track '{}': {}",
1081 track.name,
1082 response
1083 .message
1084 .unwrap_or_else(|| "Unknown error".to_string())
1085 );
1086 error_messages.push(error_msg);
1087 log::debug!("❌ {}", error_messages.last().unwrap());
1088 }
1089 }
1090 Err(e) => {
1091 failed_edits += 1;
1092 let error_msg = format!("Error editing track '{}': {}", track.name, e);
1093 error_messages.push(error_msg);
1094 log::info!("❌ {}", error_messages.last().unwrap());
1095 }
1096 }
1097 }
1098 Err(e) => {
1099 skipped_tracks += 1;
1100 log::debug!("Could not load edit form for track '{}': {e}", track.name);
1101 }
1103 }
1104
1105 }
1107
1108 let total_processed = successful_edits + failed_edits;
1109 let success = successful_edits > 0 && failed_edits == 0;
1110
1111 let message = if success {
1112 Some(format!(
1113 "Successfully renamed album '{old_album_name}' to '{new_album_name}' for all {successful_edits} editable tracks ({skipped_tracks} tracks were not in recent scrobbles)"
1114 ))
1115 } else if successful_edits > 0 {
1116 Some(format!(
1117 "Partially successful: {} of {} editable tracks renamed ({} skipped, {} failed). Errors: {}",
1118 successful_edits,
1119 total_processed,
1120 skipped_tracks,
1121 failed_edits,
1122 error_messages.join("; ")
1123 ))
1124 } else if total_processed == 0 {
1125 Some(format!(
1126 "No editable tracks found for album '{}' by '{}'. All {} tracks were skipped because they're not in recent scrobbles.",
1127 old_album_name, artist_name, tracks.len()
1128 ))
1129 } else {
1130 Some(format!(
1131 "Failed to rename any tracks. Errors: {}",
1132 error_messages.join("; ")
1133 ))
1134 };
1135
1136 Ok(EditResponse { success, message })
1137 }
1138
1139 pub async fn edit_artist(
1142 &self,
1143 old_artist_name: &str,
1144 new_artist_name: &str,
1145 ) -> Result<EditResponse> {
1146 log::debug!("Editing artist '{old_artist_name}' -> '{new_artist_name}'");
1147
1148 let mut tracks = Vec::new();
1150 let mut iterator = self.artist_tracks(old_artist_name);
1151
1152 while tracks.len() < 200 {
1154 match iterator.next().await {
1155 Ok(Some(track)) => tracks.push(track),
1156 Ok(None) => break,
1157 Err(e) => {
1158 log::warn!("Error fetching artist tracks: {e}");
1159 break;
1160 }
1161 }
1162 }
1163
1164 if tracks.is_empty() {
1165 return Ok(EditResponse {
1166 success: false,
1167 message: Some(format!(
1168 "No tracks found for artist '{old_artist_name}'. Make sure the artist name matches exactly."
1169 )),
1170 });
1171 }
1172
1173 log::info!(
1174 "Found {} tracks for artist '{}'",
1175 tracks.len(),
1176 old_artist_name
1177 );
1178
1179 let mut successful_edits = 0;
1180 let mut failed_edits = 0;
1181 let mut error_messages = Vec::new();
1182 let mut skipped_tracks = 0;
1183
1184 for (index, track) in tracks.iter().enumerate() {
1186 log::debug!(
1187 "Processing track {}/{}: '{}'",
1188 index + 1,
1189 tracks.len(),
1190 track.name
1191 );
1192
1193 match self
1194 .load_edit_form_values(&track.name, old_artist_name)
1195 .await
1196 {
1197 Ok(mut edit_data) => {
1198 edit_data.artist_name = new_artist_name.to_string();
1200 edit_data.album_artist_name = new_artist_name.to_string();
1201
1202 match self.edit_scrobble(&edit_data).await {
1204 Ok(response) => {
1205 if response.success {
1206 successful_edits += 1;
1207 log::info!("✅ Successfully edited track '{}'", track.name);
1208 } else {
1209 failed_edits += 1;
1210 let error_msg = format!(
1211 "Failed to edit track '{}': {}",
1212 track.name,
1213 response
1214 .message
1215 .unwrap_or_else(|| "Unknown error".to_string())
1216 );
1217 error_messages.push(error_msg);
1218 log::debug!("❌ {}", error_messages.last().unwrap());
1219 }
1220 }
1221 Err(e) => {
1222 failed_edits += 1;
1223 let error_msg = format!("Error editing track '{}': {}", track.name, e);
1224 error_messages.push(error_msg);
1225 log::info!("❌ {}", error_messages.last().unwrap());
1226 }
1227 }
1228 }
1229 Err(e) => {
1230 skipped_tracks += 1;
1231 log::debug!("Could not load edit form for track '{}': {e}", track.name);
1232 }
1234 }
1235
1236 }
1238
1239 let total_processed = successful_edits + failed_edits;
1240 let success = successful_edits > 0 && failed_edits == 0;
1241
1242 let message = if success {
1243 Some(format!(
1244 "Successfully renamed artist '{old_artist_name}' to '{new_artist_name}' for all {successful_edits} editable tracks ({skipped_tracks} tracks were not in recent scrobbles)"
1245 ))
1246 } else if successful_edits > 0 {
1247 Some(format!(
1248 "Partially successful: {} of {} editable tracks renamed ({} skipped, {} failed). Errors: {}",
1249 successful_edits,
1250 total_processed,
1251 skipped_tracks,
1252 failed_edits,
1253 error_messages.join("; ")
1254 ))
1255 } else if total_processed == 0 {
1256 Some(format!(
1257 "No editable tracks found for artist '{}'. All {} tracks were skipped because they're not in recent scrobbles.",
1258 old_artist_name, tracks.len()
1259 ))
1260 } else {
1261 Some(format!(
1262 "Failed to rename any tracks. Errors: {}",
1263 error_messages.join("; ")
1264 ))
1265 };
1266
1267 Ok(EditResponse { success, message })
1268 }
1269
1270 pub async fn edit_artist_for_track(
1273 &self,
1274 track_name: &str,
1275 old_artist_name: &str,
1276 new_artist_name: &str,
1277 ) -> Result<EditResponse> {
1278 log::debug!("Editing artist for track '{track_name}' from '{old_artist_name}' -> '{new_artist_name}'");
1279
1280 match self.load_edit_form_values(track_name, old_artist_name).await {
1281 Ok(mut edit_data) => {
1282 edit_data.artist_name = new_artist_name.to_string();
1284 edit_data.album_artist_name = new_artist_name.to_string();
1285
1286 log::info!("Updating artist for track '{track_name}' from '{old_artist_name}' to '{new_artist_name}'");
1287
1288 match self.edit_scrobble(&edit_data).await {
1290 Ok(response) => {
1291 if response.success {
1292 Ok(EditResponse {
1293 success: true,
1294 message: Some(format!(
1295 "Successfully renamed artist for track '{track_name}' from '{old_artist_name}' to '{new_artist_name}'"
1296 )),
1297 })
1298 } else {
1299 Ok(EditResponse {
1300 success: false,
1301 message: Some(format!(
1302 "Failed to rename artist for track '{track_name}': {}",
1303 response.message.unwrap_or_else(|| "Unknown error".to_string())
1304 )),
1305 })
1306 }
1307 }
1308 Err(e) => Ok(EditResponse {
1309 success: false,
1310 message: Some(format!("Error editing track '{track_name}': {e}")),
1311 }),
1312 }
1313 }
1314 Err(e) => Ok(EditResponse {
1315 success: false,
1316 message: Some(format!(
1317 "Could not load edit form for track '{track_name}' by '{old_artist_name}': {e}. The track may not be in your recent scrobbles."
1318 )),
1319 }),
1320 }
1321 }
1322
1323 pub async fn edit_artist_for_album(
1326 &self,
1327 album_name: &str,
1328 old_artist_name: &str,
1329 new_artist_name: &str,
1330 ) -> Result<EditResponse> {
1331 log::debug!("Editing artist for album '{album_name}' from '{old_artist_name}' -> '{new_artist_name}'");
1332
1333 let tracks = self.get_album_tracks(album_name, old_artist_name).await?;
1335
1336 if tracks.is_empty() {
1337 return Ok(EditResponse {
1338 success: false,
1339 message: Some(format!(
1340 "No tracks found for album '{album_name}' by '{old_artist_name}'. Make sure the album name matches exactly."
1341 )),
1342 });
1343 }
1344
1345 log::info!(
1346 "Found {} tracks in album '{}' by '{}'",
1347 tracks.len(),
1348 album_name,
1349 old_artist_name
1350 );
1351
1352 let mut successful_edits = 0;
1353 let mut failed_edits = 0;
1354 let mut error_messages = Vec::new();
1355 let mut skipped_tracks = 0;
1356
1357 for (index, track) in tracks.iter().enumerate() {
1359 log::debug!(
1360 "Processing track {}/{}: '{}'",
1361 index + 1,
1362 tracks.len(),
1363 track.name
1364 );
1365
1366 match self
1367 .load_edit_form_values(&track.name, old_artist_name)
1368 .await
1369 {
1370 Ok(mut edit_data) => {
1371 edit_data.artist_name = new_artist_name.to_string();
1373 edit_data.album_artist_name = new_artist_name.to_string();
1374
1375 match self.edit_scrobble(&edit_data).await {
1377 Ok(response) => {
1378 if response.success {
1379 successful_edits += 1;
1380 log::info!("✅ Successfully edited track '{}'", track.name);
1381 } else {
1382 failed_edits += 1;
1383 let error_msg = format!(
1384 "Failed to edit track '{}': {}",
1385 track.name,
1386 response
1387 .message
1388 .unwrap_or_else(|| "Unknown error".to_string())
1389 );
1390 error_messages.push(error_msg);
1391 log::debug!("❌ {}", error_messages.last().unwrap());
1392 }
1393 }
1394 Err(e) => {
1395 failed_edits += 1;
1396 let error_msg = format!("Error editing track '{}': {}", track.name, e);
1397 error_messages.push(error_msg);
1398 log::info!("❌ {}", error_messages.last().unwrap());
1399 }
1400 }
1401 }
1402 Err(e) => {
1403 skipped_tracks += 1;
1404 log::debug!("Could not load edit form for track '{}': {e}", track.name);
1405 }
1407 }
1408
1409 }
1411
1412 let total_processed = successful_edits + failed_edits;
1413 let success = successful_edits > 0 && failed_edits == 0;
1414
1415 let message = if success {
1416 Some(format!(
1417 "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)"
1418 ))
1419 } else if successful_edits > 0 {
1420 Some(format!(
1421 "Partially successful: {} of {} editable tracks renamed ({} skipped, {} failed). Errors: {}",
1422 successful_edits,
1423 total_processed,
1424 skipped_tracks,
1425 failed_edits,
1426 error_messages.join("; ")
1427 ))
1428 } else if total_processed == 0 {
1429 Some(format!(
1430 "No editable tracks found for album '{album_name}' by '{old_artist_name}'. All {} tracks were skipped because they're not in recent scrobbles.",
1431 tracks.len()
1432 ))
1433 } else {
1434 Some(format!(
1435 "Failed to rename any tracks. Errors: {}",
1436 error_messages.join("; ")
1437 ))
1438 };
1439
1440 Ok(EditResponse { success, message })
1441 }
1442
1443 pub async fn get_artist_tracks_page(&self, artist: &str, page: u32) -> Result<TrackPage> {
1444 let url = {
1446 let session = self.session.lock().unwrap();
1447 format!(
1448 "{}/user/{}/library/music/{}/+tracks?page={}&ajax=true",
1449 session.base_url,
1450 session.username,
1451 artist.replace(" ", "+"),
1452 page
1453 )
1454 };
1455
1456 log::debug!("Fetching tracks page {page} for artist: {artist}");
1457 let mut response = self.get(&url).await?;
1458 let content = response
1459 .body_string()
1460 .await
1461 .map_err(|e| LastFmError::Http(e.to_string()))?;
1462
1463 log::debug!(
1464 "AJAX response: {} status, {} chars",
1465 response.status(),
1466 content.len()
1467 );
1468
1469 if content.trim_start().starts_with("{") || content.trim_start().starts_with("[") {
1471 log::debug!("Parsing JSON response from AJAX endpoint");
1472 self.parse_json_tracks_page(&content, page, artist)
1473 } else {
1474 log::debug!("Parsing HTML response from AJAX endpoint");
1475 let document = Html::parse_document(&content);
1476 self.parser.parse_tracks_page(&document, page, artist)
1477 }
1478 }
1479
1480 fn parse_json_tracks_page(
1482 &self,
1483 _json_content: &str,
1484 page_number: u32,
1485 _artist: &str,
1486 ) -> Result<TrackPage> {
1487 log::debug!("JSON parsing not implemented, returning empty page");
1489 Ok(TrackPage {
1490 tracks: Vec::new(),
1491 page_number,
1492 has_next_page: false,
1493 total_pages: Some(1),
1494 })
1495 }
1496
1497 pub fn extract_tracks_from_document(
1499 &self,
1500 document: &Html,
1501 artist: &str,
1502 ) -> Result<Vec<Track>> {
1503 self.parser.extract_tracks_from_document(document, artist)
1504 }
1505
1506 pub fn parse_tracks_page(
1508 &self,
1509 document: &Html,
1510 page_number: u32,
1511 artist: &str,
1512 ) -> Result<TrackPage> {
1513 self.parser.parse_tracks_page(document, page_number, artist)
1514 }
1515
1516 pub fn parse_recent_scrobbles(&self, document: &Html) -> Result<Vec<Track>> {
1518 self.parser.parse_recent_scrobbles(document)
1519 }
1520
1521 fn extract_csrf_token(&self, document: &Html) -> Result<String> {
1522 let csrf_selector = Selector::parse("input[name=\"csrfmiddlewaretoken\"]").unwrap();
1523
1524 document
1525 .select(&csrf_selector)
1526 .next()
1527 .and_then(|input| input.value().attr("value"))
1528 .map(|token| token.to_string())
1529 .ok_or(LastFmError::CsrfNotFound)
1530 }
1531
1532 fn extract_login_form_data(&self, html: &str) -> Result<(String, Option<String>)> {
1534 let document = Html::parse_document(html);
1535
1536 let csrf_token = self.extract_csrf_token(&document)?;
1537
1538 let next_selector = Selector::parse("input[name=\"next\"]").unwrap();
1540 let next_field = document
1541 .select(&next_selector)
1542 .next()
1543 .and_then(|input| input.value().attr("value"))
1544 .map(|s| s.to_string());
1545
1546 Ok((csrf_token, next_field))
1547 }
1548
1549 fn parse_login_error(&self, html: &str) -> String {
1551 let document = Html::parse_document(html);
1552
1553 let error_selector = Selector::parse(".alert-danger, .form-error, .error-message").unwrap();
1554
1555 let mut error_messages = Vec::new();
1556 for error in document.select(&error_selector) {
1557 let error_text = error.text().collect::<String>().trim().to_string();
1558 if !error_text.is_empty() {
1559 error_messages.push(error_text);
1560 }
1561 }
1562
1563 if error_messages.is_empty() {
1564 "Login failed - please check your credentials".to_string()
1565 } else {
1566 format!("Login failed: {}", error_messages.join("; "))
1567 }
1568 }
1569
1570 fn check_for_login_form(&self, html: &str) -> bool {
1572 let document = Html::parse_document(html);
1573 let login_form_selector =
1574 Selector::parse("form[action*=\"login\"], input[name=\"username_or_email\"]").unwrap();
1575 document.select(&login_form_selector).next().is_some()
1576 }
1577
1578 pub async fn get(&self, url: &str) -> Result<Response> {
1580 self.get_with_retry(url, 3).await
1581 }
1582
1583 async fn get_with_retry(&self, url: &str, max_retries: u32) -> Result<Response> {
1585 let mut retries = 0;
1586
1587 loop {
1588 match self.get_with_redirects(url, 0).await {
1589 Ok(mut response) => {
1590 let body = self.extract_response_body(url, &mut response).await?;
1592
1593 if response.status().is_success() && self.is_rate_limit_response(&body) {
1595 log::debug!("Response body contains rate limit patterns");
1596 if retries < max_retries {
1597 let delay = 60 + (retries as u64 * 30); log::info!("Rate limit detected in response body, retrying in {delay}s (attempt {}/{max_retries})", retries + 1);
1599 retries += 1;
1601 continue;
1602 }
1603 return Err(crate::LastFmError::RateLimit { retry_after: 60 });
1604 }
1605
1606 let mut new_response = http_types::Response::new(response.status());
1608 for (name, values) in response.iter() {
1609 for value in values {
1610 let _ = new_response.insert_header(name.clone(), value.clone());
1611 }
1612 }
1613 new_response.set_body(body);
1614
1615 return Ok(new_response);
1616 }
1617 Err(crate::LastFmError::RateLimit { retry_after }) => {
1618 if retries < max_retries {
1619 let delay = retry_after + (retries as u64 * 30); log::info!(
1621 "Rate limit detected, retrying in {delay}s (attempt {}/{max_retries})",
1622 retries + 1
1623 );
1624 retries += 1;
1626 } else {
1627 return Err(crate::LastFmError::RateLimit { retry_after });
1628 }
1629 }
1630 Err(e) => return Err(e),
1631 }
1632 }
1633 }
1634
1635 async fn get_with_redirects(&self, url: &str, redirect_count: u32) -> Result<Response> {
1636 if redirect_count > 5 {
1637 return Err(LastFmError::Http("Too many redirects".to_string()));
1638 }
1639
1640 let mut request = Request::new(Method::Get, url.parse::<Url>().unwrap());
1641 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");
1642
1643 {
1645 let session = self.session.lock().unwrap();
1646 if !session.cookies.is_empty() {
1647 let cookie_header = session.cookies.join("; ");
1648 let _ = request.insert_header("Cookie", &cookie_header);
1649 } else if url.contains("page=") {
1650 log::debug!("No cookies available for paginated request!");
1651 }
1652 }
1653
1654 if url.contains("ajax=true") {
1656 let _ = request.insert_header("Accept", "*/*");
1658 let _ = request.insert_header("X-Requested-With", "XMLHttpRequest");
1659 } else {
1660 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");
1662 }
1663 let _ = request.insert_header("Accept-Language", "en-US,en;q=0.9");
1664 let _ = request.insert_header("Accept-Encoding", "gzip, deflate, br");
1665 let _ = request.insert_header("DNT", "1");
1666 let _ = request.insert_header("Connection", "keep-alive");
1667 let _ = request.insert_header("Upgrade-Insecure-Requests", "1");
1668
1669 if url.contains("page=") {
1671 let base_url = url.split('?').next().unwrap_or(url);
1672 let _ = request.insert_header("Referer", base_url);
1673 }
1674
1675 let response = self
1676 .client
1677 .send(request)
1678 .await
1679 .map_err(|e| LastFmError::Http(e.to_string()))?;
1680
1681 self.extract_cookies(&response);
1683
1684 if response.status() == 302 || response.status() == 301 {
1686 if let Some(location) = response.header("location") {
1687 if let Some(redirect_url) = location.get(0) {
1688 let redirect_url_str = redirect_url.as_str();
1689 if url.contains("page=") {
1690 log::debug!("Following redirect from {url} to {redirect_url_str}");
1691
1692 if redirect_url_str.contains("/login") {
1694 log::debug!("Redirect to login page - authentication failed for paginated request");
1695 return Err(LastFmError::Auth(
1696 "Session expired or invalid for paginated request".to_string(),
1697 ));
1698 }
1699 }
1700
1701 let full_redirect_url = if redirect_url_str.starts_with('/') {
1703 let base_url = self.session.lock().unwrap().base_url.clone();
1704 format!("{base_url}{redirect_url_str}")
1705 } else if redirect_url_str.starts_with("http") {
1706 redirect_url_str.to_string()
1707 } else {
1708 let base_url = url
1710 .rsplit('/')
1711 .skip(1)
1712 .collect::<Vec<_>>()
1713 .into_iter()
1714 .rev()
1715 .collect::<Vec<_>>()
1716 .join("/");
1717 format!("{base_url}/{redirect_url_str}")
1718 };
1719
1720 return Box::pin(
1722 self.get_with_redirects(&full_redirect_url, redirect_count + 1),
1723 )
1724 .await;
1725 }
1726 }
1727 }
1728
1729 if response.status() == 429 {
1731 let retry_after = response
1732 .header("retry-after")
1733 .and_then(|h| h.get(0))
1734 .and_then(|v| v.as_str().parse::<u64>().ok())
1735 .unwrap_or(60);
1736 return Err(LastFmError::RateLimit { retry_after });
1737 }
1738
1739 if response.status() == 403 {
1741 log::debug!("Got 403 response, checking if it's a rate limit");
1742 {
1744 let session = self.session.lock().unwrap();
1745 if !session.cookies.is_empty() {
1746 log::debug!("403 on authenticated request - likely rate limit");
1747 return Err(LastFmError::RateLimit { retry_after: 60 });
1748 }
1749 }
1750 }
1751
1752 Ok(response)
1753 }
1754
1755 fn is_rate_limit_response(&self, response_body: &str) -> bool {
1757 let body_lower = response_body.to_lowercase();
1758
1759 for pattern in &self.rate_limit_patterns {
1761 if body_lower.contains(&pattern.to_lowercase()) {
1762 return true;
1763 }
1764 }
1765
1766 false
1767 }
1768
1769 fn extract_cookies(&self, response: &Response) {
1770 if let Some(cookie_headers) = response.header("set-cookie") {
1772 let mut new_cookies = 0;
1773 for cookie_header in cookie_headers {
1774 let cookie_str = cookie_header.as_str();
1775 if let Some(cookie_value) = cookie_str.split(';').next() {
1777 let cookie_name = cookie_value.split('=').next().unwrap_or("");
1778
1779 {
1781 let mut session = self.session.lock().unwrap();
1782 session
1783 .cookies
1784 .retain(|existing| !existing.starts_with(&format!("{cookie_name}=")));
1785 session.cookies.push(cookie_value.to_string());
1786 }
1787 new_cookies += 1;
1788 }
1789 }
1790 if new_cookies > 0 {
1791 {
1792 let session = self.session.lock().unwrap();
1793 log::trace!(
1794 "Extracted {} new cookies, total: {}",
1795 new_cookies,
1796 session.cookies.len()
1797 );
1798 log::trace!("Updated cookies: {:?}", &session.cookies);
1799
1800 for cookie in &session.cookies {
1802 if cookie.starts_with("sessionid=") {
1803 log::trace!("Current sessionid: {}", &cookie[10..50.min(cookie.len())]);
1804 break;
1805 }
1806 }
1807 }
1808 }
1809 }
1810 }
1811
1812 async fn extract_response_body(&self, url: &str, response: &mut Response) -> Result<String> {
1814 let body = response
1815 .body_string()
1816 .await
1817 .map_err(|e| LastFmError::Http(e.to_string()))?;
1818
1819 if self.debug_save_responses {
1820 self.save_debug_response(url, response.status().into(), &body);
1821 }
1822
1823 Ok(body)
1824 }
1825
1826 fn save_debug_response(&self, url: &str, status_code: u16, body: &str) {
1828 if let Err(e) = self.try_save_debug_response(url, status_code, body) {
1829 log::warn!("Failed to save debug response: {e}");
1830 }
1831 }
1832
1833 fn try_save_debug_response(&self, url: &str, status_code: u16, body: &str) -> Result<()> {
1835 let debug_dir = Path::new("debug_responses");
1837 if !debug_dir.exists() {
1838 fs::create_dir_all(debug_dir)
1839 .map_err(|e| LastFmError::Http(format!("Failed to create debug directory: {e}")))?;
1840 }
1841
1842 let url_path = {
1844 let session = self.session.lock().unwrap();
1845 if url.starts_with(&session.base_url) {
1846 &url[session.base_url.len()..]
1847 } else {
1848 url
1849 }
1850 };
1851
1852 let now = chrono::Utc::now();
1854 let timestamp = now.format("%Y%m%d_%H%M%S_%3f");
1855 let safe_path = url_path.replace(['/', '?', '&', '=', '%', '+'], "_");
1856
1857 let filename = format!("{timestamp}_{safe_path}_status{status_code}.html");
1858 let file_path = debug_dir.join(filename);
1859
1860 fs::write(&file_path, body)
1862 .map_err(|e| LastFmError::Http(format!("Failed to write debug file: {e}")))?;
1863
1864 log::debug!(
1865 "Saved HTTP response to {file_path:?} (status: {status_code}, url: {url_path})"
1866 );
1867
1868 Ok(())
1869 }
1870
1871 pub async fn get_artist_albums_page(&self, artist: &str, page: u32) -> Result<AlbumPage> {
1872 let url = {
1874 let session = self.session.lock().unwrap();
1875 format!(
1876 "{}/user/{}/library/music/{}/+albums?page={}&ajax=true",
1877 session.base_url,
1878 session.username,
1879 artist.replace(" ", "+"),
1880 page
1881 )
1882 };
1883
1884 log::debug!("Fetching albums page {page} for artist: {artist}");
1885 let mut response = self.get(&url).await?;
1886 let content = response
1887 .body_string()
1888 .await
1889 .map_err(|e| LastFmError::Http(e.to_string()))?;
1890
1891 log::debug!(
1892 "AJAX response: {} status, {} chars",
1893 response.status(),
1894 content.len()
1895 );
1896
1897 if content.trim_start().starts_with("{") || content.trim_start().starts_with("[") {
1899 log::debug!("Parsing JSON response from AJAX endpoint");
1900 self.parse_json_albums_page(&content, page, artist)
1901 } else {
1902 log::debug!("Parsing HTML response from AJAX endpoint");
1903 let document = Html::parse_document(&content);
1904 self.parser.parse_albums_page(&document, page, artist)
1905 }
1906 }
1907
1908 fn parse_json_albums_page(
1909 &self,
1910 _json_content: &str,
1911 page_number: u32,
1912 _artist: &str,
1913 ) -> Result<AlbumPage> {
1914 log::debug!("JSON parsing not implemented, returning empty page");
1916 Ok(AlbumPage {
1917 albums: Vec::new(),
1918 page_number,
1919 has_next_page: false,
1920 total_pages: Some(1),
1921 })
1922 }
1923}