1use crate::parsing::LastFmParser;
2use crate::session::LastFmEditSession;
3use crate::{AlbumPage, EditResponse, LastFmError, Result, ScrobbleEdit, Track, TrackPage};
4use async_trait::async_trait;
5use http_client::{HttpClient, Request, Response};
6use http_types::{Method, Url};
7use scraper::{Html, Selector};
8use std::collections::HashMap;
9use std::fs;
10use std::path::Path;
11use std::sync::{Arc, Mutex};
12
13#[cfg_attr(feature = "mock", mockall::automock)]
50#[async_trait(?Send)]
51pub trait LastFmEditClient {
52 async fn login(&self, username: &str, password: &str) -> Result<()>;
54
55 fn username(&self) -> String;
57
58 fn is_logged_in(&self) -> bool;
60
61 async fn get_recent_scrobbles(&self, page: u32) -> Result<Vec<Track>>;
63
64 async fn find_scrobble_by_timestamp(&self, timestamp: u64) -> Result<Track>;
66
67 async fn find_recent_scrobble_for_track(
69 &self,
70 track_name: &str,
71 artist_name: &str,
72 max_pages: u32,
73 ) -> Result<Option<Track>>;
74
75 async fn edit_scrobble(&self, edit: &ScrobbleEdit) -> Result<EditResponse>;
77
78 async fn load_edit_form_values(
80 &self,
81 track_name: &str,
82 artist_name: &str,
83 ) -> Result<ScrobbleEdit>;
84
85 async fn get_album_tracks(&self, album_name: &str, artist_name: &str) -> Result<Vec<Track>>;
87
88 async fn edit_album(
90 &self,
91 old_album_name: &str,
92 new_album_name: &str,
93 artist_name: &str,
94 ) -> Result<EditResponse>;
95
96 async fn edit_artist(
98 &self,
99 old_artist_name: &str,
100 new_artist_name: &str,
101 ) -> Result<EditResponse>;
102
103 async fn edit_artist_for_track(
105 &self,
106 track_name: &str,
107 old_artist_name: &str,
108 new_artist_name: &str,
109 ) -> Result<EditResponse>;
110
111 async fn edit_artist_for_album(
113 &self,
114 album_name: &str,
115 old_artist_name: &str,
116 new_artist_name: &str,
117 ) -> Result<EditResponse>;
118
119 async fn get_artist_tracks_page(&self, artist: &str, page: u32) -> Result<TrackPage>;
121
122 async fn get_artist_albums_page(&self, artist: &str, page: u32) -> Result<AlbumPage>;
124
125 fn get_session(&self) -> LastFmEditSession;
127
128 fn restore_session(&self, session: LastFmEditSession);
130
131 fn artist_tracks(&self, artist: &str) -> crate::ArtistTracksIterator;
133
134 fn artist_albums(&self, artist: &str) -> crate::ArtistAlbumsIterator;
136
137 fn recent_tracks(&self) -> crate::RecentTracksIterator;
139
140 fn recent_tracks_from_page(&self, starting_page: u32) -> crate::RecentTracksIterator;
142}
143
144#[derive(Clone)]
170pub struct LastFmEditClientImpl {
171 client: Arc<dyn HttpClient + Send + Sync>,
172 session: Arc<Mutex<LastFmEditSession>>,
173 rate_limit_patterns: Vec<String>,
174 debug_save_responses: bool,
175 parser: LastFmParser,
176}
177
178impl LastFmEditClientImpl {
179 pub fn new(client: Box<dyn HttpClient + Send + Sync>) -> Self {
202 Self::with_base_url(client, "https://www.last.fm".to_string())
203 }
204
205 pub fn with_base_url(client: Box<dyn HttpClient + Send + Sync>, base_url: String) -> Self {
217 Self::with_rate_limit_patterns(
218 client,
219 base_url,
220 vec![
221 "you've tried to log in too many times".to_string(),
222 "you're requesting too many pages".to_string(),
223 "slow down".to_string(),
224 "too fast".to_string(),
225 "rate limit".to_string(),
226 "throttled".to_string(),
227 "temporarily blocked".to_string(),
228 "temporarily restricted".to_string(),
229 "captcha".to_string(),
230 "verify you're human".to_string(),
231 "prove you're not a robot".to_string(),
232 "security check".to_string(),
233 "service temporarily unavailable".to_string(),
234 "quota exceeded".to_string(),
235 "limit exceeded".to_string(),
236 "daily limit".to_string(),
237 ],
238 )
239 }
240
241 pub fn with_rate_limit_patterns(
249 client: Box<dyn HttpClient + Send + Sync>,
250 base_url: String,
251 rate_limit_patterns: Vec<String>,
252 ) -> Self {
253 Self {
254 client: Arc::from(client),
255 session: Arc::new(Mutex::new(LastFmEditSession::new(
256 String::new(),
257 Vec::new(),
258 None,
259 base_url,
260 ))),
261 rate_limit_patterns,
262 debug_save_responses: std::env::var("LASTFM_DEBUG_SAVE_RESPONSES").is_ok(),
263 parser: LastFmParser::new(),
264 }
265 }
266
267 pub async fn login_with_credentials(
298 client: Box<dyn HttpClient + Send + Sync>,
299 username: &str,
300 password: &str,
301 ) -> Result<Self> {
302 let new_client = Self::new(client);
303 new_client.login(username, password).await?;
304 Ok(new_client)
305 }
306
307 pub fn from_session(
339 client: Box<dyn HttpClient + Send + Sync>,
340 session: LastFmEditSession,
341 ) -> Self {
342 Self {
343 client: Arc::from(client),
344 session: Arc::new(Mutex::new(session)),
345 rate_limit_patterns: vec![
346 "you've tried to log in too many times".to_string(),
347 "you're requesting too many pages".to_string(),
348 "slow down".to_string(),
349 "too fast".to_string(),
350 "rate limit".to_string(),
351 "throttled".to_string(),
352 "temporarily blocked".to_string(),
353 "temporarily restricted".to_string(),
354 "captcha".to_string(),
355 "verify you're human".to_string(),
356 "prove you're not a robot".to_string(),
357 "security check".to_string(),
358 "service temporarily unavailable".to_string(),
359 "quota exceeded".to_string(),
360 "limit exceeded".to_string(),
361 "daily limit".to_string(),
362 ],
363 debug_save_responses: std::env::var("LASTFM_DEBUG_SAVE_RESPONSES").is_ok(),
364 parser: LastFmParser::new(),
365 }
366 }
367
368 pub fn get_session(&self) -> LastFmEditSession {
395 self.session.lock().unwrap().clone()
396 }
397
398 pub fn restore_session(&self, session: LastFmEditSession) {
424 *self.session.lock().unwrap() = session;
425 }
426
427 pub async fn login(&self, username: &str, password: &str) -> Result<()> {
456 let login_url = {
458 let session = self.session.lock().unwrap();
459 format!("{}/login", session.base_url)
460 };
461 let mut response = self.get(&login_url).await?;
462
463 self.extract_cookies(&response);
465
466 let html = response
467 .body_string()
468 .await
469 .map_err(|e| LastFmError::Http(e.to_string()))?;
470
471 let (csrf_token, next_field) = self.extract_login_form_data(&html)?;
473
474 let mut form_data = HashMap::new();
476 form_data.insert("csrfmiddlewaretoken", csrf_token.as_str());
477 form_data.insert("username_or_email", username);
478 form_data.insert("password", password);
479
480 if let Some(ref next_value) = next_field {
482 form_data.insert("next", next_value);
483 }
484
485 let mut request = Request::new(Method::Post, login_url.parse::<Url>().unwrap());
486 let _ = request.insert_header("Referer", &login_url);
487 {
488 let session = self.session.lock().unwrap();
489 let _ = request.insert_header("Origin", &session.base_url);
490 }
491 let _ = request.insert_header("Content-Type", "application/x-www-form-urlencoded");
492 let _ = request.insert_header(
493 "User-Agent",
494 "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36"
495 );
496 let _ = request.insert_header(
497 "Accept",
498 "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"
499 );
500 let _ = request.insert_header("Accept-Language", "en-US,en;q=0.9");
501 let _ = request.insert_header("Accept-Encoding", "gzip, deflate, br");
502 let _ = request.insert_header("DNT", "1");
503 let _ = request.insert_header("Connection", "keep-alive");
504 let _ = request.insert_header("Upgrade-Insecure-Requests", "1");
505 let _ = request.insert_header(
506 "sec-ch-ua",
507 "\"Not)A;Brand\";v=\"8\", \"Chromium\";v=\"138\", \"Google Chrome\";v=\"138\"",
508 );
509 let _ = request.insert_header("sec-ch-ua-mobile", "?0");
510 let _ = request.insert_header("sec-ch-ua-platform", "\"Linux\"");
511 let _ = request.insert_header("Sec-Fetch-Dest", "document");
512 let _ = request.insert_header("Sec-Fetch-Mode", "navigate");
513 let _ = request.insert_header("Sec-Fetch-Site", "same-origin");
514 let _ = request.insert_header("Sec-Fetch-User", "?1");
515
516 {
518 let session = self.session.lock().unwrap();
519 if !session.cookies.is_empty() {
520 let cookie_header = session.cookies.join("; ");
521 let _ = request.insert_header("Cookie", &cookie_header);
522 }
523 }
524
525 let form_string: String = form_data
527 .iter()
528 .map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v)))
529 .collect::<Vec<_>>()
530 .join("&");
531
532 request.set_body(form_string);
533
534 let mut response = self
535 .client
536 .send(request)
537 .await
538 .map_err(|e| LastFmError::Http(e.to_string()))?;
539
540 self.extract_cookies(&response);
542
543 log::debug!("Login response status: {}", response.status());
544
545 if response.status() == 403 {
547 let response_html = response
549 .body_string()
550 .await
551 .map_err(|e| LastFmError::Http(e.to_string()))?;
552
553 if self.is_rate_limit_response(&response_html) {
555 log::debug!("403 response appears to be rate limiting");
556 return Err(LastFmError::RateLimit { retry_after: 60 });
557 }
558 log::debug!("403 response appears to be authentication failure");
559
560 let login_error = self.parse_login_error(&response_html);
562 return Err(LastFmError::Auth(login_error));
563 }
564
565 let has_real_session = {
567 let session = self.session.lock().unwrap();
568 session
569 .cookies
570 .iter()
571 .any(|cookie| cookie.starts_with("sessionid=.") && cookie.len() > 50)
572 };
573
574 if has_real_session && (response.status() == 302 || response.status() == 200) {
575 {
577 let mut session = self.session.lock().unwrap();
578 session.username = username.to_string();
579 session.csrf_token = Some(csrf_token);
580 }
581 log::debug!("Login successful - authenticated session established");
582 return Ok(());
583 }
584
585 let response_html = response
587 .body_string()
588 .await
589 .map_err(|e| LastFmError::Http(e.to_string()))?;
590
591 let has_login_form = self.check_for_login_form(&response_html);
593
594 if !has_login_form && response.status() == 200 {
595 {
596 let mut session = self.session.lock().unwrap();
597 session.username = username.to_string();
598 session.csrf_token = Some(csrf_token);
599 }
600 Ok(())
601 } else {
602 let error_msg = self.parse_login_error(&response_html);
604 Err(LastFmError::Auth(error_msg))
605 }
606 }
607
608 pub fn username(&self) -> String {
612 self.session.lock().unwrap().username.clone()
613 }
614
615 pub fn is_logged_in(&self) -> bool {
619 self.session.lock().unwrap().is_valid()
620 }
621
622 pub async fn get_recent_scrobbles(&self, page: u32) -> Result<Vec<Track>> {
625 let url = {
626 let session = self.session.lock().unwrap();
627 format!(
628 "{}/user/{}/library?page={}",
629 session.base_url, session.username, page
630 )
631 };
632
633 log::debug!("Fetching recent scrobbles page {page}");
634 let mut response = self.get(&url).await?;
635 let content = response
636 .body_string()
637 .await
638 .map_err(|e| LastFmError::Http(e.to_string()))?;
639
640 log::debug!(
641 "Recent scrobbles response: {} status, {} chars",
642 response.status(),
643 content.len()
644 );
645
646 let document = Html::parse_document(&content);
647 self.parser.parse_recent_scrobbles(&document)
648 }
649
650 pub async fn find_recent_scrobble_for_track(
653 &self,
654 track_name: &str,
655 artist_name: &str,
656 max_pages: u32,
657 ) -> Result<Option<Track>> {
658 log::debug!("Searching for recent scrobble: '{track_name}' by '{artist_name}'");
659
660 for page in 1..=max_pages {
661 let scrobbles = self.get_recent_scrobbles(page).await?;
662
663 for scrobble in scrobbles {
664 if scrobble.name == track_name && scrobble.artist == artist_name {
665 log::debug!(
666 "Found recent scrobble: '{}' with timestamp {:?}",
667 scrobble.name,
668 scrobble.timestamp
669 );
670 return Ok(Some(scrobble));
671 }
672 }
673
674 }
676
677 log::debug!(
678 "No recent scrobble found for '{track_name}' by '{artist_name}' in {max_pages} pages"
679 );
680 Ok(None)
681 }
682
683 pub async fn edit_scrobble(&self, edit: &ScrobbleEdit) -> Result<EditResponse> {
684 let enriched_edit = self.enrich_edit_metadata(edit).await.unwrap_or_else(|e| {
686 log::debug!("Could not enrich metadata ({e}), using original edit");
687 edit.clone()
688 });
689
690 self.edit_scrobble_with_retry(&enriched_edit, 3).await
691 }
692
693 async fn enrich_edit_metadata(&self, edit: &ScrobbleEdit) -> Result<ScrobbleEdit> {
695 let needs_lookup = edit.track_name_original.is_none()
697 || edit.album_name_original.is_none()
698 || edit.artist_name_original.is_none()
699 || edit.album_artist_name_original.is_none();
700
701 if !needs_lookup {
702 return Ok(edit.clone());
704 }
705
706 log::debug!(
707 "Looking up missing original metadata for scrobble with timestamp {}",
708 edit.timestamp
709 );
710
711 let found_scrobble = self.find_scrobble_by_timestamp(edit.timestamp).await?;
713
714 Ok(ScrobbleEdit {
715 track_name_original: edit
716 .track_name_original
717 .clone()
718 .or_else(|| Some(found_scrobble.name.clone())),
719 album_name_original: edit
720 .album_name_original
721 .clone()
722 .or_else(|| found_scrobble.album.clone()),
723 artist_name_original: edit
724 .artist_name_original
725 .clone()
726 .or_else(|| Some(found_scrobble.artist.clone())),
727 album_artist_name_original: edit
728 .album_artist_name_original
729 .clone()
730 .or_else(|| found_scrobble.album_artist.clone())
731 .or_else(|| Some(found_scrobble.artist.clone())), track_name: edit.track_name.clone(),
733 album_name: edit.album_name.clone(),
734 artist_name: edit.artist_name.clone(),
735 album_artist_name: edit.album_artist_name.clone(),
736 timestamp: edit.timestamp,
737 edit_all: edit.edit_all,
738 })
739 }
740
741 pub async fn find_scrobble_by_timestamp(&self, timestamp: u64) -> Result<Track> {
743 log::debug!("Searching for scrobble with timestamp {timestamp}");
744
745 for page in 1..=10 {
747 let scrobbles = self.get_recent_scrobbles(page).await?;
749
750 for scrobble in scrobbles {
751 if let Some(scrobble_timestamp) = scrobble.timestamp {
752 if scrobble_timestamp == timestamp {
753 log::debug!(
754 "Found scrobble: '{}' by '{}' with album: '{:?}', album_artist: '{:?}'",
755 scrobble.name,
756 scrobble.artist,
757 scrobble.album,
758 scrobble.album_artist
759 );
760 return Ok(scrobble);
761 }
762 }
763 }
764 }
765
766 Err(LastFmError::Parse(format!(
767 "Could not find scrobble with timestamp {timestamp}"
768 )))
769 }
770
771 pub async fn edit_scrobble_with_retry(
772 &self,
773 edit: &ScrobbleEdit,
774 max_retries: u32,
775 ) -> Result<EditResponse> {
776 let mut retries = 0;
777
778 loop {
779 match self.edit_scrobble_impl(edit).await {
780 Ok(result) => return Ok(result),
781 Err(LastFmError::RateLimit { retry_after }) => {
782 if retries >= max_retries {
783 log::warn!("Max retries ({max_retries}) exceeded for edit operation");
784 return Err(LastFmError::RateLimit { retry_after });
785 }
786
787 let delay = std::cmp::min(retry_after, 2_u64.pow(retries + 1) * 5);
788 log::info!(
789 "Edit rate limited. Waiting {} seconds before retry {} of {}",
790 delay,
791 retries + 1,
792 max_retries
793 );
794 retries += 1;
796 }
797 Err(other_error) => return Err(other_error),
798 }
799 }
800 }
801
802 async fn edit_scrobble_impl(&self, edit: &ScrobbleEdit) -> Result<EditResponse> {
803 if !self.is_logged_in() {
804 return Err(LastFmError::Auth(
805 "Must be logged in to edit scrobbles".to_string(),
806 ));
807 }
808
809 let edit_url = {
810 let session = self.session.lock().unwrap();
811 format!(
812 "{}/user/{}/library/edit?edited-variation=library-track-scrobble",
813 session.base_url, session.username
814 )
815 };
816
817 log::debug!("Getting fresh CSRF token for edit");
818
819 let form_html = self.get_edit_form_html(&edit_url).await?;
821
822 let form_document = Html::parse_document(&form_html);
824 let fresh_csrf_token = self.extract_csrf_token(&form_document)?;
825
826 log::debug!("Submitting edit with fresh token");
827
828 let mut form_data = HashMap::new();
829
830 form_data.insert("csrfmiddlewaretoken", fresh_csrf_token.as_str());
832
833 let track_name_original = edit.track_name_original.as_deref().unwrap_or("");
836 let artist_name_original = edit.artist_name_original.as_deref().unwrap_or("");
837 let album_name_original = edit.album_name_original.as_deref().unwrap_or("");
838 let album_artist_name_original = edit.album_artist_name_original.as_deref().unwrap_or("");
839
840 form_data.insert("track_name_original", track_name_original);
841 form_data.insert("track_name", &edit.track_name);
842 form_data.insert("artist_name_original", artist_name_original);
843 form_data.insert("artist_name", &edit.artist_name);
844 form_data.insert("album_name_original", album_name_original);
845 form_data.insert("album_name", &edit.album_name);
846 form_data.insert("album_artist_name_original", album_artist_name_original);
847 form_data.insert("album_artist_name", &edit.album_artist_name);
848
849 let timestamp_str = edit.timestamp.to_string();
851 form_data.insert("timestamp", ×tamp_str);
852
853 if edit.edit_all {
855 form_data.insert("edit_all", "1");
856 }
857 form_data.insert("submit", "edit-scrobble");
858 form_data.insert("ajax", "1");
859
860 log::debug!(
861 "Editing scrobble: '{}' -> '{}'",
862 edit.track_name_original.as_deref().unwrap_or("unknown"),
863 edit.track_name
864 );
865 {
866 let session = self.session.lock().unwrap();
867 log::trace!("Session cookies count: {}", session.cookies.len());
868 }
869
870 let mut request = Request::new(Method::Post, edit_url.parse::<Url>().unwrap());
871
872 let _ = request.insert_header("Accept", "*/*");
874 let _ = request.insert_header("Accept-Language", "en-US,en;q=0.9");
875 let _ = request.insert_header(
876 "Content-Type",
877 "application/x-www-form-urlencoded;charset=UTF-8",
878 );
879 let _ = request.insert_header("Priority", "u=1, i");
880 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");
881 let _ = request.insert_header("X-Requested-With", "XMLHttpRequest");
882 let _ = request.insert_header("Sec-Fetch-Dest", "empty");
883 let _ = request.insert_header("Sec-Fetch-Mode", "cors");
884 let _ = request.insert_header("Sec-Fetch-Site", "same-origin");
885 let _ = request.insert_header(
886 "sec-ch-ua",
887 "\"Not)A;Brand\";v=\"8\", \"Chromium\";v=\"138\", \"Google Chrome\";v=\"138\"",
888 );
889 let _ = request.insert_header("sec-ch-ua-mobile", "?0");
890 let _ = request.insert_header("sec-ch-ua-platform", "\"Linux\"");
891
892 {
894 let session = self.session.lock().unwrap();
895 if !session.cookies.is_empty() {
896 let cookie_header = session.cookies.join("; ");
897 let _ = request.insert_header("Cookie", &cookie_header);
898 }
899 }
900
901 {
903 let session = self.session.lock().unwrap();
904 let _ = request.insert_header(
905 "Referer",
906 format!("{}/user/{}/library", session.base_url, session.username),
907 );
908 }
909
910 let form_string: String = form_data
912 .iter()
913 .map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v)))
914 .collect::<Vec<_>>()
915 .join("&");
916
917 request.set_body(form_string);
918
919 let mut response = self
920 .client
921 .send(request)
922 .await
923 .map_err(|e| LastFmError::Http(e.to_string()))?;
924
925 log::debug!("Edit response status: {}", response.status());
926
927 let response_text = response
928 .body_string()
929 .await
930 .map_err(|e| LastFmError::Http(e.to_string()))?;
931
932 let document = Html::parse_document(&response_text);
934
935 let success_selector = Selector::parse(".alert-success").unwrap();
937 let error_selector = Selector::parse(".alert-danger, .alert-error, .error").unwrap();
938
939 let has_success_alert = document.select(&success_selector).next().is_some();
940 let has_error_alert = document.select(&error_selector).next().is_some();
941
942 let mut actual_track_name = None;
945 let mut actual_album_name = None;
946
947 let track_name_selector = Selector::parse("td.chartlist-name a").unwrap();
949 let album_name_selector = Selector::parse("td.chartlist-album a").unwrap();
950
951 if let Some(track_element) = document.select(&track_name_selector).next() {
952 actual_track_name = Some(track_element.text().collect::<String>().trim().to_string());
953 }
954
955 if let Some(album_element) = document.select(&album_name_selector).next() {
956 actual_album_name = Some(album_element.text().collect::<String>().trim().to_string());
957 }
958
959 if actual_track_name.is_none() || actual_album_name.is_none() {
961 let track_pattern = regex::Regex::new(r#"href="/music/[^"]+/_/([^"]+)""#).unwrap();
964 if let Some(captures) = track_pattern.captures(&response_text) {
965 if let Some(track_match) = captures.get(1) {
966 let raw_track = track_match.as_str();
967 let decoded_track = urlencoding::decode(raw_track)
969 .unwrap_or_else(|_| raw_track.into())
970 .replace("+", " ");
971 actual_track_name = Some(decoded_track);
972 }
973 }
974
975 let album_pattern =
978 regex::Regex::new(r#"href="/music/[^"]+/([^"/_]+)"[^>]*>[^<]*</a>"#).unwrap();
979 if let Some(captures) = album_pattern.captures(&response_text) {
980 if let Some(album_match) = captures.get(1) {
981 let raw_album = album_match.as_str();
982 let decoded_album = urlencoding::decode(raw_album)
984 .unwrap_or_else(|_| raw_album.into())
985 .replace("+", " ");
986 actual_album_name = Some(decoded_album);
987 }
988 }
989 }
990
991 log::debug!(
992 "Response analysis: success_alert={}, error_alert={}, track='{}', album='{}'",
993 has_success_alert,
994 has_error_alert,
995 actual_track_name.as_deref().unwrap_or("not found"),
996 actual_album_name.as_deref().unwrap_or("not found")
997 );
998
999 let final_success = response.status().is_success() && has_success_alert && !has_error_alert;
1001
1002 let message = if has_error_alert {
1004 if let Some(error_element) = document.select(&error_selector).next() {
1006 Some(format!(
1007 "Edit failed: {}",
1008 error_element.text().collect::<String>().trim()
1009 ))
1010 } else {
1011 Some("Edit failed with unknown error".to_string())
1012 }
1013 } else if final_success {
1014 Some(format!(
1015 "Edit successful - Track: '{}', Album: '{}'",
1016 actual_track_name.as_deref().unwrap_or("unknown"),
1017 actual_album_name.as_deref().unwrap_or("unknown")
1018 ))
1019 } else {
1020 Some(format!("Edit failed with status: {}", response.status()))
1021 };
1022
1023 Ok(EditResponse {
1024 success: final_success,
1025 message,
1026 })
1027 }
1028
1029 async fn get_edit_form_html(&self, edit_url: &str) -> Result<String> {
1032 let mut form_response = self.get(edit_url).await?;
1033 let form_html = form_response
1034 .body_string()
1035 .await
1036 .map_err(|e| LastFmError::Http(e.to_string()))?;
1037
1038 log::debug!("Edit form response status: {}", form_response.status());
1039 Ok(form_html)
1040 }
1041
1042 pub async fn load_edit_form_values(
1045 &self,
1046 track_name: &str,
1047 artist_name: &str,
1048 ) -> Result<crate::ScrobbleEdit> {
1049 log::debug!("Loading edit form values for '{track_name}' by '{artist_name}'");
1050
1051 let track_url = {
1055 let session = self.session.lock().unwrap();
1056 format!(
1057 "{}/user/{}/library/music/+noredirect/{}/_/{}",
1058 session.base_url,
1059 session.username,
1060 urlencoding::encode(artist_name),
1061 urlencoding::encode(track_name)
1062 )
1063 };
1064
1065 log::debug!("Fetching track page: {track_url}");
1066
1067 let mut response = self.get(&track_url).await?;
1068 let html = response
1069 .body_string()
1070 .await
1071 .map_err(|e| crate::LastFmError::Http(e.to_string()))?;
1072
1073 let document = Html::parse_document(&html);
1074
1075 self.extract_scrobble_data_from_track_page(&document, track_name, artist_name)
1077 }
1078
1079 fn extract_scrobble_data_from_track_page(
1082 &self,
1083 document: &Html,
1084 expected_track: &str,
1085 expected_artist: &str,
1086 ) -> Result<crate::ScrobbleEdit> {
1087 let table_selector =
1089 Selector::parse("table.chartlist:not(.chartlist__placeholder)").unwrap();
1090 let table = document.select(&table_selector).next().ok_or_else(|| {
1091 crate::LastFmError::Parse("No chartlist table found on track page".to_string())
1092 })?;
1093
1094 let row_selector = Selector::parse("tr").unwrap();
1096 for row in table.select(&row_selector) {
1097 let count_bar_link_selector = Selector::parse(".chartlist-count-bar-link").unwrap();
1099 if row.select(&count_bar_link_selector).next().is_some() {
1100 log::debug!("Found count bar link, skipping aggregated row");
1101 continue;
1102 }
1103
1104 let form_selector = Selector::parse("form[data-edit-scrobble]").unwrap();
1106 if let Some(form) = row.select(&form_selector).next() {
1107 let extract_form_value = |name: &str| -> Option<String> {
1109 let selector = Selector::parse(&format!("input[name='{name}']")).unwrap();
1110 form.select(&selector)
1111 .next()
1112 .and_then(|input| input.value().attr("value"))
1113 .map(|s| s.to_string())
1114 };
1115
1116 let form_track = extract_form_value("track_name").unwrap_or_default();
1118 let form_artist = extract_form_value("artist_name").unwrap_or_default();
1119 let form_album = extract_form_value("album_name").unwrap_or_default();
1120 let form_album_artist =
1121 extract_form_value("album_artist_name").unwrap_or_else(|| form_artist.clone());
1122 let form_timestamp = extract_form_value("timestamp").unwrap_or_default();
1123
1124 log::debug!(
1125 "Found scrobble form - Track: '{form_track}', Artist: '{form_artist}', Album: '{form_album}', Timestamp: {form_timestamp}"
1126 );
1127
1128 if form_track == expected_track && form_artist == expected_artist {
1130 let timestamp = form_timestamp.parse::<u64>().map_err(|_| {
1131 crate::LastFmError::Parse("Invalid timestamp in form".to_string())
1132 })?;
1133
1134 log::debug!(
1135 "✅ Found matching scrobble form for '{expected_track}' by '{expected_artist}'"
1136 );
1137
1138 return Ok(crate::ScrobbleEdit::new(
1140 Some(form_track.clone()),
1141 Some(form_album.clone()),
1142 Some(form_artist.clone()),
1143 Some(form_album_artist.clone()),
1144 form_track,
1145 form_album,
1146 form_artist,
1147 form_album_artist,
1148 timestamp,
1149 true,
1150 ));
1151 }
1152 }
1153 }
1154
1155 Err(crate::LastFmError::Parse(format!(
1156 "No scrobble form found for track '{expected_track}' by '{expected_artist}'"
1157 )))
1158 }
1159
1160 pub async fn get_album_tracks(
1163 &self,
1164 album_name: &str,
1165 artist_name: &str,
1166 ) -> Result<Vec<Track>> {
1167 log::debug!("Getting tracks from album '{album_name}' by '{artist_name}'");
1168
1169 let album_url = {
1171 let session = self.session.lock().unwrap();
1172 format!(
1173 "{}/user/{}/library/music/{}/{}",
1174 session.base_url,
1175 session.username,
1176 urlencoding::encode(artist_name),
1177 urlencoding::encode(album_name)
1178 )
1179 };
1180
1181 log::debug!("Fetching album page: {album_url}");
1182
1183 let mut response = self.get(&album_url).await?;
1184 let html = response
1185 .body_string()
1186 .await
1187 .map_err(|e| LastFmError::Http(e.to_string()))?;
1188
1189 let document = Html::parse_document(&html);
1190
1191 let tracks =
1193 self.parser
1194 .extract_tracks_from_document(&document, artist_name, Some(album_name))?;
1195
1196 log::debug!(
1197 "Successfully parsed {} tracks from album page",
1198 tracks.len()
1199 );
1200 Ok(tracks)
1201 }
1202
1203 pub async fn edit_album(
1206 &self,
1207 old_album_name: &str,
1208 new_album_name: &str,
1209 artist_name: &str,
1210 ) -> Result<EditResponse> {
1211 log::debug!("Editing album '{old_album_name}' -> '{new_album_name}' by '{artist_name}'");
1212
1213 let tracks = self.get_album_tracks(old_album_name, artist_name).await?;
1215
1216 if tracks.is_empty() {
1217 return Ok(EditResponse {
1218 success: false,
1219 message: Some(format!(
1220 "No tracks found for album '{old_album_name}' by '{artist_name}'. Make sure the album name matches exactly."
1221 )),
1222 });
1223 }
1224
1225 log::info!(
1226 "Found {} tracks in album '{}'",
1227 tracks.len(),
1228 old_album_name
1229 );
1230
1231 let mut successful_edits = 0;
1232 let mut failed_edits = 0;
1233 let mut error_messages = Vec::new();
1234 let mut skipped_tracks = 0;
1235
1236 for (index, track) in tracks.iter().enumerate() {
1238 log::debug!(
1239 "Processing track {}/{}: '{}'",
1240 index + 1,
1241 tracks.len(),
1242 track.name
1243 );
1244
1245 match self.load_edit_form_values(&track.name, artist_name).await {
1246 Ok(mut edit_data) => {
1247 edit_data.album_name = new_album_name.to_string();
1249
1250 match self.edit_scrobble(&edit_data).await {
1252 Ok(response) => {
1253 if response.success {
1254 successful_edits += 1;
1255 log::info!("✅ Successfully edited track '{}'", track.name);
1256 } else {
1257 failed_edits += 1;
1258 let error_msg = format!(
1259 "Failed to edit track '{}': {}",
1260 track.name,
1261 response
1262 .message
1263 .unwrap_or_else(|| "Unknown error".to_string())
1264 );
1265 error_messages.push(error_msg);
1266 log::debug!("❌ {}", error_messages.last().unwrap());
1267 }
1268 }
1269 Err(e) => {
1270 failed_edits += 1;
1271 let error_msg = format!("Error editing track '{}': {}", track.name, e);
1272 error_messages.push(error_msg);
1273 log::info!("❌ {}", error_messages.last().unwrap());
1274 }
1275 }
1276 }
1277 Err(e) => {
1278 skipped_tracks += 1;
1279 log::debug!("Could not load edit form for track '{}': {e}", track.name);
1280 }
1282 }
1283
1284 }
1286
1287 let total_processed = successful_edits + failed_edits;
1288 let success = successful_edits > 0 && failed_edits == 0;
1289
1290 let message = if success {
1291 Some(format!(
1292 "Successfully renamed album '{old_album_name}' to '{new_album_name}' for all {successful_edits} editable tracks ({skipped_tracks} tracks were not in recent scrobbles)"
1293 ))
1294 } else if successful_edits > 0 {
1295 Some(format!(
1296 "Partially successful: {} of {} editable tracks renamed ({} skipped, {} failed). Errors: {}",
1297 successful_edits,
1298 total_processed,
1299 skipped_tracks,
1300 failed_edits,
1301 error_messages.join("; ")
1302 ))
1303 } else if total_processed == 0 {
1304 Some(format!(
1305 "No editable tracks found for album '{}' by '{}'. All {} tracks were skipped because they're not in recent scrobbles.",
1306 old_album_name, artist_name, tracks.len()
1307 ))
1308 } else {
1309 Some(format!(
1310 "Failed to rename any tracks. Errors: {}",
1311 error_messages.join("; ")
1312 ))
1313 };
1314
1315 Ok(EditResponse { success, message })
1316 }
1317
1318 pub async fn edit_artist(
1321 &self,
1322 old_artist_name: &str,
1323 new_artist_name: &str,
1324 ) -> Result<EditResponse> {
1325 log::debug!("Editing artist '{old_artist_name}' -> '{new_artist_name}'");
1326
1327 let mut tracks = Vec::new();
1329 let mut page = 1;
1330 let max_pages = 10; loop {
1333 if page > max_pages || tracks.len() >= 200 {
1334 break;
1335 }
1336
1337 match self.get_artist_tracks_page(old_artist_name, page).await {
1338 Ok(track_page) => {
1339 if track_page.tracks.is_empty() {
1340 break;
1341 }
1342 tracks.extend(track_page.tracks);
1343 if !track_page.has_next_page {
1344 break;
1345 }
1346 page += 1;
1347 }
1348 Err(e) => {
1349 log::warn!("Error fetching artist tracks page {page}: {e}");
1350 break;
1351 }
1352 }
1353 }
1354
1355 if tracks.is_empty() {
1356 return Ok(EditResponse {
1357 success: false,
1358 message: Some(format!(
1359 "No tracks found for artist '{old_artist_name}'. Make sure the artist name matches exactly."
1360 )),
1361 });
1362 }
1363
1364 log::info!(
1365 "Found {} tracks for artist '{}'",
1366 tracks.len(),
1367 old_artist_name
1368 );
1369
1370 let mut successful_edits = 0;
1371 let mut failed_edits = 0;
1372 let mut error_messages = Vec::new();
1373 let mut skipped_tracks = 0;
1374
1375 for (index, track) in tracks.iter().enumerate() {
1377 log::debug!(
1378 "Processing track {}/{}: '{}'",
1379 index + 1,
1380 tracks.len(),
1381 track.name
1382 );
1383
1384 match self
1385 .load_edit_form_values(&track.name, old_artist_name)
1386 .await
1387 {
1388 Ok(mut edit_data) => {
1389 edit_data.artist_name = new_artist_name.to_string();
1391 edit_data.album_artist_name = new_artist_name.to_string();
1392
1393 match self.edit_scrobble(&edit_data).await {
1395 Ok(response) => {
1396 if response.success {
1397 successful_edits += 1;
1398 log::info!("✅ Successfully edited track '{}'", track.name);
1399 } else {
1400 failed_edits += 1;
1401 let error_msg = format!(
1402 "Failed to edit track '{}': {}",
1403 track.name,
1404 response
1405 .message
1406 .unwrap_or_else(|| "Unknown error".to_string())
1407 );
1408 error_messages.push(error_msg);
1409 log::debug!("❌ {}", error_messages.last().unwrap());
1410 }
1411 }
1412 Err(e) => {
1413 failed_edits += 1;
1414 let error_msg = format!("Error editing track '{}': {}", track.name, e);
1415 error_messages.push(error_msg);
1416 log::info!("❌ {}", error_messages.last().unwrap());
1417 }
1418 }
1419 }
1420 Err(e) => {
1421 skipped_tracks += 1;
1422 log::debug!("Could not load edit form for track '{}': {e}", track.name);
1423 }
1425 }
1426
1427 }
1429
1430 let total_processed = successful_edits + failed_edits;
1431 let success = successful_edits > 0 && failed_edits == 0;
1432
1433 let message = if success {
1434 Some(format!(
1435 "Successfully renamed artist '{old_artist_name}' to '{new_artist_name}' for all {successful_edits} editable tracks ({skipped_tracks} tracks were not in recent scrobbles)"
1436 ))
1437 } else if successful_edits > 0 {
1438 Some(format!(
1439 "Partially successful: {} of {} editable tracks renamed ({} skipped, {} failed). Errors: {}",
1440 successful_edits,
1441 total_processed,
1442 skipped_tracks,
1443 failed_edits,
1444 error_messages.join("; ")
1445 ))
1446 } else if total_processed == 0 {
1447 Some(format!(
1448 "No editable tracks found for artist '{}'. All {} tracks were skipped because they're not in recent scrobbles.",
1449 old_artist_name, tracks.len()
1450 ))
1451 } else {
1452 Some(format!(
1453 "Failed to rename any tracks. Errors: {}",
1454 error_messages.join("; ")
1455 ))
1456 };
1457
1458 Ok(EditResponse { success, message })
1459 }
1460
1461 pub async fn edit_artist_for_track(
1464 &self,
1465 track_name: &str,
1466 old_artist_name: &str,
1467 new_artist_name: &str,
1468 ) -> Result<EditResponse> {
1469 log::debug!("Editing artist for track '{track_name}' from '{old_artist_name}' -> '{new_artist_name}'");
1470
1471 match self.load_edit_form_values(track_name, old_artist_name).await {
1472 Ok(mut edit_data) => {
1473 edit_data.artist_name = new_artist_name.to_string();
1475 edit_data.album_artist_name = new_artist_name.to_string();
1476
1477 log::info!("Updating artist for track '{track_name}' from '{old_artist_name}' to '{new_artist_name}'");
1478
1479 match self.edit_scrobble(&edit_data).await {
1481 Ok(response) => {
1482 if response.success {
1483 Ok(EditResponse {
1484 success: true,
1485 message: Some(format!(
1486 "Successfully renamed artist for track '{track_name}' from '{old_artist_name}' to '{new_artist_name}'"
1487 )),
1488 })
1489 } else {
1490 Ok(EditResponse {
1491 success: false,
1492 message: Some(format!(
1493 "Failed to rename artist for track '{track_name}': {}",
1494 response.message.unwrap_or_else(|| "Unknown error".to_string())
1495 )),
1496 })
1497 }
1498 }
1499 Err(e) => Ok(EditResponse {
1500 success: false,
1501 message: Some(format!("Error editing track '{track_name}': {e}")),
1502 }),
1503 }
1504 }
1505 Err(e) => Ok(EditResponse {
1506 success: false,
1507 message: Some(format!(
1508 "Could not load edit form for track '{track_name}' by '{old_artist_name}': {e}. The track may not be in your recent scrobbles."
1509 )),
1510 }),
1511 }
1512 }
1513
1514 pub async fn edit_artist_for_album(
1517 &self,
1518 album_name: &str,
1519 old_artist_name: &str,
1520 new_artist_name: &str,
1521 ) -> Result<EditResponse> {
1522 log::debug!("Editing artist for album '{album_name}' from '{old_artist_name}' -> '{new_artist_name}'");
1523
1524 let tracks = self.get_album_tracks(album_name, old_artist_name).await?;
1526
1527 if tracks.is_empty() {
1528 return Ok(EditResponse {
1529 success: false,
1530 message: Some(format!(
1531 "No tracks found for album '{album_name}' by '{old_artist_name}'. Make sure the album name matches exactly."
1532 )),
1533 });
1534 }
1535
1536 log::info!(
1537 "Found {} tracks in album '{}' by '{}'",
1538 tracks.len(),
1539 album_name,
1540 old_artist_name
1541 );
1542
1543 let mut successful_edits = 0;
1544 let mut failed_edits = 0;
1545 let mut error_messages = Vec::new();
1546 let mut skipped_tracks = 0;
1547
1548 for (index, track) in tracks.iter().enumerate() {
1550 log::debug!(
1551 "Processing track {}/{}: '{}'",
1552 index + 1,
1553 tracks.len(),
1554 track.name
1555 );
1556
1557 match self
1558 .load_edit_form_values(&track.name, old_artist_name)
1559 .await
1560 {
1561 Ok(mut edit_data) => {
1562 edit_data.artist_name = new_artist_name.to_string();
1564 edit_data.album_artist_name = new_artist_name.to_string();
1565
1566 match self.edit_scrobble(&edit_data).await {
1568 Ok(response) => {
1569 if response.success {
1570 successful_edits += 1;
1571 log::info!("✅ Successfully edited track '{}'", track.name);
1572 } else {
1573 failed_edits += 1;
1574 let error_msg = format!(
1575 "Failed to edit track '{}': {}",
1576 track.name,
1577 response
1578 .message
1579 .unwrap_or_else(|| "Unknown error".to_string())
1580 );
1581 error_messages.push(error_msg);
1582 log::debug!("❌ {}", error_messages.last().unwrap());
1583 }
1584 }
1585 Err(e) => {
1586 failed_edits += 1;
1587 let error_msg = format!("Error editing track '{}': {}", track.name, e);
1588 error_messages.push(error_msg);
1589 log::info!("❌ {}", error_messages.last().unwrap());
1590 }
1591 }
1592 }
1593 Err(e) => {
1594 skipped_tracks += 1;
1595 log::debug!("Could not load edit form for track '{}': {e}", track.name);
1596 }
1598 }
1599
1600 }
1602
1603 let total_processed = successful_edits + failed_edits;
1604 let success = successful_edits > 0 && failed_edits == 0;
1605
1606 let message = if success {
1607 Some(format!(
1608 "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)"
1609 ))
1610 } else if successful_edits > 0 {
1611 Some(format!(
1612 "Partially successful: {} of {} editable tracks renamed ({} skipped, {} failed). Errors: {}",
1613 successful_edits,
1614 total_processed,
1615 skipped_tracks,
1616 failed_edits,
1617 error_messages.join("; ")
1618 ))
1619 } else if total_processed == 0 {
1620 Some(format!(
1621 "No editable tracks found for album '{album_name}' by '{old_artist_name}'. All {} tracks were skipped because they're not in recent scrobbles.",
1622 tracks.len()
1623 ))
1624 } else {
1625 Some(format!(
1626 "Failed to rename any tracks. Errors: {}",
1627 error_messages.join("; ")
1628 ))
1629 };
1630
1631 Ok(EditResponse { success, message })
1632 }
1633
1634 pub async fn get_artist_tracks_page(&self, artist: &str, page: u32) -> Result<TrackPage> {
1635 let url = {
1637 let session = self.session.lock().unwrap();
1638 format!(
1639 "{}/user/{}/library/music/{}/+tracks?page={}&ajax=true",
1640 session.base_url,
1641 session.username,
1642 artist.replace(" ", "+"),
1643 page
1644 )
1645 };
1646
1647 log::debug!("Fetching tracks page {page} for artist: {artist}");
1648 let mut response = self.get(&url).await?;
1649 let content = response
1650 .body_string()
1651 .await
1652 .map_err(|e| LastFmError::Http(e.to_string()))?;
1653
1654 log::debug!(
1655 "AJAX response: {} status, {} chars",
1656 response.status(),
1657 content.len()
1658 );
1659
1660 if content.trim_start().starts_with("{") || content.trim_start().starts_with("[") {
1662 log::debug!("Parsing JSON response from AJAX endpoint");
1663 self.parse_json_tracks_page(&content, page, artist)
1664 } else {
1665 log::debug!("Parsing HTML response from AJAX endpoint");
1666 let document = Html::parse_document(&content);
1667 self.parser.parse_tracks_page(&document, page, artist, None)
1668 }
1669 }
1670
1671 fn parse_json_tracks_page(
1673 &self,
1674 _json_content: &str,
1675 page_number: u32,
1676 _artist: &str,
1677 ) -> Result<TrackPage> {
1678 log::debug!("JSON parsing not implemented, returning empty page");
1680 Ok(TrackPage {
1681 tracks: Vec::new(),
1682 page_number,
1683 has_next_page: false,
1684 total_pages: Some(1),
1685 })
1686 }
1687
1688 pub fn extract_tracks_from_document(
1690 &self,
1691 document: &Html,
1692 artist: &str,
1693 album: Option<&str>,
1694 ) -> Result<Vec<Track>> {
1695 self.parser
1696 .extract_tracks_from_document(document, artist, album)
1697 }
1698
1699 pub fn parse_tracks_page(
1701 &self,
1702 document: &Html,
1703 page_number: u32,
1704 artist: &str,
1705 album: Option<&str>,
1706 ) -> Result<TrackPage> {
1707 self.parser
1708 .parse_tracks_page(document, page_number, artist, album)
1709 }
1710
1711 pub fn parse_recent_scrobbles(&self, document: &Html) -> Result<Vec<Track>> {
1713 self.parser.parse_recent_scrobbles(document)
1714 }
1715
1716 fn extract_csrf_token(&self, document: &Html) -> Result<String> {
1717 let csrf_selector = Selector::parse("input[name=\"csrfmiddlewaretoken\"]").unwrap();
1718
1719 document
1720 .select(&csrf_selector)
1721 .next()
1722 .and_then(|input| input.value().attr("value"))
1723 .map(|token| token.to_string())
1724 .ok_or(LastFmError::CsrfNotFound)
1725 }
1726
1727 fn extract_login_form_data(&self, html: &str) -> Result<(String, Option<String>)> {
1729 let document = Html::parse_document(html);
1730
1731 let csrf_token = self.extract_csrf_token(&document)?;
1732
1733 let next_selector = Selector::parse("input[name=\"next\"]").unwrap();
1735 let next_field = document
1736 .select(&next_selector)
1737 .next()
1738 .and_then(|input| input.value().attr("value"))
1739 .map(|s| s.to_string());
1740
1741 Ok((csrf_token, next_field))
1742 }
1743
1744 fn parse_login_error(&self, html: &str) -> String {
1746 let document = Html::parse_document(html);
1747
1748 let error_selector = Selector::parse(".alert-danger, .form-error, .error-message").unwrap();
1749
1750 let mut error_messages = Vec::new();
1751 for error in document.select(&error_selector) {
1752 let error_text = error.text().collect::<String>().trim().to_string();
1753 if !error_text.is_empty() {
1754 error_messages.push(error_text);
1755 }
1756 }
1757
1758 if error_messages.is_empty() {
1759 "Login failed - please check your credentials".to_string()
1760 } else {
1761 format!("Login failed: {}", error_messages.join("; "))
1762 }
1763 }
1764
1765 fn check_for_login_form(&self, html: &str) -> bool {
1767 let document = Html::parse_document(html);
1768 let login_form_selector =
1769 Selector::parse("form[action*=\"login\"], input[name=\"username_or_email\"]").unwrap();
1770 document.select(&login_form_selector).next().is_some()
1771 }
1772
1773 pub async fn get(&self, url: &str) -> Result<Response> {
1775 self.get_with_retry(url, 3).await
1776 }
1777
1778 async fn get_with_retry(&self, url: &str, max_retries: u32) -> Result<Response> {
1780 let mut retries = 0;
1781
1782 loop {
1783 match self.get_with_redirects(url, 0).await {
1784 Ok(mut response) => {
1785 let body = self.extract_response_body(url, &mut response).await?;
1787
1788 if response.status().is_success() && self.is_rate_limit_response(&body) {
1790 log::debug!("Response body contains rate limit patterns");
1791 if retries < max_retries {
1792 let delay = 60 + (retries as u64 * 30); log::info!("Rate limit detected in response body, retrying in {delay}s (attempt {}/{max_retries})", retries + 1);
1794 retries += 1;
1796 continue;
1797 }
1798 return Err(crate::LastFmError::RateLimit { retry_after: 60 });
1799 }
1800
1801 let mut new_response = http_types::Response::new(response.status());
1803 for (name, values) in response.iter() {
1804 for value in values {
1805 let _ = new_response.insert_header(name.clone(), value.clone());
1806 }
1807 }
1808 new_response.set_body(body);
1809
1810 return Ok(new_response);
1811 }
1812 Err(crate::LastFmError::RateLimit { retry_after }) => {
1813 if retries < max_retries {
1814 let delay = retry_after + (retries as u64 * 30); log::info!(
1816 "Rate limit detected, retrying in {delay}s (attempt {}/{max_retries})",
1817 retries + 1
1818 );
1819 retries += 1;
1821 } else {
1822 return Err(crate::LastFmError::RateLimit { retry_after });
1823 }
1824 }
1825 Err(e) => return Err(e),
1826 }
1827 }
1828 }
1829
1830 async fn get_with_redirects(&self, url: &str, redirect_count: u32) -> Result<Response> {
1831 if redirect_count > 5 {
1832 return Err(LastFmError::Http("Too many redirects".to_string()));
1833 }
1834
1835 let mut request = Request::new(Method::Get, url.parse::<Url>().unwrap());
1836 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");
1837
1838 {
1840 let session = self.session.lock().unwrap();
1841 if !session.cookies.is_empty() {
1842 let cookie_header = session.cookies.join("; ");
1843 let _ = request.insert_header("Cookie", &cookie_header);
1844 } else if url.contains("page=") {
1845 log::debug!("No cookies available for paginated request!");
1846 }
1847 }
1848
1849 if url.contains("ajax=true") {
1851 let _ = request.insert_header("Accept", "*/*");
1853 let _ = request.insert_header("X-Requested-With", "XMLHttpRequest");
1854 } else {
1855 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");
1857 }
1858 let _ = request.insert_header("Accept-Language", "en-US,en;q=0.9");
1859 let _ = request.insert_header("Accept-Encoding", "gzip, deflate, br");
1860 let _ = request.insert_header("DNT", "1");
1861 let _ = request.insert_header("Connection", "keep-alive");
1862 let _ = request.insert_header("Upgrade-Insecure-Requests", "1");
1863
1864 if url.contains("page=") {
1866 let base_url = url.split('?').next().unwrap_or(url);
1867 let _ = request.insert_header("Referer", base_url);
1868 }
1869
1870 let response = self
1871 .client
1872 .send(request)
1873 .await
1874 .map_err(|e| LastFmError::Http(e.to_string()))?;
1875
1876 self.extract_cookies(&response);
1878
1879 if response.status() == 302 || response.status() == 301 {
1881 if let Some(location) = response.header("location") {
1882 if let Some(redirect_url) = location.get(0) {
1883 let redirect_url_str = redirect_url.as_str();
1884 if url.contains("page=") {
1885 log::debug!("Following redirect from {url} to {redirect_url_str}");
1886
1887 if redirect_url_str.contains("/login") {
1889 log::debug!("Redirect to login page - authentication failed for paginated request");
1890 return Err(LastFmError::Auth(
1891 "Session expired or invalid for paginated request".to_string(),
1892 ));
1893 }
1894 }
1895
1896 let full_redirect_url = if redirect_url_str.starts_with('/') {
1898 let base_url = self.session.lock().unwrap().base_url.clone();
1899 format!("{base_url}{redirect_url_str}")
1900 } else if redirect_url_str.starts_with("http") {
1901 redirect_url_str.to_string()
1902 } else {
1903 let base_url = url
1905 .rsplit('/')
1906 .skip(1)
1907 .collect::<Vec<_>>()
1908 .into_iter()
1909 .rev()
1910 .collect::<Vec<_>>()
1911 .join("/");
1912 format!("{base_url}/{redirect_url_str}")
1913 };
1914
1915 return Box::pin(
1917 self.get_with_redirects(&full_redirect_url, redirect_count + 1),
1918 )
1919 .await;
1920 }
1921 }
1922 }
1923
1924 if response.status() == 429 {
1926 let retry_after = response
1927 .header("retry-after")
1928 .and_then(|h| h.get(0))
1929 .and_then(|v| v.as_str().parse::<u64>().ok())
1930 .unwrap_or(60);
1931 return Err(LastFmError::RateLimit { retry_after });
1932 }
1933
1934 if response.status() == 403 {
1936 log::debug!("Got 403 response, checking if it's a rate limit");
1937 {
1939 let session = self.session.lock().unwrap();
1940 if !session.cookies.is_empty() {
1941 log::debug!("403 on authenticated request - likely rate limit");
1942 return Err(LastFmError::RateLimit { retry_after: 60 });
1943 }
1944 }
1945 }
1946
1947 Ok(response)
1948 }
1949
1950 fn is_rate_limit_response(&self, response_body: &str) -> bool {
1952 let body_lower = response_body.to_lowercase();
1953
1954 for pattern in &self.rate_limit_patterns {
1956 if body_lower.contains(&pattern.to_lowercase()) {
1957 return true;
1958 }
1959 }
1960
1961 false
1962 }
1963
1964 fn extract_cookies(&self, response: &Response) {
1965 if let Some(cookie_headers) = response.header("set-cookie") {
1967 let mut new_cookies = 0;
1968 for cookie_header in cookie_headers {
1969 let cookie_str = cookie_header.as_str();
1970 if let Some(cookie_value) = cookie_str.split(';').next() {
1972 let cookie_name = cookie_value.split('=').next().unwrap_or("");
1973
1974 {
1976 let mut session = self.session.lock().unwrap();
1977 session
1978 .cookies
1979 .retain(|existing| !existing.starts_with(&format!("{cookie_name}=")));
1980 session.cookies.push(cookie_value.to_string());
1981 }
1982 new_cookies += 1;
1983 }
1984 }
1985 if new_cookies > 0 {
1986 {
1987 let session = self.session.lock().unwrap();
1988 log::trace!(
1989 "Extracted {} new cookies, total: {}",
1990 new_cookies,
1991 session.cookies.len()
1992 );
1993 log::trace!("Updated cookies: {:?}", &session.cookies);
1994
1995 for cookie in &session.cookies {
1997 if cookie.starts_with("sessionid=") {
1998 log::trace!("Current sessionid: {}", &cookie[10..50.min(cookie.len())]);
1999 break;
2000 }
2001 }
2002 }
2003 }
2004 }
2005 }
2006
2007 async fn extract_response_body(&self, url: &str, response: &mut Response) -> Result<String> {
2009 let body = response
2010 .body_string()
2011 .await
2012 .map_err(|e| LastFmError::Http(e.to_string()))?;
2013
2014 if self.debug_save_responses {
2015 self.save_debug_response(url, response.status().into(), &body);
2016 }
2017
2018 Ok(body)
2019 }
2020
2021 fn save_debug_response(&self, url: &str, status_code: u16, body: &str) {
2023 if let Err(e) = self.try_save_debug_response(url, status_code, body) {
2024 log::warn!("Failed to save debug response: {e}");
2025 }
2026 }
2027
2028 fn try_save_debug_response(&self, url: &str, status_code: u16, body: &str) -> Result<()> {
2030 let debug_dir = Path::new("debug_responses");
2032 if !debug_dir.exists() {
2033 fs::create_dir_all(debug_dir)
2034 .map_err(|e| LastFmError::Http(format!("Failed to create debug directory: {e}")))?;
2035 }
2036
2037 let url_path = {
2039 let session = self.session.lock().unwrap();
2040 if url.starts_with(&session.base_url) {
2041 &url[session.base_url.len()..]
2042 } else {
2043 url
2044 }
2045 };
2046
2047 let now = chrono::Utc::now();
2049 let timestamp = now.format("%Y%m%d_%H%M%S_%3f");
2050 let safe_path = url_path.replace(['/', '?', '&', '=', '%', '+'], "_");
2051
2052 let filename = format!("{timestamp}_{safe_path}_status{status_code}.html");
2053 let file_path = debug_dir.join(filename);
2054
2055 fs::write(&file_path, body)
2057 .map_err(|e| LastFmError::Http(format!("Failed to write debug file: {e}")))?;
2058
2059 log::debug!(
2060 "Saved HTTP response to {file_path:?} (status: {status_code}, url: {url_path})"
2061 );
2062
2063 Ok(())
2064 }
2065
2066 pub async fn get_artist_albums_page(&self, artist: &str, page: u32) -> Result<AlbumPage> {
2067 let url = {
2069 let session = self.session.lock().unwrap();
2070 format!(
2071 "{}/user/{}/library/music/{}/+albums?page={}&ajax=true",
2072 session.base_url,
2073 session.username,
2074 artist.replace(" ", "+"),
2075 page
2076 )
2077 };
2078
2079 log::debug!("Fetching albums page {page} for artist: {artist}");
2080 let mut response = self.get(&url).await?;
2081 let content = response
2082 .body_string()
2083 .await
2084 .map_err(|e| LastFmError::Http(e.to_string()))?;
2085
2086 log::debug!(
2087 "AJAX response: {} status, {} chars",
2088 response.status(),
2089 content.len()
2090 );
2091
2092 if content.trim_start().starts_with("{") || content.trim_start().starts_with("[") {
2094 log::debug!("Parsing JSON response from AJAX endpoint");
2095 self.parse_json_albums_page(&content, page, artist)
2096 } else {
2097 log::debug!("Parsing HTML response from AJAX endpoint");
2098 let document = Html::parse_document(&content);
2099 self.parser.parse_albums_page(&document, page, artist)
2100 }
2101 }
2102
2103 fn parse_json_albums_page(
2104 &self,
2105 _json_content: &str,
2106 page_number: u32,
2107 _artist: &str,
2108 ) -> Result<AlbumPage> {
2109 log::debug!("JSON parsing not implemented, returning empty page");
2111 Ok(AlbumPage {
2112 albums: Vec::new(),
2113 page_number,
2114 has_next_page: false,
2115 total_pages: Some(1),
2116 })
2117 }
2118}
2119
2120#[async_trait(?Send)]
2121impl LastFmEditClient for LastFmEditClientImpl {
2122 async fn login(&self, username: &str, password: &str) -> Result<()> {
2123 self.login(username, password).await
2124 }
2125
2126 fn username(&self) -> String {
2127 self.username()
2128 }
2129
2130 fn is_logged_in(&self) -> bool {
2131 self.is_logged_in()
2132 }
2133
2134 async fn get_recent_scrobbles(&self, page: u32) -> Result<Vec<Track>> {
2135 self.get_recent_scrobbles(page).await
2136 }
2137
2138 async fn find_scrobble_by_timestamp(&self, timestamp: u64) -> Result<Track> {
2139 self.find_scrobble_by_timestamp(timestamp).await
2140 }
2141
2142 async fn find_recent_scrobble_for_track(
2143 &self,
2144 track_name: &str,
2145 artist_name: &str,
2146 max_pages: u32,
2147 ) -> Result<Option<Track>> {
2148 self.find_recent_scrobble_for_track(track_name, artist_name, max_pages)
2149 .await
2150 }
2151
2152 async fn edit_scrobble(&self, edit: &ScrobbleEdit) -> Result<EditResponse> {
2153 self.edit_scrobble(edit).await
2154 }
2155
2156 async fn load_edit_form_values(
2157 &self,
2158 track_name: &str,
2159 artist_name: &str,
2160 ) -> Result<ScrobbleEdit> {
2161 self.load_edit_form_values(track_name, artist_name).await
2162 }
2163
2164 async fn get_album_tracks(&self, album_name: &str, artist_name: &str) -> Result<Vec<Track>> {
2165 self.get_album_tracks(album_name, artist_name).await
2166 }
2167
2168 async fn edit_album(
2169 &self,
2170 old_album_name: &str,
2171 new_album_name: &str,
2172 artist_name: &str,
2173 ) -> Result<EditResponse> {
2174 self.edit_album(old_album_name, new_album_name, artist_name)
2175 .await
2176 }
2177
2178 async fn edit_artist(
2179 &self,
2180 old_artist_name: &str,
2181 new_artist_name: &str,
2182 ) -> Result<EditResponse> {
2183 self.edit_artist(old_artist_name, new_artist_name).await
2184 }
2185
2186 async fn edit_artist_for_track(
2187 &self,
2188 track_name: &str,
2189 old_artist_name: &str,
2190 new_artist_name: &str,
2191 ) -> Result<EditResponse> {
2192 self.edit_artist_for_track(track_name, old_artist_name, new_artist_name)
2193 .await
2194 }
2195
2196 async fn edit_artist_for_album(
2197 &self,
2198 album_name: &str,
2199 old_artist_name: &str,
2200 new_artist_name: &str,
2201 ) -> Result<EditResponse> {
2202 self.edit_artist_for_album(album_name, old_artist_name, new_artist_name)
2203 .await
2204 }
2205
2206 async fn get_artist_tracks_page(&self, artist: &str, page: u32) -> Result<TrackPage> {
2207 self.get_artist_tracks_page(artist, page).await
2208 }
2209
2210 async fn get_artist_albums_page(&self, artist: &str, page: u32) -> Result<AlbumPage> {
2211 self.get_artist_albums_page(artist, page).await
2212 }
2213
2214 fn get_session(&self) -> LastFmEditSession {
2215 self.get_session()
2216 }
2217
2218 fn restore_session(&self, session: LastFmEditSession) {
2219 self.restore_session(session)
2220 }
2221
2222 fn artist_tracks(&self, artist: &str) -> crate::ArtistTracksIterator {
2223 crate::ArtistTracksIterator::new(self.clone(), artist.to_string())
2224 }
2225
2226 fn artist_albums(&self, artist: &str) -> crate::ArtistAlbumsIterator {
2227 crate::ArtistAlbumsIterator::new(self.clone(), artist.to_string())
2228 }
2229
2230 fn recent_tracks(&self) -> crate::RecentTracksIterator {
2231 crate::RecentTracksIterator::new(self.clone())
2232 }
2233
2234 fn recent_tracks_from_page(&self, starting_page: u32) -> crate::RecentTracksIterator {
2235 crate::RecentTracksIterator::with_starting_page(self.clone(), starting_page)
2236 }
2237}