1use crate::{
2 Album, AlbumPage, ArtistAlbumsIterator, ArtistTracksIterator, AsyncPaginatedIterator,
3 EditResponse, LastFmError, RecentTracksIterator, Result, ScrobbleEdit, Track, TrackPage,
4};
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;
11
12pub struct LastFmClient {
38 client: Box<dyn HttpClient>,
39 username: String,
40 csrf_token: Option<String>,
41 base_url: String,
42 session_cookies: Vec<String>,
43 rate_limit_patterns: Vec<String>,
44 debug_save_responses: bool,
45}
46
47impl LastFmClient {
48 pub fn new(client: Box<dyn HttpClient>) -> Self {
63 Self::with_base_url(client, "https://www.last.fm".to_string())
64 }
65
66 pub fn with_base_url(client: Box<dyn HttpClient>, base_url: String) -> Self {
75 Self::with_rate_limit_patterns(
76 client,
77 base_url,
78 vec![
79 "you've tried to log in too many times".to_string(),
80 "you're requesting too many pages".to_string(),
81 "slow down".to_string(),
82 "too fast".to_string(),
83 "rate limit".to_string(),
84 "throttled".to_string(),
85 "temporarily blocked".to_string(),
86 "temporarily restricted".to_string(),
87 "captcha".to_string(),
88 "verify you're human".to_string(),
89 "prove you're not a robot".to_string(),
90 "security check".to_string(),
91 "service temporarily unavailable".to_string(),
92 "quota exceeded".to_string(),
93 "limit exceeded".to_string(),
94 "daily limit".to_string(),
95 ],
96 )
97 }
98
99 pub fn with_rate_limit_patterns(
107 client: Box<dyn HttpClient>,
108 base_url: String,
109 rate_limit_patterns: Vec<String>,
110 ) -> Self {
111 Self {
112 client,
113 username: String::new(),
114 csrf_token: None,
115 base_url,
116 session_cookies: Vec::new(),
117 rate_limit_patterns,
118 debug_save_responses: std::env::var("LASTFM_DEBUG_SAVE_RESPONSES").is_ok(),
119 }
120 }
121
122 pub async fn login(&mut self, username: &str, password: &str) -> Result<()> {
151 let login_url = format!("{}/login", self.base_url);
153 let mut response = self.get(&login_url).await?;
154
155 self.extract_cookies(&response);
157
158 let html = response
159 .body_string()
160 .await
161 .map_err(|e| LastFmError::Http(e.to_string()))?;
162 let document = Html::parse_document(&html);
163
164 let csrf_token = self.extract_csrf_token(&document)?;
165
166 let mut form_data = HashMap::new();
168 form_data.insert("csrfmiddlewaretoken", csrf_token.as_str());
169 form_data.insert("username_or_email", username);
170 form_data.insert("password", password);
171
172 let next_selector = Selector::parse("input[name=\"next\"]").unwrap();
174 if let Some(next_input) = document.select(&next_selector).next() {
175 if let Some(next_value) = next_input.value().attr("value") {
176 form_data.insert("next", next_value);
177 }
178 }
179
180 let mut request = Request::new(Method::Post, login_url.parse::<Url>().unwrap());
181 request.insert_header("Referer", &login_url);
182 request.insert_header("Origin", &self.base_url);
183 request.insert_header("Content-Type", "application/x-www-form-urlencoded");
184 request.insert_header(
185 "User-Agent",
186 "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36"
187 );
188 request.insert_header(
189 "Accept",
190 "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"
191 );
192 request.insert_header("Accept-Language", "en-US,en;q=0.9");
193 request.insert_header("Accept-Encoding", "gzip, deflate, br");
194 request.insert_header("DNT", "1");
195 request.insert_header("Connection", "keep-alive");
196 request.insert_header("Upgrade-Insecure-Requests", "1");
197 request.insert_header(
198 "sec-ch-ua",
199 "\"Not)A;Brand\";v=\"8\", \"Chromium\";v=\"138\", \"Google Chrome\";v=\"138\"",
200 );
201 request.insert_header("sec-ch-ua-mobile", "?0");
202 request.insert_header("sec-ch-ua-platform", "\"Linux\"");
203 request.insert_header("Sec-Fetch-Dest", "document");
204 request.insert_header("Sec-Fetch-Mode", "navigate");
205 request.insert_header("Sec-Fetch-Site", "same-origin");
206 request.insert_header("Sec-Fetch-User", "?1");
207
208 if !self.session_cookies.is_empty() {
210 let cookie_header = self.session_cookies.join("; ");
211 request.insert_header("Cookie", &cookie_header);
212 }
213
214 let form_string: String = form_data
216 .iter()
217 .map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v)))
218 .collect::<Vec<_>>()
219 .join("&");
220
221 request.set_body(form_string);
222
223 let mut response = self
224 .client
225 .send(request)
226 .await
227 .map_err(|e| LastFmError::Http(e.to_string()))?;
228
229 self.extract_cookies(&response);
231
232 log::debug!("Login response status: {}", response.status());
233
234 if response.status() == 403 {
236 let response_html = response
238 .body_string()
239 .await
240 .map_err(|e| LastFmError::Http(e.to_string()))?;
241
242 if self.is_rate_limit_response(&response_html) {
244 log::debug!("403 response appears to be rate limiting");
245 return Err(LastFmError::RateLimit { retry_after: 60 });
246 } else {
247 log::debug!("403 response appears to be authentication failure");
248
249 let success_doc = Html::parse_document(&response_html);
251 let login_form_selector =
252 Selector::parse("form[action*=\"login\"], input[name=\"username_or_email\"]")
253 .unwrap();
254 let has_login_form = success_doc.select(&login_form_selector).next().is_some();
255
256 if !has_login_form {
257 return Err(LastFmError::Auth(
258 "Login failed - 403 Forbidden. Check credentials.".to_string(),
259 ));
260 } else {
261 let error_selector =
263 Selector::parse(".alert-danger, .form-error, .error-message").unwrap();
264 let mut error_messages = Vec::new();
265 for error in success_doc.select(&error_selector) {
266 let error_text = error.text().collect::<String>().trim().to_string();
267 if !error_text.is_empty() {
268 error_messages.push(error_text);
269 }
270 }
271 let error_msg = if error_messages.is_empty() {
272 "Login failed - 403 Forbidden. Check credentials.".to_string()
273 } else {
274 format!("Login failed: {}", error_messages.join("; "))
275 };
276 return Err(LastFmError::Auth(error_msg));
277 }
278 }
279 }
280
281 let has_real_session = self
283 .session_cookies
284 .iter()
285 .any(|cookie| cookie.starts_with("sessionid=.") && cookie.len() > 50);
286
287 if has_real_session && (response.status() == 302 || response.status() == 200) {
288 self.username = username.to_string();
290 self.csrf_token = Some(csrf_token);
291 log::debug!("Login successful - authenticated session established");
292 return Ok(());
293 }
294
295 let response_html = response
297 .body_string()
298 .await
299 .map_err(|e| LastFmError::Http(e.to_string()))?;
300
301 let success_doc = Html::parse_document(&response_html);
303 let login_form_selector =
304 Selector::parse("form[action*=\"login\"], input[name=\"username_or_email\"]").unwrap();
305 let has_login_form = success_doc.select(&login_form_selector).next().is_some();
306
307 if !has_login_form && response.status() == 200 {
308 self.username = username.to_string();
309 self.csrf_token = Some(csrf_token);
310 Ok(())
311 } else {
312 let error_doc = success_doc;
314 let error_selector =
315 Selector::parse(".alert-danger, .form-error, .error-message").unwrap();
316
317 let mut error_messages = Vec::new();
318 for error in error_doc.select(&error_selector) {
319 let error_text = error.text().collect::<String>().trim().to_string();
320 if !error_text.is_empty() {
321 error_messages.push(error_text);
322 }
323 }
324
325 let error_msg = if error_messages.is_empty() {
326 "Login failed - please check your credentials".to_string()
327 } else {
328 format!("Login failed: {}", error_messages.join("; "))
329 };
330
331 Err(LastFmError::Auth(error_msg))
332 }
333 }
334
335 pub fn username(&self) -> &str {
339 &self.username
340 }
341
342 pub fn is_logged_in(&self) -> bool {
346 !self.username.is_empty() && self.csrf_token.is_some()
347 }
348
349 pub fn artist_tracks<'a>(&'a mut self, artist: &str) -> ArtistTracksIterator<'a> {
375 ArtistTracksIterator::new(self, artist.to_string())
376 }
377
378 pub fn artist_albums<'a>(&'a mut self, artist: &str) -> ArtistAlbumsIterator<'a> {
388 ArtistAlbumsIterator::new(self, artist.to_string())
389 }
390
391 pub fn recent_tracks<'a>(&'a mut self) -> RecentTracksIterator<'a> {
418 RecentTracksIterator::new(self)
419 }
420
421 pub async fn get_recent_scrobbles(&mut self, page: u32) -> Result<Vec<Track>> {
424 let url = format!(
425 "{}/user/{}/library?page={}",
426 self.base_url, self.username, page
427 );
428
429 log::debug!("Fetching recent scrobbles page {page}");
430 let mut response = self.get(&url).await?;
431 let content = response
432 .body_string()
433 .await
434 .map_err(|e| LastFmError::Http(e.to_string()))?;
435
436 log::debug!(
437 "Recent scrobbles response: {} status, {} chars",
438 response.status(),
439 content.len()
440 );
441
442 let document = Html::parse_document(&content);
443 self.parse_recent_scrobbles(&document)
444 }
445
446 pub async fn find_recent_scrobble_for_track(
449 &mut self,
450 track_name: &str,
451 artist_name: &str,
452 max_pages: u32,
453 ) -> Result<Option<Track>> {
454 log::debug!("Searching for recent scrobble: '{track_name}' by '{artist_name}'");
455
456 for page in 1..=max_pages {
457 let scrobbles = self.get_recent_scrobbles(page).await?;
458
459 for scrobble in scrobbles {
460 if scrobble.name == track_name && scrobble.artist == artist_name {
461 log::debug!(
462 "Found recent scrobble: '{}' with timestamp {:?}",
463 scrobble.name,
464 scrobble.timestamp
465 );
466 return Ok(Some(scrobble));
467 }
468 }
469
470 tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
472 }
473
474 log::debug!(
475 "No recent scrobble found for '{track_name}' by '{artist_name}' in {max_pages} pages"
476 );
477 Ok(None)
478 }
479
480 pub async fn edit_scrobble(&mut self, edit: &ScrobbleEdit) -> Result<EditResponse> {
481 self.edit_scrobble_with_retry(edit, 3).await
482 }
483
484 pub async fn edit_scrobble_with_retry(
485 &mut self,
486 edit: &ScrobbleEdit,
487 max_retries: u32,
488 ) -> Result<EditResponse> {
489 let mut retries = 0;
490
491 loop {
492 match self.edit_scrobble_impl(edit).await {
493 Ok(result) => return Ok(result),
494 Err(LastFmError::RateLimit { retry_after }) => {
495 if retries >= max_retries {
496 log::warn!("Max retries ({max_retries}) exceeded for edit operation");
497 return Err(LastFmError::RateLimit { retry_after });
498 }
499
500 let delay = std::cmp::min(retry_after, 2_u64.pow(retries + 1) * 5);
501 log::info!(
502 "Edit rate limited. Waiting {} seconds before retry {} of {}",
503 delay,
504 retries + 1,
505 max_retries
506 );
507 tokio::time::sleep(tokio::time::Duration::from_secs(delay)).await;
508 retries += 1;
509 }
510 Err(other_error) => return Err(other_error),
511 }
512 }
513 }
514
515 async fn edit_scrobble_impl(&mut self, edit: &ScrobbleEdit) -> Result<EditResponse> {
516 if !self.is_logged_in() {
517 return Err(LastFmError::Auth(
518 "Must be logged in to edit scrobbles".to_string(),
519 ));
520 }
521
522 let edit_url = format!(
523 "{}/user/{}/library/edit?edited-variation=library-track-scrobble",
524 self.base_url, self.username
525 );
526
527 log::debug!("Getting fresh CSRF token for edit");
528
529 let mut form_response = self.get(&edit_url).await?;
531 let form_html = form_response
532 .body_string()
533 .await
534 .map_err(|e| LastFmError::Http(e.to_string()))?;
535
536 log::debug!("Edit form response status: {}", form_response.status());
537
538 let form_document = Html::parse_document(&form_html);
540 let fresh_csrf_token = self.extract_csrf_token(&form_document)?;
541
542 log::debug!("Submitting edit with fresh token");
543
544 let mut form_data = HashMap::new();
545
546 form_data.insert("csrfmiddlewaretoken", fresh_csrf_token.as_str());
548
549 form_data.insert("track_name_original", &edit.track_name_original);
551 form_data.insert("track_name", &edit.track_name);
552 form_data.insert("artist_name_original", &edit.artist_name_original);
553 form_data.insert("artist_name", &edit.artist_name);
554 form_data.insert("album_name_original", &edit.album_name_original);
555 form_data.insert("album_name", &edit.album_name);
556 form_data.insert(
557 "album_artist_name_original",
558 &edit.album_artist_name_original,
559 );
560 form_data.insert("album_artist_name", &edit.album_artist_name);
561
562 let timestamp_str = edit.timestamp.to_string();
564 form_data.insert("timestamp", ×tamp_str);
565
566 if edit.edit_all {
568 form_data.insert("edit_all", "1");
569 }
570 form_data.insert("submit", "edit-scrobble");
571 form_data.insert("ajax", "1");
572
573 log::debug!(
574 "Editing scrobble: '{}' -> '{}'",
575 edit.track_name_original,
576 edit.track_name
577 );
578 log::trace!("Session cookies count: {}", self.session_cookies.len());
579
580 let mut request = Request::new(Method::Post, edit_url.parse::<Url>().unwrap());
581
582 request.insert_header("Accept", "*/*");
584 request.insert_header("Accept-Language", "en-US,en;q=0.9");
585 request.insert_header(
586 "Content-Type",
587 "application/x-www-form-urlencoded;charset=UTF-8",
588 );
589 request.insert_header("Priority", "u=1, i");
590 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");
591 request.insert_header("X-Requested-With", "XMLHttpRequest");
592 request.insert_header("Sec-Fetch-Dest", "empty");
593 request.insert_header("Sec-Fetch-Mode", "cors");
594 request.insert_header("Sec-Fetch-Site", "same-origin");
595 request.insert_header(
596 "sec-ch-ua",
597 "\"Not)A;Brand\";v=\"8\", \"Chromium\";v=\"138\", \"Google Chrome\";v=\"138\"",
598 );
599 request.insert_header("sec-ch-ua-mobile", "?0");
600 request.insert_header("sec-ch-ua-platform", "\"Linux\"");
601
602 if !self.session_cookies.is_empty() {
604 let cookie_header = self.session_cookies.join("; ");
605 request.insert_header("Cookie", &cookie_header);
606 }
607
608 request.insert_header(
610 "Referer",
611 format!("{}/user/{}/library", self.base_url, self.username),
612 );
613
614 let form_string: String = form_data
616 .iter()
617 .map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v)))
618 .collect::<Vec<_>>()
619 .join("&");
620
621 request.set_body(form_string);
622
623 let mut response = self
624 .client
625 .send(request)
626 .await
627 .map_err(|e| LastFmError::Http(e.to_string()))?;
628
629 log::debug!("Edit response status: {}", response.status());
630
631 let response_text = response
632 .body_string()
633 .await
634 .map_err(|e| LastFmError::Http(e.to_string()))?;
635
636 let document = Html::parse_document(&response_text);
638
639 let success_selector = Selector::parse(".alert-success").unwrap();
641 let error_selector = Selector::parse(".alert-danger, .alert-error, .error").unwrap();
642
643 let has_success_alert = document.select(&success_selector).next().is_some();
644 let has_error_alert = document.select(&error_selector).next().is_some();
645
646 let mut actual_track_name = None;
649 let mut actual_album_name = None;
650
651 let track_name_selector = Selector::parse("td.chartlist-name a").unwrap();
653 let album_name_selector = Selector::parse("td.chartlist-album a").unwrap();
654
655 if let Some(track_element) = document.select(&track_name_selector).next() {
656 actual_track_name = Some(track_element.text().collect::<String>().trim().to_string());
657 }
658
659 if let Some(album_element) = document.select(&album_name_selector).next() {
660 actual_album_name = Some(album_element.text().collect::<String>().trim().to_string());
661 }
662
663 if actual_track_name.is_none() || actual_album_name.is_none() {
665 let track_pattern = regex::Regex::new(r#"href="/music/[^"]+/_/([^"]+)""#).unwrap();
668 if let Some(captures) = track_pattern.captures(&response_text) {
669 if let Some(track_match) = captures.get(1) {
670 let raw_track = track_match.as_str();
671 let decoded_track = urlencoding::decode(raw_track)
673 .unwrap_or_else(|_| raw_track.into())
674 .replace("+", " ");
675 actual_track_name = Some(decoded_track);
676 }
677 }
678
679 let album_pattern =
682 regex::Regex::new(r#"href="/music/[^"]+/([^"/_]+)"[^>]*>[^<]*</a>"#).unwrap();
683 if let Some(captures) = album_pattern.captures(&response_text) {
684 if let Some(album_match) = captures.get(1) {
685 let raw_album = album_match.as_str();
686 let decoded_album = urlencoding::decode(raw_album)
688 .unwrap_or_else(|_| raw_album.into())
689 .replace("+", " ");
690 actual_album_name = Some(decoded_album);
691 }
692 }
693 }
694
695 log::debug!(
696 "Response analysis: success_alert={}, error_alert={}, track='{}', album='{}'",
697 has_success_alert,
698 has_error_alert,
699 actual_track_name.as_deref().unwrap_or("not found"),
700 actual_album_name.as_deref().unwrap_or("not found")
701 );
702
703 let final_success = response.status().is_success() && has_success_alert && !has_error_alert;
705
706 let message = if has_error_alert {
708 if let Some(error_element) = document.select(&error_selector).next() {
710 Some(format!(
711 "Edit failed: {}",
712 error_element.text().collect::<String>().trim()
713 ))
714 } else {
715 Some("Edit failed with unknown error".to_string())
716 }
717 } else if final_success {
718 Some(format!(
719 "Edit successful - Track: '{}', Album: '{}'",
720 actual_track_name.as_deref().unwrap_or("unknown"),
721 actual_album_name.as_deref().unwrap_or("unknown")
722 ))
723 } else {
724 Some(format!("Edit failed with status: {}", response.status()))
725 };
726
727 Ok(EditResponse {
728 success: final_success,
729 message,
730 })
731 }
732
733 pub async fn load_edit_form_values(
736 &mut self,
737 track_name: &str,
738 artist_name: &str,
739 ) -> Result<crate::ScrobbleEdit> {
740 log::debug!("Loading edit form values for '{track_name}' by '{artist_name}'");
741
742 let track_url = format!(
746 "{}/user/{}/library/music/+noredirect/{}/_/{}",
747 self.base_url,
748 self.username,
749 urlencoding::encode(artist_name),
750 urlencoding::encode(track_name)
751 );
752
753 log::debug!("Fetching track page: {track_url}");
754
755 let mut response = self.get(&track_url).await?;
756 let html = response
757 .body_string()
758 .await
759 .map_err(|e| crate::LastFmError::Http(e.to_string()))?;
760
761 let document = Html::parse_document(&html);
762
763 self.extract_scrobble_data_from_track_page(&document, track_name, artist_name)
765 }
766
767 fn extract_scrobble_data_from_track_page(
770 &self,
771 document: &Html,
772 expected_track: &str,
773 expected_artist: &str,
774 ) -> Result<crate::ScrobbleEdit> {
775 let table_selector =
777 Selector::parse("table.chartlist:not(.chartlist__placeholder)").unwrap();
778 let table = document.select(&table_selector).next().ok_or_else(|| {
779 crate::LastFmError::Parse("No chartlist table found on track page".to_string())
780 })?;
781
782 let row_selector = Selector::parse("tr").unwrap();
784 for row in table.select(&row_selector) {
785 let count_bar_link_selector = Selector::parse(".chartlist-count-bar-link").unwrap();
787 if row.select(&count_bar_link_selector).next().is_some() {
788 log::debug!("Found count bar link, skipping aggregated row");
789 continue;
790 }
791
792 let form_selector = Selector::parse("form[data-edit-scrobble]").unwrap();
794 if let Some(form) = row.select(&form_selector).next() {
795 let extract_form_value = |name: &str| -> Option<String> {
797 let selector = Selector::parse(&format!("input[name='{name}']")).unwrap();
798 form.select(&selector)
799 .next()
800 .and_then(|input| input.value().attr("value"))
801 .map(|s| s.to_string())
802 };
803
804 let form_track = extract_form_value("track_name").unwrap_or_default();
806 let form_artist = extract_form_value("artist_name").unwrap_or_default();
807 let form_album = extract_form_value("album_name").unwrap_or_default();
808 let form_album_artist =
809 extract_form_value("album_artist_name").unwrap_or_else(|| form_artist.clone());
810 let form_timestamp = extract_form_value("timestamp").unwrap_or_default();
811
812 log::debug!(
813 "Found scrobble form - Track: '{form_track}', Artist: '{form_artist}', Album: '{form_album}', Timestamp: {form_timestamp}"
814 );
815
816 if form_track == expected_track && form_artist == expected_artist {
818 let timestamp = form_timestamp.parse::<u64>().map_err(|_| {
819 crate::LastFmError::Parse("Invalid timestamp in form".to_string())
820 })?;
821
822 log::debug!(
823 "✅ Found matching scrobble form for '{expected_track}' by '{expected_artist}'"
824 );
825
826 return Ok(crate::ScrobbleEdit::new(
828 form_track.clone(),
829 form_album.clone(),
830 form_artist.clone(),
831 form_album_artist.clone(),
832 form_track,
833 form_album,
834 form_artist,
835 form_album_artist,
836 timestamp,
837 true,
838 ));
839 }
840 }
841 }
842
843 Err(crate::LastFmError::Parse(format!(
844 "No scrobble form found for track '{expected_track}' by '{expected_artist}'"
845 )))
846 }
847
848 pub async fn get_album_tracks(
851 &mut self,
852 album_name: &str,
853 artist_name: &str,
854 ) -> Result<Vec<Track>> {
855 log::debug!("Getting tracks from album '{album_name}' by '{artist_name}'");
856
857 let album_url = format!(
859 "{}/user/{}/library/music/{}/{}",
860 self.base_url,
861 self.username,
862 urlencoding::encode(artist_name),
863 urlencoding::encode(album_name)
864 );
865
866 log::debug!("Fetching album page: {album_url}");
867
868 let mut response = self.get(&album_url).await?;
869 let html = response
870 .body_string()
871 .await
872 .map_err(|e| LastFmError::Http(e.to_string()))?;
873
874 let document = Html::parse_document(&html);
875
876 let tracks = self.extract_tracks_from_document(&document, artist_name)?;
878
879 log::debug!(
880 "Successfully parsed {} tracks from album page",
881 tracks.len()
882 );
883 Ok(tracks)
884 }
885
886 pub async fn edit_album(
889 &mut self,
890 old_album_name: &str,
891 new_album_name: &str,
892 artist_name: &str,
893 ) -> Result<EditResponse> {
894 log::debug!("Editing album '{old_album_name}' -> '{new_album_name}' by '{artist_name}'");
895
896 let tracks = self.get_album_tracks(old_album_name, artist_name).await?;
898
899 if tracks.is_empty() {
900 return Ok(EditResponse {
901 success: false,
902 message: Some(format!(
903 "No tracks found for album '{old_album_name}' by '{artist_name}'. Make sure the album name matches exactly."
904 )),
905 });
906 }
907
908 log::info!(
909 "Found {} tracks in album '{}'",
910 tracks.len(),
911 old_album_name
912 );
913
914 let mut successful_edits = 0;
915 let mut failed_edits = 0;
916 let mut error_messages = Vec::new();
917 let mut skipped_tracks = 0;
918
919 for (index, track) in tracks.iter().enumerate() {
921 log::debug!(
922 "Processing track {}/{}: '{}'",
923 index + 1,
924 tracks.len(),
925 track.name
926 );
927
928 match self.load_edit_form_values(&track.name, artist_name).await {
929 Ok(mut edit_data) => {
930 edit_data.album_name = new_album_name.to_string();
932
933 match self.edit_scrobble(&edit_data).await {
935 Ok(response) => {
936 if response.success {
937 successful_edits += 1;
938 log::info!("✅ Successfully edited track '{}'", track.name);
939 } else {
940 failed_edits += 1;
941 let error_msg = format!(
942 "Failed to edit track '{}': {}",
943 track.name,
944 response
945 .message
946 .unwrap_or_else(|| "Unknown error".to_string())
947 );
948 error_messages.push(error_msg);
949 log::debug!("❌ {}", error_messages.last().unwrap());
950 }
951 }
952 Err(e) => {
953 failed_edits += 1;
954 let error_msg = format!("Error editing track '{}': {}", track.name, e);
955 error_messages.push(error_msg);
956 log::info!("❌ {}", error_messages.last().unwrap());
957 }
958 }
959 }
960 Err(e) => {
961 skipped_tracks += 1;
962 log::debug!("Could not load edit form for track '{}': {e}", track.name);
963 }
965 }
966
967 if index < tracks.len() - 1 {
969 tokio::time::sleep(tokio::time::Duration::from_millis(1000)).await;
970 }
971 }
972
973 let total_processed = successful_edits + failed_edits;
974 let success = successful_edits > 0 && failed_edits == 0;
975
976 let message = if success {
977 Some(format!(
978 "Successfully renamed album '{old_album_name}' to '{new_album_name}' for all {successful_edits} editable tracks ({skipped_tracks} tracks were not in recent scrobbles)"
979 ))
980 } else if successful_edits > 0 {
981 Some(format!(
982 "Partially successful: {} of {} editable tracks renamed ({} skipped, {} failed). Errors: {}",
983 successful_edits,
984 total_processed,
985 skipped_tracks,
986 failed_edits,
987 error_messages.join("; ")
988 ))
989 } else if total_processed == 0 {
990 Some(format!(
991 "No editable tracks found for album '{}' by '{}'. All {} tracks were skipped because they're not in recent scrobbles.",
992 old_album_name, artist_name, tracks.len()
993 ))
994 } else {
995 Some(format!(
996 "Failed to rename any tracks. Errors: {}",
997 error_messages.join("; ")
998 ))
999 };
1000
1001 Ok(EditResponse { success, message })
1002 }
1003
1004 pub async fn edit_artist(
1007 &mut self,
1008 old_artist_name: &str,
1009 new_artist_name: &str,
1010 ) -> Result<EditResponse> {
1011 log::debug!("Editing artist '{old_artist_name}' -> '{new_artist_name}'");
1012
1013 let mut tracks = Vec::new();
1015 let mut iterator = self.artist_tracks(old_artist_name);
1016
1017 while tracks.len() < 200 {
1019 match iterator.next().await {
1020 Ok(Some(track)) => tracks.push(track),
1021 Ok(None) => break,
1022 Err(e) => {
1023 log::warn!("Error fetching artist tracks: {e}");
1024 break;
1025 }
1026 }
1027 }
1028
1029 if tracks.is_empty() {
1030 return Ok(EditResponse {
1031 success: false,
1032 message: Some(format!(
1033 "No tracks found for artist '{old_artist_name}'. Make sure the artist name matches exactly."
1034 )),
1035 });
1036 }
1037
1038 log::info!(
1039 "Found {} tracks for artist '{}'",
1040 tracks.len(),
1041 old_artist_name
1042 );
1043
1044 let mut successful_edits = 0;
1045 let mut failed_edits = 0;
1046 let mut error_messages = Vec::new();
1047 let mut skipped_tracks = 0;
1048
1049 for (index, track) in tracks.iter().enumerate() {
1051 log::debug!(
1052 "Processing track {}/{}: '{}'",
1053 index + 1,
1054 tracks.len(),
1055 track.name
1056 );
1057
1058 match self
1059 .load_edit_form_values(&track.name, old_artist_name)
1060 .await
1061 {
1062 Ok(mut edit_data) => {
1063 edit_data.artist_name = new_artist_name.to_string();
1065 edit_data.album_artist_name = new_artist_name.to_string();
1066
1067 match self.edit_scrobble(&edit_data).await {
1069 Ok(response) => {
1070 if response.success {
1071 successful_edits += 1;
1072 log::info!("✅ Successfully edited track '{}'", track.name);
1073 } else {
1074 failed_edits += 1;
1075 let error_msg = format!(
1076 "Failed to edit track '{}': {}",
1077 track.name,
1078 response
1079 .message
1080 .unwrap_or_else(|| "Unknown error".to_string())
1081 );
1082 error_messages.push(error_msg);
1083 log::debug!("❌ {}", error_messages.last().unwrap());
1084 }
1085 }
1086 Err(e) => {
1087 failed_edits += 1;
1088 let error_msg = format!("Error editing track '{}': {}", track.name, e);
1089 error_messages.push(error_msg);
1090 log::info!("❌ {}", error_messages.last().unwrap());
1091 }
1092 }
1093 }
1094 Err(e) => {
1095 skipped_tracks += 1;
1096 log::debug!("Could not load edit form for track '{}': {e}", track.name);
1097 }
1099 }
1100
1101 if index < tracks.len() - 1 {
1103 tokio::time::sleep(tokio::time::Duration::from_millis(1000)).await;
1104 }
1105 }
1106
1107 let total_processed = successful_edits + failed_edits;
1108 let success = successful_edits > 0 && failed_edits == 0;
1109
1110 let message = if success {
1111 Some(format!(
1112 "Successfully renamed artist '{old_artist_name}' to '{new_artist_name}' for all {successful_edits} editable tracks ({skipped_tracks} tracks were not in recent scrobbles)"
1113 ))
1114 } else if successful_edits > 0 {
1115 Some(format!(
1116 "Partially successful: {} of {} editable tracks renamed ({} skipped, {} failed). Errors: {}",
1117 successful_edits,
1118 total_processed,
1119 skipped_tracks,
1120 failed_edits,
1121 error_messages.join("; ")
1122 ))
1123 } else if total_processed == 0 {
1124 Some(format!(
1125 "No editable tracks found for artist '{}'. All {} tracks were skipped because they're not in recent scrobbles.",
1126 old_artist_name, tracks.len()
1127 ))
1128 } else {
1129 Some(format!(
1130 "Failed to rename any tracks. Errors: {}",
1131 error_messages.join("; ")
1132 ))
1133 };
1134
1135 Ok(EditResponse { success, message })
1136 }
1137
1138 pub async fn edit_artist_for_track(
1141 &mut self,
1142 track_name: &str,
1143 old_artist_name: &str,
1144 new_artist_name: &str,
1145 ) -> Result<EditResponse> {
1146 log::debug!("Editing artist for track '{track_name}' from '{old_artist_name}' -> '{new_artist_name}'");
1147
1148 match self.load_edit_form_values(track_name, old_artist_name).await {
1149 Ok(mut edit_data) => {
1150 edit_data.artist_name = new_artist_name.to_string();
1152 edit_data.album_artist_name = new_artist_name.to_string();
1153
1154 log::info!("Updating artist for track '{track_name}' from '{old_artist_name}' to '{new_artist_name}'");
1155
1156 match self.edit_scrobble(&edit_data).await {
1158 Ok(response) => {
1159 if response.success {
1160 Ok(EditResponse {
1161 success: true,
1162 message: Some(format!(
1163 "Successfully renamed artist for track '{track_name}' from '{old_artist_name}' to '{new_artist_name}'"
1164 )),
1165 })
1166 } else {
1167 Ok(EditResponse {
1168 success: false,
1169 message: Some(format!(
1170 "Failed to rename artist for track '{track_name}': {}",
1171 response.message.unwrap_or_else(|| "Unknown error".to_string())
1172 )),
1173 })
1174 }
1175 }
1176 Err(e) => Ok(EditResponse {
1177 success: false,
1178 message: Some(format!("Error editing track '{track_name}': {e}")),
1179 }),
1180 }
1181 }
1182 Err(e) => Ok(EditResponse {
1183 success: false,
1184 message: Some(format!(
1185 "Could not load edit form for track '{track_name}' by '{old_artist_name}': {e}. The track may not be in your recent scrobbles."
1186 )),
1187 }),
1188 }
1189 }
1190
1191 pub async fn edit_artist_for_album(
1194 &mut self,
1195 album_name: &str,
1196 old_artist_name: &str,
1197 new_artist_name: &str,
1198 ) -> Result<EditResponse> {
1199 log::debug!("Editing artist for album '{album_name}' from '{old_artist_name}' -> '{new_artist_name}'");
1200
1201 let tracks = self.get_album_tracks(album_name, old_artist_name).await?;
1203
1204 if tracks.is_empty() {
1205 return Ok(EditResponse {
1206 success: false,
1207 message: Some(format!(
1208 "No tracks found for album '{album_name}' by '{old_artist_name}'. Make sure the album name matches exactly."
1209 )),
1210 });
1211 }
1212
1213 log::info!(
1214 "Found {} tracks in album '{}' by '{}'",
1215 tracks.len(),
1216 album_name,
1217 old_artist_name
1218 );
1219
1220 let mut successful_edits = 0;
1221 let mut failed_edits = 0;
1222 let mut error_messages = Vec::new();
1223 let mut skipped_tracks = 0;
1224
1225 for (index, track) in tracks.iter().enumerate() {
1227 log::debug!(
1228 "Processing track {}/{}: '{}'",
1229 index + 1,
1230 tracks.len(),
1231 track.name
1232 );
1233
1234 match self
1235 .load_edit_form_values(&track.name, old_artist_name)
1236 .await
1237 {
1238 Ok(mut edit_data) => {
1239 edit_data.artist_name = new_artist_name.to_string();
1241 edit_data.album_artist_name = new_artist_name.to_string();
1242
1243 match self.edit_scrobble(&edit_data).await {
1245 Ok(response) => {
1246 if response.success {
1247 successful_edits += 1;
1248 log::info!("✅ Successfully edited track '{}'", track.name);
1249 } else {
1250 failed_edits += 1;
1251 let error_msg = format!(
1252 "Failed to edit track '{}': {}",
1253 track.name,
1254 response
1255 .message
1256 .unwrap_or_else(|| "Unknown error".to_string())
1257 );
1258 error_messages.push(error_msg);
1259 log::debug!("❌ {}", error_messages.last().unwrap());
1260 }
1261 }
1262 Err(e) => {
1263 failed_edits += 1;
1264 let error_msg = format!("Error editing track '{}': {}", track.name, e);
1265 error_messages.push(error_msg);
1266 log::info!("❌ {}", error_messages.last().unwrap());
1267 }
1268 }
1269 }
1270 Err(e) => {
1271 skipped_tracks += 1;
1272 log::debug!("Could not load edit form for track '{}': {e}", track.name);
1273 }
1275 }
1276
1277 if index < tracks.len() - 1 {
1279 tokio::time::sleep(tokio::time::Duration::from_millis(1000)).await;
1280 }
1281 }
1282
1283 let total_processed = successful_edits + failed_edits;
1284 let success = successful_edits > 0 && failed_edits == 0;
1285
1286 let message = if success {
1287 Some(format!(
1288 "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)"
1289 ))
1290 } else if successful_edits > 0 {
1291 Some(format!(
1292 "Partially successful: {} of {} editable tracks renamed ({} skipped, {} failed). Errors: {}",
1293 successful_edits,
1294 total_processed,
1295 skipped_tracks,
1296 failed_edits,
1297 error_messages.join("; ")
1298 ))
1299 } else if total_processed == 0 {
1300 Some(format!(
1301 "No editable tracks found for album '{album_name}' by '{old_artist_name}'. All {} tracks were skipped because they're not in recent scrobbles.",
1302 tracks.len()
1303 ))
1304 } else {
1305 Some(format!(
1306 "Failed to rename any tracks. Errors: {}",
1307 error_messages.join("; ")
1308 ))
1309 };
1310
1311 Ok(EditResponse { success, message })
1312 }
1313
1314 pub async fn get_artist_tracks_page(&mut self, artist: &str, page: u32) -> Result<TrackPage> {
1315 let url = format!(
1317 "{}/user/{}/library/music/{}/+tracks?page={}&ajax=true",
1318 self.base_url,
1319 self.username,
1320 artist.replace(" ", "+"),
1321 page
1322 );
1323
1324 log::debug!("Fetching tracks page {page} for artist: {artist}");
1325 let mut response = self.get(&url).await?;
1326 let content = response
1327 .body_string()
1328 .await
1329 .map_err(|e| LastFmError::Http(e.to_string()))?;
1330
1331 log::debug!(
1332 "AJAX response: {} status, {} chars",
1333 response.status(),
1334 content.len()
1335 );
1336
1337 if content.trim_start().starts_with("{") || content.trim_start().starts_with("[") {
1339 log::debug!("Parsing JSON response from AJAX endpoint");
1340 self.parse_json_tracks_page(&content, page, artist)
1341 } else {
1342 log::debug!("Parsing HTML response from AJAX endpoint");
1343 let document = Html::parse_document(&content);
1344 self.parse_tracks_page(&document, page, artist)
1345 }
1346 }
1347
1348 fn parse_json_tracks_page(
1349 &self,
1350 _json_content: &str,
1351 page_number: u32,
1352 _artist: &str,
1353 ) -> Result<TrackPage> {
1354 log::debug!("JSON parsing not implemented, returning empty page");
1356 Ok(TrackPage {
1357 tracks: Vec::new(),
1358 page_number,
1359 has_next_page: false,
1360 total_pages: Some(1),
1361 })
1362 }
1363
1364 pub fn extract_tracks_from_document(
1366 &self,
1367 document: &Html,
1368 artist: &str,
1369 ) -> Result<Vec<Track>> {
1370 let mut tracks = Vec::new();
1371 let mut seen_tracks = std::collections::HashSet::new();
1372
1373 let track_selector = Selector::parse("[data-track-name]").unwrap();
1375 let track_elements: Vec<_> = document.select(&track_selector).collect();
1376
1377 if !track_elements.is_empty() {
1378 log::debug!(
1379 "Found {} track elements with data-track-name",
1380 track_elements.len()
1381 );
1382
1383 for element in track_elements {
1384 if let Some(track_name) = element.value().attr("data-track-name") {
1385 if seen_tracks.contains(track_name) {
1386 continue;
1387 }
1388 seen_tracks.insert(track_name.to_string());
1389
1390 if let Ok(playcount) = self.find_playcount_for_track(document, track_name) {
1392 let timestamp = self.find_timestamp_for_track(document, track_name);
1394
1395 let track = Track {
1396 name: track_name.to_string(),
1397 artist: artist.to_string(),
1398 playcount,
1399 timestamp,
1400 };
1401 tracks.push(track);
1402 }
1403
1404 if tracks.len() >= 50 {
1405 break; }
1407 }
1408 }
1409 }
1410
1411 let form_input_selector = Selector::parse("input[name='track']").unwrap();
1413 let form_inputs: Vec<_> = document.select(&form_input_selector).collect();
1414
1415 if !form_inputs.is_empty() {
1416 log::debug!("Found {} form inputs with track names", form_inputs.len());
1417
1418 for input in form_inputs {
1419 if let Some(track_name) = input.value().attr("value") {
1420 if seen_tracks.contains(track_name) {
1421 continue;
1422 }
1423 seen_tracks.insert(track_name.to_string());
1424
1425 let playcount = self
1427 .find_playcount_for_track(document, track_name)
1428 .unwrap_or(0);
1429 let timestamp = self.find_timestamp_for_track(document, track_name);
1430
1431 let track = Track {
1432 name: track_name.to_string(),
1433 artist: artist.to_string(),
1434 playcount,
1435 timestamp,
1436 };
1437 tracks.push(track);
1438
1439 if tracks.len() >= 50 {
1440 break;
1441 }
1442 }
1443 }
1444 }
1445
1446 if tracks.len() < 10 {
1448 log::debug!("Found {} tracks so far, trying table parsing", tracks.len());
1449
1450 let table_selector = Selector::parse("table.chartlist").unwrap();
1451 let row_selector = Selector::parse("tbody tr").unwrap();
1452
1453 if let Some(table) = document.select(&table_selector).next() {
1454 for row in table.select(&row_selector) {
1455 if let Ok(mut track) = self.parse_track_row(&row) {
1456 if !seen_tracks.contains(&track.name) {
1457 track.artist = artist.to_string();
1458 seen_tracks.insert(track.name.clone());
1459 tracks.push(track);
1460 }
1461 }
1462 }
1463 }
1464 }
1465
1466 log::debug!("Successfully extracted {} unique tracks", tracks.len());
1467 Ok(tracks)
1468 }
1469
1470 pub fn parse_tracks_page(
1471 &self,
1472 document: &Html,
1473 page_number: u32,
1474 artist: &str,
1475 ) -> Result<TrackPage> {
1476 let tracks = self.extract_tracks_from_document(document, artist)?;
1477
1478 let (has_next_page, total_pages) = self.parse_pagination(document, page_number)?;
1480
1481 Ok(TrackPage {
1482 tracks,
1483 page_number,
1484 has_next_page,
1485 total_pages,
1486 })
1487 }
1488
1489 fn find_timestamp_for_track(&self, document: &Html, track_name: &str) -> Option<u64> {
1490 let form_selector = Selector::parse("form[data-edit-scrobble]").unwrap();
1492 let timestamp_selector = Selector::parse("input[name=\"timestamp\"]").unwrap();
1493
1494 for form in document.select(&form_selector) {
1495 let track_input_selector = Selector::parse("input[name=\"track_name\"]").unwrap();
1497 if let Some(track_input) = form.select(&track_input_selector).next() {
1498 if let Some(value) = track_input.value().attr("value") {
1499 if value == track_name {
1500 if let Some(timestamp_input) = form.select(×tamp_selector).next() {
1502 if let Some(timestamp_str) = timestamp_input.value().attr("value") {
1503 if let Ok(timestamp) = timestamp_str.parse::<u64>() {
1504 return Some(timestamp);
1505 }
1506 }
1507 }
1508 }
1509 }
1510 }
1511 }
1512 None
1513 }
1514
1515 fn find_playcount_for_track(&self, document: &Html, track_name: &str) -> Result<u32> {
1516 let count_selector = Selector::parse(".chartlist-count-bar-value").unwrap();
1518 let link_selector = Selector::parse("a[href*=\"/music/\"]").unwrap();
1519
1520 for link in document.select(&link_selector) {
1522 let link_text = link.text().collect::<String>().trim().to_string();
1523 if link_text == track_name {
1524 if let Some(row) = self.find_ancestor_row(link) {
1526 for count_elem in row.select(&count_selector) {
1528 let count_text = count_elem.text().collect::<String>();
1529 if let Some(number_part) = count_text.split_whitespace().next() {
1530 if let Ok(count) = number_part.parse::<u32>() {
1531 return Ok(count);
1532 }
1533 }
1534 }
1535 }
1536 }
1537 }
1538
1539 Ok(1)
1541 }
1542
1543 fn find_ancestor_row<'a>(
1544 &self,
1545 element: scraper::ElementRef<'a>,
1546 ) -> Option<scraper::ElementRef<'a>> {
1547 let mut current = element;
1548
1549 while let Some(parent) = current.parent() {
1551 if let Some(parent_elem) = scraper::ElementRef::wrap(parent) {
1552 if parent_elem.value().name() == "tr" {
1553 return Some(parent_elem);
1554 }
1555 current = parent_elem;
1556 } else {
1557 break;
1558 }
1559 }
1560 None
1561 }
1562
1563 fn parse_track_row(&self, row: &scraper::ElementRef) -> Result<Track> {
1564 let name_selector = Selector::parse(".chartlist-name a").unwrap();
1565
1566 let name = row
1567 .select(&name_selector)
1568 .next()
1569 .map(|el| el.text().collect::<String>().trim().to_string())
1570 .ok_or_else(|| LastFmError::Parse("Missing track name".to_string()))?;
1571
1572 let playcount_selector = Selector::parse(".chartlist-count-bar-value").unwrap();
1575 let mut playcount = 1; if let Some(element) = row.select(&playcount_selector).next() {
1578 let text = element.text().collect::<String>().trim().to_string();
1579 if let Some(number_part) = text.split_whitespace().next() {
1581 if let Ok(count) = number_part.parse::<u32>() {
1582 playcount = count;
1583 }
1584 }
1585 }
1586
1587 let artist = "".to_string(); Ok(Track {
1592 name,
1593 artist,
1594 playcount,
1595 timestamp: None, })
1597 }
1598
1599 pub fn parse_recent_scrobbles(&self, document: &Html) -> Result<Vec<Track>> {
1602 let mut tracks = Vec::new();
1603
1604 let table_selector = Selector::parse("table.chartlist").unwrap();
1606 let row_selector = Selector::parse("tbody tr").unwrap();
1607
1608 let tables: Vec<_> = document.select(&table_selector).collect();
1609 log::debug!("Found {} chartlist tables", tables.len());
1610
1611 for table in tables {
1612 for row in table.select(&row_selector) {
1613 if let Ok(track) = self.parse_recent_scrobble_row(&row) {
1614 tracks.push(track);
1615 }
1616 }
1617 }
1618
1619 if tracks.is_empty() {
1620 log::debug!("No tracks found in recent scrobbles");
1621 }
1622
1623 log::debug!("Parsed {} recent scrobbles", tracks.len());
1624 Ok(tracks)
1625 }
1626
1627 fn parse_recent_scrobble_row(&self, row: &scraper::ElementRef) -> Result<Track> {
1629 let name_selector = Selector::parse(".chartlist-name a").unwrap();
1631 let name = row
1632 .select(&name_selector)
1633 .next()
1634 .ok_or(LastFmError::Parse("Missing track name".to_string()))?
1635 .text()
1636 .collect::<String>()
1637 .trim()
1638 .to_string();
1639
1640 let artist_selector = Selector::parse(".chartlist-artist a").unwrap();
1642 let artist = row
1643 .select(&artist_selector)
1644 .next()
1645 .ok_or(LastFmError::Parse("Missing artist name".to_string()))?
1646 .text()
1647 .collect::<String>()
1648 .trim()
1649 .to_string();
1650
1651 let timestamp = self.extract_scrobble_timestamp(row);
1653
1654 let playcount = 1;
1656
1657 Ok(Track {
1658 name,
1659 artist,
1660 playcount,
1661 timestamp,
1662 })
1663 }
1664
1665 fn extract_scrobble_timestamp(&self, row: &scraper::ElementRef) -> Option<u64> {
1667 if let Some(timestamp_str) = row.value().attr("data-timestamp") {
1671 if let Ok(timestamp) = timestamp_str.parse::<u64>() {
1672 return Some(timestamp);
1673 }
1674 }
1675
1676 let timestamp_input_selector = Selector::parse("input[name='timestamp']").unwrap();
1678 if let Some(input) = row.select(×tamp_input_selector).next() {
1679 if let Some(value) = input.value().attr("value") {
1680 if let Ok(timestamp) = value.parse::<u64>() {
1681 return Some(timestamp);
1682 }
1683 }
1684 }
1685
1686 let edit_form_selector =
1688 Selector::parse("form[data-edit-scrobble] input[name='timestamp']").unwrap();
1689 if let Some(timestamp_input) = row.select(&edit_form_selector).next() {
1690 if let Some(value) = timestamp_input.value().attr("value") {
1691 if let Ok(timestamp) = value.parse::<u64>() {
1692 return Some(timestamp);
1693 }
1694 }
1695 }
1696
1697 let time_selector = Selector::parse("time").unwrap();
1699 if let Some(time_elem) = row.select(&time_selector).next() {
1700 if let Some(datetime) = time_elem.value().attr("datetime") {
1701 if let Ok(parsed_time) = chrono::DateTime::parse_from_rfc3339(datetime) {
1703 return Some(parsed_time.timestamp() as u64);
1704 }
1705 }
1706 }
1707
1708 None
1709 }
1710
1711 fn parse_pagination(&self, document: &Html, current_page: u32) -> Result<(bool, Option<u32>)> {
1712 let pagination_selector = Selector::parse(".pagination-list").unwrap();
1713
1714 if let Some(pagination) = document.select(&pagination_selector).next() {
1715 let next_selectors = [
1717 "a[aria-label=\"Next\"]",
1718 ".pagination-next a",
1719 "a:contains(\"Next\")",
1720 ".next a",
1721 ];
1722
1723 let mut has_next = false;
1724 for selector_str in &next_selectors {
1725 if let Ok(selector) = Selector::parse(selector_str) {
1726 if pagination.select(&selector).next().is_some() {
1727 has_next = true;
1728 break;
1729 }
1730 }
1731 }
1732
1733 if !has_next {
1735 let page_link_selector = Selector::parse("a").unwrap();
1736 for link in pagination.select(&page_link_selector) {
1737 if let Some(href) = link.value().attr("href") {
1738 if href.contains(&format!("page={}", current_page + 1)) {
1739 has_next = true;
1740 break;
1741 }
1742 }
1743 }
1744 }
1745
1746 let page_link_selector = Selector::parse("a").unwrap();
1748 let mut max_page = current_page;
1749
1750 for link in pagination.select(&page_link_selector) {
1751 if let Some(href) = link.value().attr("href") {
1752 if let Some(page_param) = href.split("page=").nth(1) {
1753 if let Some(page_num_str) = page_param.split('&').next() {
1754 if let Ok(page_num) = page_num_str.parse::<u32>() {
1755 max_page = max_page.max(page_num);
1756 }
1757 }
1758 }
1759 }
1760
1761 let link_text = link.text().collect::<String>().trim().to_string();
1763 if let Ok(page_num) = link_text.parse::<u32>() {
1764 max_page = max_page.max(page_num);
1765 }
1766 }
1767
1768 Ok((
1769 has_next,
1770 if max_page > current_page {
1771 Some(max_page)
1772 } else {
1773 None
1774 },
1775 ))
1776 } else {
1777 Ok((false, Some(1)))
1779 }
1780 }
1781
1782 fn extract_csrf_token(&self, document: &Html) -> Result<String> {
1783 let csrf_selector = Selector::parse("input[name=\"csrfmiddlewaretoken\"]").unwrap();
1784
1785 document
1786 .select(&csrf_selector)
1787 .next()
1788 .and_then(|input| input.value().attr("value"))
1789 .map(|token| token.to_string())
1790 .ok_or(LastFmError::CsrfNotFound)
1791 }
1792
1793 pub async fn get(&mut self, url: &str) -> Result<Response> {
1795 self.get_with_retry(url, 3).await
1796 }
1797
1798 async fn get_with_retry(&mut self, url: &str, max_retries: u32) -> Result<Response> {
1800 let mut retries = 0;
1801
1802 loop {
1803 match self.get_with_redirects(url, 0).await {
1804 Ok(mut response) => {
1805 let body = self.extract_response_body(url, &mut response).await?;
1807
1808 if response.status().is_success() && self.is_rate_limit_response(&body) {
1810 log::debug!("Response body contains rate limit patterns");
1811 if retries < max_retries {
1812 let delay = 60 + (retries as u64 * 30); log::info!("Rate limit detected in response body, retrying in {delay}s (attempt {}/{max_retries})", retries + 1);
1814 tokio::time::sleep(tokio::time::Duration::from_secs(delay)).await;
1815 retries += 1;
1816 continue;
1817 } else {
1818 return Err(crate::LastFmError::RateLimit { retry_after: 60 });
1819 }
1820 }
1821
1822 let mut new_response = http_types::Response::new(response.status());
1824 for (name, values) in response.iter() {
1825 for value in values {
1826 new_response.insert_header(name.clone(), value.clone());
1827 }
1828 }
1829 new_response.set_body(body);
1830
1831 return Ok(new_response);
1832 }
1833 Err(crate::LastFmError::RateLimit { retry_after }) => {
1834 if retries < max_retries {
1835 let delay = retry_after + (retries as u64 * 30); log::info!(
1837 "Rate limit detected, retrying in {delay}s (attempt {}/{max_retries})",
1838 retries + 1
1839 );
1840 tokio::time::sleep(tokio::time::Duration::from_secs(delay)).await;
1841 retries += 1;
1842 } else {
1843 return Err(crate::LastFmError::RateLimit { retry_after });
1844 }
1845 }
1846 Err(e) => return Err(e),
1847 }
1848 }
1849 }
1850
1851 async fn get_with_redirects(&mut self, url: &str, redirect_count: u32) -> Result<Response> {
1852 if redirect_count > 5 {
1853 return Err(LastFmError::Http("Too many redirects".to_string()));
1854 }
1855
1856 let mut request = Request::new(Method::Get, url.parse::<Url>().unwrap());
1857 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");
1858
1859 if !self.session_cookies.is_empty() {
1861 let cookie_header = self.session_cookies.join("; ");
1862 request.insert_header("Cookie", &cookie_header);
1863 } else if url.contains("page=") {
1864 log::debug!("No cookies available for paginated request!");
1865 }
1866
1867 if url.contains("ajax=true") {
1869 request.insert_header("Accept", "*/*");
1871 request.insert_header("X-Requested-With", "XMLHttpRequest");
1872 } else {
1873 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");
1875 }
1876 request.insert_header("Accept-Language", "en-US,en;q=0.9");
1877 request.insert_header("Accept-Encoding", "gzip, deflate, br");
1878 request.insert_header("DNT", "1");
1879 request.insert_header("Connection", "keep-alive");
1880 request.insert_header("Upgrade-Insecure-Requests", "1");
1881
1882 if url.contains("page=") {
1884 let base_url = url.split('?').next().unwrap_or(url);
1885 request.insert_header("Referer", base_url);
1886 }
1887
1888 let response = self
1889 .client
1890 .send(request)
1891 .await
1892 .map_err(|e| LastFmError::Http(e.to_string()))?;
1893
1894 self.extract_cookies(&response);
1896
1897 if response.status() == 302 || response.status() == 301 {
1899 if let Some(location) = response.header("location") {
1900 if let Some(redirect_url) = location.get(0) {
1901 let redirect_url_str = redirect_url.as_str();
1902 if url.contains("page=") {
1903 log::debug!("Following redirect from {url} to {redirect_url_str}");
1904
1905 if redirect_url_str.contains("/login") {
1907 log::debug!("Redirect to login page - authentication failed for paginated request");
1908 return Err(LastFmError::Auth(
1909 "Session expired or invalid for paginated request".to_string(),
1910 ));
1911 }
1912 }
1913
1914 let full_redirect_url = if redirect_url_str.starts_with('/') {
1916 format!("{}{redirect_url_str}", self.base_url)
1917 } else if redirect_url_str.starts_with("http") {
1918 redirect_url_str.to_string()
1919 } else {
1920 let base_url = url
1922 .rsplit('/')
1923 .skip(1)
1924 .collect::<Vec<_>>()
1925 .into_iter()
1926 .rev()
1927 .collect::<Vec<_>>()
1928 .join("/");
1929 format!("{base_url}/{redirect_url_str}")
1930 };
1931
1932 return Box::pin(
1934 self.get_with_redirects(&full_redirect_url, redirect_count + 1),
1935 )
1936 .await;
1937 }
1938 }
1939 }
1940
1941 if response.status() == 429 {
1943 let retry_after = response
1944 .header("retry-after")
1945 .and_then(|h| h.get(0))
1946 .and_then(|v| v.as_str().parse::<u64>().ok())
1947 .unwrap_or(60);
1948 return Err(LastFmError::RateLimit { retry_after });
1949 }
1950
1951 if response.status() == 403 {
1953 log::debug!("Got 403 response, checking if it's a rate limit");
1954 if !self.session_cookies.is_empty() {
1956 log::debug!("403 on authenticated request - likely rate limit");
1957 return Err(LastFmError::RateLimit { retry_after: 60 });
1958 }
1959 }
1960
1961 Ok(response)
1962 }
1963
1964 fn is_rate_limit_response(&self, response_body: &str) -> bool {
1966 let body_lower = response_body.to_lowercase();
1967
1968 for pattern in &self.rate_limit_patterns {
1970 if body_lower.contains(&pattern.to_lowercase()) {
1971 return true;
1972 }
1973 }
1974
1975 false
1976 }
1977
1978 fn extract_cookies(&mut self, response: &Response) {
1979 if let Some(cookie_headers) = response.header("set-cookie") {
1981 let mut new_cookies = 0;
1982 for cookie_header in cookie_headers {
1983 let cookie_str = cookie_header.as_str();
1984 if let Some(cookie_value) = cookie_str.split(';').next() {
1986 let cookie_name = cookie_value.split('=').next().unwrap_or("");
1987
1988 self.session_cookies
1990 .retain(|existing| !existing.starts_with(&format!("{cookie_name}=")));
1991
1992 self.session_cookies.push(cookie_value.to_string());
1993 new_cookies += 1;
1994 }
1995 }
1996 if new_cookies > 0 {
1997 log::trace!(
1998 "Extracted {} new cookies, total: {}",
1999 new_cookies,
2000 self.session_cookies.len()
2001 );
2002 log::trace!("Updated cookies: {:?}", &self.session_cookies);
2003
2004 for cookie in &self.session_cookies {
2006 if cookie.starts_with("sessionid=") {
2007 log::trace!("Current sessionid: {}", &cookie[10..50.min(cookie.len())]);
2008 break;
2009 }
2010 }
2011 }
2012 }
2013 }
2014
2015 async fn extract_response_body(&self, url: &str, response: &mut Response) -> Result<String> {
2017 let body = response
2018 .body_string()
2019 .await
2020 .map_err(|e| LastFmError::Http(e.to_string()))?;
2021
2022 if self.debug_save_responses {
2023 self.save_debug_response(url, response.status().into(), &body);
2024 }
2025
2026 Ok(body)
2027 }
2028
2029 fn save_debug_response(&self, url: &str, status_code: u16, body: &str) {
2031 if let Err(e) = self.try_save_debug_response(url, status_code, body) {
2032 log::warn!("Failed to save debug response: {e}");
2033 }
2034 }
2035
2036 fn try_save_debug_response(&self, url: &str, status_code: u16, body: &str) -> Result<()> {
2038 let debug_dir = Path::new("debug_responses");
2040 if !debug_dir.exists() {
2041 fs::create_dir_all(debug_dir)
2042 .map_err(|e| LastFmError::Http(format!("Failed to create debug directory: {e}")))?;
2043 }
2044
2045 let url_path = if url.starts_with(&self.base_url) {
2047 &url[self.base_url.len()..]
2048 } else {
2049 url
2050 };
2051
2052 let now = chrono::Utc::now();
2054 let timestamp = now.format("%Y%m%d_%H%M%S_%3f");
2055 let safe_path = url_path.replace(['/', '?', '&', '=', '%', '+'], "_");
2056
2057 let filename = format!("{timestamp}_{safe_path}_status{status_code}.html");
2058 let file_path = debug_dir.join(filename);
2059
2060 fs::write(&file_path, body)
2062 .map_err(|e| LastFmError::Http(format!("Failed to write debug file: {e}")))?;
2063
2064 log::debug!(
2065 "Saved HTTP response to {file_path:?} (status: {status_code}, url: {url_path})"
2066 );
2067
2068 Ok(())
2069 }
2070
2071 pub async fn get_artist_albums_page(&mut self, artist: &str, page: u32) -> Result<AlbumPage> {
2072 let url = format!(
2074 "{}/user/{}/library/music/{}/+albums?page={}&ajax=true",
2075 self.base_url,
2076 self.username,
2077 artist.replace(" ", "+"),
2078 page
2079 );
2080
2081 log::debug!("Fetching albums page {page} for artist: {artist}");
2082 let mut response = self.get(&url).await?;
2083 let content = response
2084 .body_string()
2085 .await
2086 .map_err(|e| LastFmError::Http(e.to_string()))?;
2087
2088 log::debug!(
2089 "AJAX response: {} status, {} chars",
2090 response.status(),
2091 content.len()
2092 );
2093
2094 if content.trim_start().starts_with("{") || content.trim_start().starts_with("[") {
2096 log::debug!("Parsing JSON response from AJAX endpoint");
2097 self.parse_json_albums_page(&content, page, artist)
2098 } else {
2099 log::debug!("Parsing HTML response from AJAX endpoint");
2100 let document = Html::parse_document(&content);
2101 self.parse_albums_page(&document, page, artist)
2102 }
2103 }
2104
2105 fn parse_json_albums_page(
2106 &self,
2107 _json_content: &str,
2108 page_number: u32,
2109 _artist: &str,
2110 ) -> Result<AlbumPage> {
2111 log::debug!("JSON parsing not implemented, returning empty page");
2113 Ok(AlbumPage {
2114 albums: Vec::new(),
2115 page_number,
2116 has_next_page: false,
2117 total_pages: Some(1),
2118 })
2119 }
2120
2121 fn parse_albums_page(
2122 &self,
2123 document: &Html,
2124 page_number: u32,
2125 artist: &str,
2126 ) -> Result<AlbumPage> {
2127 let mut albums = Vec::new();
2128
2129 let album_selector = Selector::parse("[data-album-name]").unwrap();
2131 let album_elements: Vec<_> = document.select(&album_selector).collect();
2132
2133 if !album_elements.is_empty() {
2134 log::debug!(
2135 "Found {} album elements with data-album-name",
2136 album_elements.len()
2137 );
2138
2139 let mut seen_albums = std::collections::HashSet::new();
2141
2142 for element in album_elements {
2143 if let Some(album_name) = element.value().attr("data-album-name") {
2144 if seen_albums.contains(album_name) {
2146 continue;
2147 }
2148 seen_albums.insert(album_name.to_string());
2149
2150 let playcount = self.find_playcount_for_album(document, album_name)?;
2152
2153 let timestamp = self.find_timestamp_for_album(document, album_name);
2155
2156 let album = Album {
2157 name: album_name.to_string(),
2158 artist: artist.to_string(),
2159 playcount,
2160 timestamp,
2161 };
2162 albums.push(album);
2163
2164 if albums.len() >= 50 {
2165 break; }
2167 }
2168 }
2169
2170 log::debug!("Successfully parsed {} unique albums", albums.len());
2171 } else {
2172 log::debug!("No data-album-name elements found, trying table parsing");
2174
2175 let table_selector = Selector::parse("table.chartlist").unwrap();
2176 let row_selector = Selector::parse("tbody tr").unwrap();
2177
2178 if let Some(table) = document.select(&table_selector).next() {
2179 for row in table.select(&row_selector) {
2180 if let Ok(mut album) = self.parse_album_row(&row) {
2181 album.artist = artist.to_string();
2182 albums.push(album);
2183 }
2184 }
2185 } else {
2186 log::debug!("No table.chartlist found either");
2187 }
2188 }
2189
2190 let (has_next_page, total_pages) = self.parse_pagination(document, page_number)?;
2192
2193 Ok(AlbumPage {
2194 albums,
2195 page_number,
2196 has_next_page,
2197 total_pages,
2198 })
2199 }
2200
2201 fn find_timestamp_for_album(&self, document: &Html, album_name: &str) -> Option<u64> {
2202 let form_selector = Selector::parse("form[data-edit-scrobble]").unwrap();
2204 let timestamp_selector = Selector::parse("input[name=\"timestamp\"]").unwrap();
2205
2206 for form in document.select(&form_selector) {
2207 let album_input_selector = Selector::parse("input[name=\"album_name\"]").unwrap();
2209 if let Some(album_input) = form.select(&album_input_selector).next() {
2210 if let Some(value) = album_input.value().attr("value") {
2211 if value == album_name {
2212 if let Some(timestamp_input) = form.select(×tamp_selector).next() {
2214 if let Some(timestamp_str) = timestamp_input.value().attr("value") {
2215 if let Ok(timestamp) = timestamp_str.parse::<u64>() {
2216 return Some(timestamp);
2217 }
2218 }
2219 }
2220 }
2221 }
2222 }
2223 }
2224 None
2225 }
2226
2227 fn find_playcount_for_album(&self, document: &Html, album_name: &str) -> Result<u32> {
2228 let count_selector = Selector::parse(".chartlist-count-bar-value").unwrap();
2230 let link_selector = Selector::parse("a[href*=\"/music/\"]").unwrap();
2231
2232 for link in document.select(&link_selector) {
2234 let link_text = link.text().collect::<String>().trim().to_string();
2235 if link_text == album_name {
2236 if let Some(row) = self.find_ancestor_row(link) {
2238 for count_elem in row.select(&count_selector) {
2240 let count_text = count_elem.text().collect::<String>();
2241 if let Some(number_part) = count_text.split_whitespace().next() {
2242 if let Ok(count) = number_part.parse::<u32>() {
2243 return Ok(count);
2244 }
2245 }
2246 }
2247 }
2248 }
2249 }
2250
2251 Ok(1)
2253 }
2254
2255 fn parse_album_row(&self, row: &scraper::ElementRef) -> Result<Album> {
2256 let name_selector = Selector::parse(".chartlist-name a").unwrap();
2257
2258 let name = row
2259 .select(&name_selector)
2260 .next()
2261 .map(|el| el.text().collect::<String>().trim().to_string())
2262 .ok_or_else(|| LastFmError::Parse("Missing album name".to_string()))?;
2263
2264 let playcount_selector = Selector::parse(".chartlist-count-bar-value").unwrap();
2266 let mut playcount = 1; if let Some(element) = row.select(&playcount_selector).next() {
2269 let text = element.text().collect::<String>().trim().to_string();
2270 if let Some(number_part) = text.split_whitespace().next() {
2271 if let Ok(count) = number_part.parse::<u32>() {
2272 playcount = count;
2273 }
2274 }
2275 }
2276
2277 let artist = "".to_string(); Ok(Album {
2280 name,
2281 artist,
2282 playcount,
2283 timestamp: None, })
2285 }
2286}