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