1use crate::edit::{ExactScrobbleEdit, SingleEditResponse};
2use crate::iterator::AsyncPaginatedIterator;
3use crate::parsing::LastFmParser;
4use crate::r#trait::LastFmEditClient;
5use crate::session::LastFmEditSession;
6use crate::{AlbumPage, EditResponse, LastFmError, Result, ScrobbleEdit, Track, TrackPage};
7use async_trait::async_trait;
8use http_client::{HttpClient, Request, Response};
9use http_types::{Method, Url};
10use scraper::{Html, Selector};
11use std::collections::HashMap;
12use std::fs;
13use std::path::Path;
14use std::sync::{Arc, Mutex};
15
16#[derive(Clone)]
22pub struct LastFmEditClientImpl {
23 client: Arc<dyn HttpClient + Send + Sync>,
24 session: Arc<Mutex<LastFmEditSession>>,
25 rate_limit_patterns: Vec<String>,
26 debug_save_responses: bool,
27 parser: LastFmParser,
28}
29
30impl LastFmEditClientImpl {
31 pub fn new(client: Box<dyn HttpClient + Send + Sync>) -> Self {
41 Self::with_base_url(client, "https://www.last.fm".to_string())
42 }
43
44 pub fn with_base_url(client: Box<dyn HttpClient + Send + Sync>, base_url: String) -> Self {
56 Self::with_rate_limit_patterns(
57 client,
58 base_url,
59 vec![
60 "you've tried to log in too many times".to_string(),
61 "you're requesting too many pages".to_string(),
62 "slow down".to_string(),
63 "too fast".to_string(),
64 "rate limit".to_string(),
65 "throttled".to_string(),
66 "temporarily blocked".to_string(),
67 "temporarily restricted".to_string(),
68 "captcha".to_string(),
69 "verify you're human".to_string(),
70 "prove you're not a robot".to_string(),
71 "security check".to_string(),
72 "service temporarily unavailable".to_string(),
73 "quota exceeded".to_string(),
74 "limit exceeded".to_string(),
75 "daily limit".to_string(),
76 ],
77 )
78 }
79
80 pub fn with_rate_limit_patterns(
88 client: Box<dyn HttpClient + Send + Sync>,
89 base_url: String,
90 rate_limit_patterns: Vec<String>,
91 ) -> Self {
92 Self {
93 client: Arc::from(client),
94 session: Arc::new(Mutex::new(LastFmEditSession::new(
95 String::new(),
96 Vec::new(),
97 None,
98 base_url,
99 ))),
100 rate_limit_patterns,
101 debug_save_responses: std::env::var("LASTFM_DEBUG_SAVE_RESPONSES").is_ok(),
102 parser: LastFmParser::new(),
103 }
104 }
105
106 pub async fn login_with_credentials(
121 client: Box<dyn HttpClient + Send + Sync>,
122 username: &str,
123 password: &str,
124 ) -> Result<Self> {
125 let new_client = Self::new(client);
126 new_client.login(username, password).await?;
127 Ok(new_client)
128 }
129
130 pub fn from_session(
144 client: Box<dyn HttpClient + Send + Sync>,
145 session: LastFmEditSession,
146 ) -> Self {
147 Self {
148 client: Arc::from(client),
149 session: Arc::new(Mutex::new(session)),
150 rate_limit_patterns: vec![
151 "you've tried to log in too many times".to_string(),
152 "you're requesting too many pages".to_string(),
153 "slow down".to_string(),
154 "too fast".to_string(),
155 "rate limit".to_string(),
156 "throttled".to_string(),
157 "temporarily blocked".to_string(),
158 "temporarily restricted".to_string(),
159 "captcha".to_string(),
160 "verify you're human".to_string(),
161 "prove you're not a robot".to_string(),
162 "security check".to_string(),
163 "service temporarily unavailable".to_string(),
164 "quota exceeded".to_string(),
165 "limit exceeded".to_string(),
166 "daily limit".to_string(),
167 ],
168 debug_save_responses: std::env::var("LASTFM_DEBUG_SAVE_RESPONSES").is_ok(),
169 parser: LastFmParser::new(),
170 }
171 }
172
173 pub fn get_session(&self) -> LastFmEditSession {
183 self.session.lock().unwrap().clone()
184 }
185
186 pub fn restore_session(&self, session: LastFmEditSession) {
195 *self.session.lock().unwrap() = session;
196 }
197
198 pub async fn login(&self, username: &str, password: &str) -> Result<()> {
216 let login_url = {
218 let session = self.session.lock().unwrap();
219 format!("{}/login", session.base_url)
220 };
221 let mut response = self.get(&login_url).await?;
222
223 self.extract_cookies(&response);
225
226 let html = response
227 .body_string()
228 .await
229 .map_err(|e| LastFmError::Http(e.to_string()))?;
230
231 let (csrf_token, next_field) = self.extract_login_form_data(&html)?;
233
234 let mut form_data = HashMap::new();
236 form_data.insert("csrfmiddlewaretoken", csrf_token.as_str());
237 form_data.insert("username_or_email", username);
238 form_data.insert("password", password);
239
240 if let Some(ref next_value) = next_field {
242 form_data.insert("next", next_value);
243 }
244
245 let mut request = Request::new(Method::Post, login_url.parse::<Url>().unwrap());
246 let _ = request.insert_header("Referer", &login_url);
247 {
248 let session = self.session.lock().unwrap();
249 let _ = request.insert_header("Origin", &session.base_url);
250 }
251 let _ = request.insert_header("Content-Type", "application/x-www-form-urlencoded");
252 let _ = request.insert_header(
253 "User-Agent",
254 "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36"
255 );
256 let _ = request.insert_header(
257 "Accept",
258 "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"
259 );
260 let _ = request.insert_header("Accept-Language", "en-US,en;q=0.9");
261 let _ = request.insert_header("Accept-Encoding", "gzip, deflate, br");
262 let _ = request.insert_header("DNT", "1");
263 let _ = request.insert_header("Connection", "keep-alive");
264 let _ = request.insert_header("Upgrade-Insecure-Requests", "1");
265 let _ = request.insert_header(
266 "sec-ch-ua",
267 "\"Not)A;Brand\";v=\"8\", \"Chromium\";v=\"138\", \"Google Chrome\";v=\"138\"",
268 );
269 let _ = request.insert_header("sec-ch-ua-mobile", "?0");
270 let _ = request.insert_header("sec-ch-ua-platform", "\"Linux\"");
271 let _ = request.insert_header("Sec-Fetch-Dest", "document");
272 let _ = request.insert_header("Sec-Fetch-Mode", "navigate");
273 let _ = request.insert_header("Sec-Fetch-Site", "same-origin");
274 let _ = request.insert_header("Sec-Fetch-User", "?1");
275
276 {
278 let session = self.session.lock().unwrap();
279 if !session.cookies.is_empty() {
280 let cookie_header = session.cookies.join("; ");
281 let _ = request.insert_header("Cookie", &cookie_header);
282 }
283 }
284
285 let form_string: String = form_data
287 .iter()
288 .map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v)))
289 .collect::<Vec<_>>()
290 .join("&");
291
292 request.set_body(form_string);
293
294 let mut response = self
295 .client
296 .send(request)
297 .await
298 .map_err(|e| LastFmError::Http(e.to_string()))?;
299
300 self.extract_cookies(&response);
302
303 log::debug!("Login response status: {}", response.status());
304
305 if response.status() == 403 {
307 let response_html = response
309 .body_string()
310 .await
311 .map_err(|e| LastFmError::Http(e.to_string()))?;
312
313 if self.is_rate_limit_response(&response_html) {
315 log::debug!("403 response appears to be rate limiting");
316 return Err(LastFmError::RateLimit { retry_after: 60 });
317 }
318 log::debug!("403 response appears to be authentication failure");
319
320 let login_error = self.parse_login_error(&response_html);
322 return Err(LastFmError::Auth(login_error));
323 }
324
325 let has_real_session = {
327 let session = self.session.lock().unwrap();
328 session
329 .cookies
330 .iter()
331 .any(|cookie| cookie.starts_with("sessionid=.") && cookie.len() > 50)
332 };
333
334 if has_real_session && (response.status() == 302 || response.status() == 200) {
335 {
337 let mut session = self.session.lock().unwrap();
338 session.username = username.to_string();
339 session.csrf_token = Some(csrf_token);
340 }
341 log::debug!("Login successful - authenticated session established");
342 return Ok(());
343 }
344
345 let response_html = response
347 .body_string()
348 .await
349 .map_err(|e| LastFmError::Http(e.to_string()))?;
350
351 let has_login_form = self.check_for_login_form(&response_html);
353
354 if !has_login_form && response.status() == 200 {
355 {
356 let mut session = self.session.lock().unwrap();
357 session.username = username.to_string();
358 session.csrf_token = Some(csrf_token);
359 }
360 Ok(())
361 } else {
362 let error_msg = self.parse_login_error(&response_html);
364 Err(LastFmError::Auth(error_msg))
365 }
366 }
367
368 pub fn username(&self) -> String {
372 self.session.lock().unwrap().username.clone()
373 }
374
375 pub fn is_logged_in(&self) -> bool {
379 self.session.lock().unwrap().is_valid()
380 }
381
382 pub async fn get_recent_scrobbles(&self, page: u32) -> Result<Vec<Track>> {
385 let url = {
386 let session = self.session.lock().unwrap();
387 format!(
388 "{}/user/{}/library?page={}",
389 session.base_url, session.username, page
390 )
391 };
392
393 log::debug!("Fetching recent scrobbles page {page}");
394 let mut response = self.get(&url).await?;
395 let content = response
396 .body_string()
397 .await
398 .map_err(|e| LastFmError::Http(e.to_string()))?;
399
400 log::debug!(
401 "Recent scrobbles response: {} status, {} chars",
402 response.status(),
403 content.len()
404 );
405
406 let document = Html::parse_document(&content);
407 self.parser.parse_recent_scrobbles(&document)
408 }
409
410 pub async fn get_recent_tracks_page(&self, page: u32) -> Result<TrackPage> {
412 let tracks = self.get_recent_scrobbles(page).await?;
413
414 let has_next_page = !tracks.is_empty(); Ok(TrackPage {
419 tracks,
420 page_number: page,
421 has_next_page,
422 total_pages: None, })
424 }
425
426 pub async fn find_recent_scrobble_for_track(
429 &self,
430 track_name: &str,
431 artist_name: &str,
432 max_pages: u32,
433 ) -> Result<Option<Track>> {
434 log::debug!("Searching for recent scrobble: '{track_name}' by '{artist_name}'");
435
436 for page in 1..=max_pages {
437 let scrobbles = self.get_recent_scrobbles(page).await?;
438
439 for scrobble in scrobbles {
440 if scrobble.name == track_name && scrobble.artist == artist_name {
441 log::debug!(
442 "Found recent scrobble: '{}' with timestamp {:?}",
443 scrobble.name,
444 scrobble.timestamp
445 );
446 return Ok(Some(scrobble));
447 }
448 }
449 }
450
451 log::debug!(
452 "No recent scrobble found for '{track_name}' by '{artist_name}' in {max_pages} pages"
453 );
454 Ok(None)
455 }
456
457 pub async fn edit_scrobble(&self, edit: &ScrobbleEdit) -> Result<EditResponse> {
458 let discovered_edits = self.discover_scrobble_edit_variations(edit).await?;
460
461 if discovered_edits.is_empty() {
462 let context = match (&edit.track_name_original, &edit.album_name_original) {
463 (Some(track_name), _) => {
464 format!("track '{}' by '{}'", track_name, edit.artist_name_original)
465 }
466 (None, Some(album_name)) => {
467 format!("album '{}' by '{}'", album_name, edit.artist_name_original)
468 }
469 (None, None) => format!("artist '{}'", edit.artist_name_original),
470 };
471 return Err(LastFmError::Parse(format!(
472 "No scrobbles found for {context}. Make sure the names are correct and that you have scrobbled recently."
473 )));
474 }
475
476 log::info!(
477 "Discovered {} scrobble instances to edit",
478 discovered_edits.len()
479 );
480
481 let mut all_results = Vec::new();
482
483 for (index, discovered_edit) in discovered_edits.iter().enumerate() {
485 log::debug!(
486 "Processing scrobble {}/{}: '{}' from '{}'",
487 index + 1,
488 discovered_edits.len(),
489 discovered_edit.track_name_original,
490 discovered_edit.album_name_original
491 );
492
493 let mut modified_exact_edit = discovered_edit.clone();
495
496 if let Some(new_track_name) = &edit.track_name {
498 modified_exact_edit.track_name = new_track_name.clone();
499 }
500 if let Some(new_album_name) = &edit.album_name {
501 modified_exact_edit.album_name = new_album_name.clone();
502 }
503 modified_exact_edit.artist_name = edit.artist_name.clone();
504 if let Some(new_album_artist_name) = &edit.album_artist_name {
505 modified_exact_edit.album_artist_name = new_album_artist_name.clone();
506 }
507 modified_exact_edit.edit_all = edit.edit_all;
508
509 let album_info = format!(
510 "{} by {}",
511 modified_exact_edit.album_name_original,
512 modified_exact_edit.album_artist_name_original
513 );
514
515 let single_response = self.edit_scrobble_single(&modified_exact_edit, 3).await?;
516 let success = single_response.success();
517 let message = single_response.message();
518
519 all_results.push(SingleEditResponse {
520 success,
521 message,
522 album_info: Some(album_info),
523 exact_scrobble_edit: modified_exact_edit.clone(),
524 });
525
526 if index < discovered_edits.len() - 1 {
528 tokio::time::sleep(std::time::Duration::from_millis(1000)).await;
529 }
530 }
531
532 Ok(EditResponse::from_results(all_results))
533 }
534
535 pub async fn edit_scrobble_single(
546 &self,
547 exact_edit: &ExactScrobbleEdit,
548 max_retries: u32,
549 ) -> Result<EditResponse> {
550 let mut retries = 0;
551
552 loop {
553 match self.edit_scrobble_impl(exact_edit).await {
554 Ok(success) => {
555 return Ok(EditResponse::single(
556 success,
557 None,
558 None,
559 exact_edit.clone(),
560 ));
561 }
562 Err(LastFmError::RateLimit { retry_after }) => {
563 if retries >= max_retries {
564 log::warn!("Max retries ({max_retries}) exceeded for edit operation");
565 return Ok(EditResponse::single(
566 false,
567 Some(format!("Rate limit exceeded after {max_retries} retries")),
568 None,
569 exact_edit.clone(),
570 ));
571 }
572
573 let delay = std::cmp::min(retry_after, 2_u64.pow(retries + 1) * 5);
574 log::info!(
575 "Edit rate limited. Waiting {} seconds before retry {} of {}",
576 delay,
577 retries + 1,
578 max_retries
579 );
580 retries += 1;
582 }
583 Err(other_error) => {
584 return Ok(EditResponse::single(
585 false,
586 Some(other_error.to_string()),
587 None,
588 exact_edit.clone(),
589 ));
590 }
591 }
592 }
593 }
594
595 async fn edit_scrobble_impl(&self, exact_edit: &ExactScrobbleEdit) -> Result<bool> {
596 if !self.is_logged_in() {
597 return Err(LastFmError::Auth(
598 "Must be logged in to edit scrobbles".to_string(),
599 ));
600 }
601
602 let edit_url = {
603 let session = self.session.lock().unwrap();
604 format!(
605 "{}/user/{}/library/edit?edited-variation=library-track-scrobble",
606 session.base_url, session.username
607 )
608 };
609
610 log::debug!("Getting fresh CSRF token for edit");
611
612 let form_html = self.get_edit_form_html(&edit_url).await?;
614
615 let form_document = Html::parse_document(&form_html);
617 let fresh_csrf_token = self.extract_csrf_token(&form_document)?;
618
619 log::debug!("Submitting edit with fresh token");
620
621 let mut form_data = HashMap::new();
622
623 form_data.insert("csrfmiddlewaretoken", fresh_csrf_token.as_str());
625
626 form_data.insert("track_name_original", &exact_edit.track_name_original);
628 form_data.insert("track_name", &exact_edit.track_name);
629 form_data.insert("artist_name_original", &exact_edit.artist_name_original);
630 form_data.insert("artist_name", &exact_edit.artist_name);
631 form_data.insert("album_name_original", &exact_edit.album_name_original);
632 form_data.insert("album_name", &exact_edit.album_name);
633 form_data.insert(
634 "album_artist_name_original",
635 &exact_edit.album_artist_name_original,
636 );
637 form_data.insert("album_artist_name", &exact_edit.album_artist_name);
638
639 let timestamp_str = exact_edit.timestamp.to_string();
641 form_data.insert("timestamp", ×tamp_str);
642
643 if exact_edit.edit_all {
645 form_data.insert("edit_all", "1");
646 }
647 form_data.insert("submit", "edit-scrobble");
648 form_data.insert("ajax", "1");
649
650 log::debug!(
651 "Editing scrobble: '{}' -> '{}'",
652 exact_edit.track_name_original,
653 exact_edit.track_name
654 );
655 {
656 let session = self.session.lock().unwrap();
657 log::trace!("Session cookies count: {}", session.cookies.len());
658 }
659
660 let mut request = Request::new(Method::Post, edit_url.parse::<Url>().unwrap());
661
662 let _ = request.insert_header("Accept", "*/*");
664 let _ = request.insert_header("Accept-Language", "en-US,en;q=0.9");
665 let _ = request.insert_header(
666 "Content-Type",
667 "application/x-www-form-urlencoded;charset=UTF-8",
668 );
669 let _ = request.insert_header("Priority", "u=1, i");
670 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");
671 let _ = request.insert_header("X-Requested-With", "XMLHttpRequest");
672 let _ = request.insert_header("Sec-Fetch-Dest", "empty");
673 let _ = request.insert_header("Sec-Fetch-Mode", "cors");
674 let _ = request.insert_header("Sec-Fetch-Site", "same-origin");
675 let _ = request.insert_header(
676 "sec-ch-ua",
677 "\"Not)A;Brand\";v=\"8\", \"Chromium\";v=\"138\", \"Google Chrome\";v=\"138\"",
678 );
679 let _ = request.insert_header("sec-ch-ua-mobile", "?0");
680 let _ = request.insert_header("sec-ch-ua-platform", "\"Linux\"");
681
682 {
684 let session = self.session.lock().unwrap();
685 if !session.cookies.is_empty() {
686 let cookie_header = session.cookies.join("; ");
687 let _ = request.insert_header("Cookie", &cookie_header);
688 }
689 }
690
691 {
693 let session = self.session.lock().unwrap();
694 let _ = request.insert_header(
695 "Referer",
696 format!("{}/user/{}/library", session.base_url, session.username),
697 );
698 }
699
700 let form_string: String = form_data
702 .iter()
703 .map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v)))
704 .collect::<Vec<_>>()
705 .join("&");
706
707 request.set_body(form_string);
708
709 let mut response = self
710 .client
711 .send(request)
712 .await
713 .map_err(|e| LastFmError::Http(e.to_string()))?;
714
715 log::debug!("Edit response status: {}", response.status());
716
717 let response_text = response
718 .body_string()
719 .await
720 .map_err(|e| LastFmError::Http(e.to_string()))?;
721
722 let document = Html::parse_document(&response_text);
724
725 let success_selector = Selector::parse(".alert-success").unwrap();
727 let error_selector = Selector::parse(".alert-danger, .alert-error, .error").unwrap();
728
729 let has_success_alert = document.select(&success_selector).next().is_some();
730 let has_error_alert = document.select(&error_selector).next().is_some();
731
732 let mut actual_track_name = None;
735 let mut actual_album_name = None;
736
737 let track_name_selector = Selector::parse("td.chartlist-name a").unwrap();
739 let album_name_selector = Selector::parse("td.chartlist-album a").unwrap();
740
741 if let Some(track_element) = document.select(&track_name_selector).next() {
742 actual_track_name = Some(track_element.text().collect::<String>().trim().to_string());
743 }
744
745 if let Some(album_element) = document.select(&album_name_selector).next() {
746 actual_album_name = Some(album_element.text().collect::<String>().trim().to_string());
747 }
748
749 if actual_track_name.is_none() || actual_album_name.is_none() {
751 let track_pattern = regex::Regex::new(r#"href="/music/[^"]+/_/([^"]+)""#).unwrap();
754 if let Some(captures) = track_pattern.captures(&response_text) {
755 if let Some(track_match) = captures.get(1) {
756 let raw_track = track_match.as_str();
757 let decoded_track = urlencoding::decode(raw_track)
759 .unwrap_or_else(|_| raw_track.into())
760 .replace("+", " ");
761 actual_track_name = Some(decoded_track);
762 }
763 }
764
765 let album_pattern =
768 regex::Regex::new(r#"href="/music/[^"]+/([^"/_]+)"[^>]*>[^<]*</a>"#).unwrap();
769 if let Some(captures) = album_pattern.captures(&response_text) {
770 if let Some(album_match) = captures.get(1) {
771 let raw_album = album_match.as_str();
772 let decoded_album = urlencoding::decode(raw_album)
774 .unwrap_or_else(|_| raw_album.into())
775 .replace("+", " ");
776 actual_album_name = Some(decoded_album);
777 }
778 }
779 }
780
781 log::debug!(
782 "Response analysis: success_alert={}, error_alert={}, track='{}', album='{}'",
783 has_success_alert,
784 has_error_alert,
785 actual_track_name.as_deref().unwrap_or("not found"),
786 actual_album_name.as_deref().unwrap_or("not found")
787 );
788
789 let final_success = response.status().is_success() && has_success_alert && !has_error_alert;
791
792 let _message = if has_error_alert {
794 if let Some(error_element) = document.select(&error_selector).next() {
796 Some(format!(
797 "Edit failed: {}",
798 error_element.text().collect::<String>().trim()
799 ))
800 } else {
801 Some("Edit failed with unknown error".to_string())
802 }
803 } else if final_success {
804 Some(format!(
805 "Edit successful - Track: '{}', Album: '{}'",
806 actual_track_name.as_deref().unwrap_or("unknown"),
807 actual_album_name.as_deref().unwrap_or("unknown")
808 ))
809 } else {
810 Some(format!("Edit failed with status: {}", response.status()))
811 };
812
813 Ok(final_success)
814 }
815
816 async fn get_edit_form_html(&self, edit_url: &str) -> Result<String> {
819 let mut form_response = self.get(edit_url).await?;
820 let form_html = form_response
821 .body_string()
822 .await
823 .map_err(|e| LastFmError::Http(e.to_string()))?;
824
825 log::debug!("Edit form response status: {}", form_response.status());
826 Ok(form_html)
827 }
828
829 async fn load_edit_form_values_internal(
832 &self,
833 track_name: &str,
834 artist_name: &str,
835 ) -> Result<Vec<ExactScrobbleEdit>> {
836 log::debug!("Loading edit form values for '{track_name}' by '{artist_name}'");
837
838 let base_track_url = {
842 let session = self.session.lock().unwrap();
843 format!(
844 "{}/user/{}/library/music/+noredirect/{}/_/{}",
845 session.base_url,
846 session.username,
847 urlencoding::encode(artist_name),
848 urlencoding::encode(track_name)
849 )
850 };
851
852 log::debug!("Fetching track page: {base_track_url}");
853
854 let mut response = self.get(&base_track_url).await?;
855 let html = response
856 .body_string()
857 .await
858 .map_err(|e| crate::LastFmError::Http(e.to_string()))?;
859
860 let document = Html::parse_document(&html);
861
862 let mut all_scrobble_edits = Vec::new();
864 let mut unique_albums = std::collections::HashSet::new();
865 let max_pages = 5;
866
867 let page_edits = self.extract_scrobble_edits_from_page(
869 &document,
870 track_name,
871 artist_name,
872 &mut unique_albums,
873 )?;
874 all_scrobble_edits.extend(page_edits);
875
876 log::debug!(
877 "Page 1: found {} unique album variations",
878 all_scrobble_edits.len()
879 );
880
881 let pagination_selector = Selector::parse(".pagination .pagination-next").unwrap();
883 let mut has_next_page = document.select(&pagination_selector).next().is_some();
884 let mut page = 2;
885
886 while has_next_page && page <= max_pages {
887 let page_url = {
889 let session = self.session.lock().unwrap();
890 format!(
891 "{}/user/{}/library/music/{}/_/{}?page={page}",
892 session.base_url,
893 session.username,
894 urlencoding::encode(artist_name),
895 urlencoding::encode(track_name)
896 )
897 };
898
899 log::debug!("Fetching page {page} for additional album variations");
900
901 let mut response = self.get(&page_url).await?;
902 let html = response
903 .body_string()
904 .await
905 .map_err(|e| crate::LastFmError::Http(e.to_string()))?;
906
907 let document = Html::parse_document(&html);
908
909 let page_edits = self.extract_scrobble_edits_from_page(
910 &document,
911 track_name,
912 artist_name,
913 &mut unique_albums,
914 )?;
915
916 let initial_count = all_scrobble_edits.len();
917 all_scrobble_edits.extend(page_edits);
918 let found_new_unique_albums = all_scrobble_edits.len() > initial_count;
919
920 has_next_page = document.select(&pagination_selector).next().is_some();
922
923 log::debug!(
924 "Page {page}: found {} total unique albums ({})",
925 all_scrobble_edits.len(),
926 if found_new_unique_albums {
927 "new albums found"
928 } else {
929 "no new unique albums"
930 }
931 );
932
933 page += 1;
936 }
937
938 if all_scrobble_edits.is_empty() {
939 return Err(crate::LastFmError::Parse(format!(
940 "No scrobble forms found for track '{track_name}' by '{artist_name}'"
941 )));
942 }
943
944 log::debug!(
945 "Final result: found {} unique album variations for '{track_name}' by '{artist_name}'",
946 all_scrobble_edits.len(),
947 );
948
949 Ok(all_scrobble_edits)
950 }
951
952 fn extract_scrobble_edits_from_page(
955 &self,
956 document: &Html,
957 expected_track: &str,
958 expected_artist: &str,
959 unique_albums: &mut std::collections::HashSet<(String, String)>,
960 ) -> Result<Vec<ExactScrobbleEdit>> {
961 let mut scrobble_edits = Vec::new();
962 let table_selector =
964 Selector::parse("table.chartlist:not(.chartlist__placeholder)").unwrap();
965 let table = document.select(&table_selector).next().ok_or_else(|| {
966 crate::LastFmError::Parse("No chartlist table found on track page".to_string())
967 })?;
968
969 let row_selector = Selector::parse("tr").unwrap();
971 for row in table.select(&row_selector) {
972 let count_bar_link_selector = Selector::parse(".chartlist-count-bar-link").unwrap();
974 if row.select(&count_bar_link_selector).next().is_some() {
975 log::debug!("Found count bar link, skipping aggregated row");
976 continue;
977 }
978
979 let form_selector = Selector::parse("form[data-edit-scrobble]").unwrap();
981 if let Some(form) = row.select(&form_selector).next() {
982 let extract_form_value = |name: &str| -> Option<String> {
984 let selector = Selector::parse(&format!("input[name='{name}']")).unwrap();
985 form.select(&selector)
986 .next()
987 .and_then(|input| input.value().attr("value"))
988 .map(|s| s.to_string())
989 };
990
991 let form_track = extract_form_value("track_name").unwrap_or_default();
993 let form_artist = extract_form_value("artist_name").unwrap_or_default();
994 let form_album = extract_form_value("album_name").unwrap_or_default();
995 let form_album_artist =
996 extract_form_value("album_artist_name").unwrap_or_else(|| form_artist.clone());
997 let form_timestamp = extract_form_value("timestamp").unwrap_or_default();
998
999 log::debug!(
1000 "Found scrobble form - Track: '{form_track}', Artist: '{form_artist}', Album: '{form_album}', Timestamp: {form_timestamp}"
1001 );
1002
1003 if form_track == expected_track && form_artist == expected_artist {
1005 let album_key = (form_album.clone(), form_album_artist.clone());
1007 if unique_albums.insert(album_key) {
1008 let timestamp = if form_timestamp.is_empty() {
1010 None
1011 } else {
1012 form_timestamp.parse::<u64>().ok()
1013 };
1014
1015 if let Some(timestamp) = timestamp {
1016 log::debug!(
1017 "✅ Found unique album variation: '{form_album}' by '{form_album_artist}' for '{expected_track}' by '{expected_artist}'"
1018 );
1019
1020 let scrobble_edit = ExactScrobbleEdit::new(
1022 form_track.clone(),
1023 form_album.clone(),
1024 form_artist.clone(),
1025 form_album_artist.clone(),
1026 form_track,
1027 form_album,
1028 form_artist,
1029 form_album_artist,
1030 timestamp,
1031 true,
1032 );
1033 scrobble_edits.push(scrobble_edit);
1034 } else {
1035 log::debug!(
1036 "⚠️ Skipping album variation without valid timestamp: '{form_album}' by '{form_album_artist}'"
1037 );
1038 }
1039 }
1040 }
1041 }
1042 }
1043
1044 Ok(scrobble_edits)
1045 }
1046
1047 fn filter_by_original_album_artist(
1053 discovered_edits: Vec<ExactScrobbleEdit>,
1054 edit: &ScrobbleEdit,
1055 ) -> Vec<ExactScrobbleEdit> {
1056 if let Some(target_album_artist) = &edit.album_artist_name_original {
1057 log::debug!(
1058 "Filtering {} discovered edits to only include album artist '{}'",
1059 discovered_edits.len(),
1060 target_album_artist
1061 );
1062
1063 let filtered: Vec<ExactScrobbleEdit> = discovered_edits
1064 .into_iter()
1065 .filter(|scrobble| scrobble.album_artist_name_original == *target_album_artist)
1066 .collect();
1067
1068 log::debug!(
1069 "After filtering by album artist '{}': {} edits remain",
1070 target_album_artist,
1071 filtered.len()
1072 );
1073
1074 filtered
1075 } else {
1076 discovered_edits
1078 }
1079 }
1080
1081 async fn discover_track_album_exact_match(
1083 &self,
1084 edit: &ScrobbleEdit,
1085 track_name: &str,
1086 album_name: &str,
1087 ) -> Result<Vec<ExactScrobbleEdit>> {
1088 log::debug!(
1089 "Looking up missing metadata for track '{}' on album '{}' by '{}'",
1090 track_name,
1091 album_name,
1092 edit.artist_name_original
1093 );
1094 let all_variations = self
1095 .load_edit_form_values_internal(track_name, &edit.artist_name_original)
1096 .await?;
1097
1098 let filtered_variations = Self::filter_by_original_album_artist(all_variations, edit);
1100
1101 if let Some(exact_edit) = filtered_variations
1102 .iter()
1103 .find(|variation| variation.album_name_original == *album_name)
1104 {
1105 let mut modified_edit = exact_edit.clone();
1107 if let Some(new_track_name) = &edit.track_name {
1108 modified_edit.track_name = new_track_name.clone();
1109 }
1110 if let Some(new_album_name) = &edit.album_name {
1111 modified_edit.album_name = new_album_name.clone();
1112 }
1113 modified_edit.artist_name = edit.artist_name.clone();
1114 if let Some(new_album_artist_name) = &edit.album_artist_name {
1115 modified_edit.album_artist_name = new_album_artist_name.clone();
1116 }
1117 modified_edit.edit_all = edit.edit_all;
1118
1119 Ok(vec![modified_edit])
1120 } else {
1121 let album_artist_filter = if edit.album_artist_name_original.is_some() {
1122 format!(
1123 " with album artist '{}'",
1124 edit.album_artist_name_original.as_ref().unwrap()
1125 )
1126 } else {
1127 String::new()
1128 };
1129 Err(LastFmError::Parse(format!(
1130 "Track '{}' not found on album '{}' by '{}'{} in recent scrobbles",
1131 track_name, album_name, edit.artist_name_original, album_artist_filter
1132 )))
1133 }
1134 }
1135
1136 async fn discover_track_variations(
1138 &self,
1139 edit: &ScrobbleEdit,
1140 track_name: &str,
1141 ) -> Result<Vec<ExactScrobbleEdit>> {
1142 log::debug!(
1143 "Discovering album variations for track '{}' by '{}'",
1144 track_name,
1145 edit.artist_name_original
1146 );
1147 let discovered_edits = self
1148 .load_edit_form_values_internal(track_name, &edit.artist_name_original)
1149 .await?;
1150 Ok(Self::filter_by_original_album_artist(
1151 discovered_edits,
1152 edit,
1153 ))
1154 }
1155
1156 async fn discover_album_tracks(
1158 &self,
1159 edit: &ScrobbleEdit,
1160 album_name: &str,
1161 ) -> Result<Vec<ExactScrobbleEdit>> {
1162 log::debug!(
1163 "Discovering tracks in album '{}' by '{}'",
1164 album_name,
1165 edit.artist_name_original
1166 );
1167 let tracks = self
1168 .get_album_tracks(album_name, &edit.artist_name_original)
1169 .await?;
1170 let discovered_edits: Vec<ExactScrobbleEdit> = tracks
1171 .iter()
1172 .filter_map(|track| {
1173 track.timestamp.map(|timestamp| {
1174 ExactScrobbleEdit::new(
1175 track.name.clone(),
1176 album_name.to_string(),
1177 edit.artist_name_original.clone(),
1178 track
1179 .album_artist
1180 .clone()
1181 .unwrap_or_else(|| edit.artist_name_original.clone()),
1182 track.name.clone(), album_name.to_string(),
1184 edit.artist_name_original.clone(),
1185 track
1186 .album_artist
1187 .clone()
1188 .unwrap_or_else(|| edit.artist_name_original.clone()),
1189 timestamp,
1190 false,
1191 )
1192 })
1193 })
1194 .collect();
1195 Ok(Self::filter_by_original_album_artist(
1196 discovered_edits,
1197 edit,
1198 ))
1199 }
1200
1201 async fn discover_artist_tracks(&self, edit: &ScrobbleEdit) -> Result<Vec<ExactScrobbleEdit>> {
1203 log::debug!(
1204 "Discovering all tracks by artist '{}'",
1205 edit.artist_name_original
1206 );
1207
1208 let mut tracks_iterator =
1209 crate::ArtistTracksIterator::new(self.clone(), edit.artist_name_original.clone());
1210 let mut discovered_edits = Vec::new();
1211
1212 while let Some(track) = tracks_iterator.next().await? {
1213 log::debug!(
1214 "Getting scrobble data for track '{}' by '{}'",
1215 track.name,
1216 edit.artist_name_original
1217 );
1218
1219 match self
1221 .load_edit_form_values_internal(&track.name, &edit.artist_name_original)
1222 .await
1223 {
1224 Ok(track_scrobbles) => {
1225 for scrobble in track_scrobbles {
1227 let mut modified_edit = scrobble.clone();
1228 if let Some(new_track_name) = &edit.track_name {
1229 modified_edit.track_name = new_track_name.clone();
1230 }
1231 if let Some(new_album_name) = &edit.album_name {
1232 modified_edit.album_name = new_album_name.clone();
1233 }
1234 modified_edit.artist_name = edit.artist_name.clone();
1235 if let Some(new_album_artist_name) = &edit.album_artist_name {
1236 modified_edit.album_artist_name = new_album_artist_name.clone();
1237 }
1238 modified_edit.edit_all = edit.edit_all;
1239
1240 discovered_edits.push(modified_edit);
1241 }
1242 }
1243 Err(e) => {
1244 log::debug!(
1245 "Failed to get scrobble data for track '{}': {}",
1246 track.name,
1247 e
1248 );
1249 }
1251 }
1252 }
1253
1254 log::debug!(
1255 "Found {} scrobbles from '{}' across all tracks",
1256 discovered_edits.len(),
1257 edit.artist_name_original
1258 );
1259
1260 Ok(Self::filter_by_original_album_artist(
1261 discovered_edits,
1262 edit,
1263 ))
1264 }
1265
1266 pub async fn discover_scrobble_edit_variations(
1280 &self,
1281 edit: &ScrobbleEdit,
1282 ) -> Result<Vec<ExactScrobbleEdit>> {
1283 if let (Some(track_name), Some(album_name), Some(album_artist), Some(timestamp)) = (
1285 &edit.track_name_original,
1286 &edit.album_name_original,
1287 &edit.album_artist_name_original,
1288 edit.timestamp,
1289 ) {
1290 let exact_edit = ExactScrobbleEdit::new(
1291 track_name.clone(),
1292 album_name.clone(),
1293 edit.artist_name_original.clone(),
1294 album_artist.clone(),
1295 edit.track_name
1296 .clone()
1297 .unwrap_or_else(|| track_name.clone()),
1298 edit.album_name
1299 .clone()
1300 .unwrap_or_else(|| album_name.clone()),
1301 edit.artist_name.clone(),
1302 edit.album_artist_name
1303 .clone()
1304 .unwrap_or_else(|| album_artist.clone()),
1305 timestamp,
1306 edit.edit_all,
1307 );
1308 return Ok(vec![exact_edit]);
1309 }
1310
1311 match (&edit.track_name_original, &edit.album_name_original) {
1312 (Some(track_name), Some(album_name)) => {
1314 self.discover_track_album_exact_match(edit, track_name, album_name)
1315 .await
1316 }
1317
1318 (Some(track_name), None) => self.discover_track_variations(edit, track_name).await,
1320
1321 (None, Some(album_name)) => self.discover_album_tracks(edit, album_name).await,
1323
1324 (None, None) => self.discover_artist_tracks(edit).await,
1326 }
1327 }
1328
1329 pub async fn get_album_tracks(
1332 &self,
1333 album_name: &str,
1334 artist_name: &str,
1335 ) -> Result<Vec<Track>> {
1336 log::debug!("Getting tracks from album '{album_name}' by '{artist_name}'");
1337
1338 let album_url = {
1340 let session = self.session.lock().unwrap();
1341 format!(
1342 "{}/user/{}/library/music/{}/{}",
1343 session.base_url,
1344 session.username,
1345 urlencoding::encode(artist_name),
1346 urlencoding::encode(album_name)
1347 )
1348 };
1349
1350 log::debug!("Fetching album page: {album_url}");
1351
1352 let mut response = self.get(&album_url).await?;
1353 let html = response
1354 .body_string()
1355 .await
1356 .map_err(|e| LastFmError::Http(e.to_string()))?;
1357
1358 let document = Html::parse_document(&html);
1359
1360 let tracks =
1362 self.parser
1363 .extract_tracks_from_document(&document, artist_name, Some(album_name))?;
1364
1365 log::debug!(
1366 "Successfully parsed {} tracks from album page",
1367 tracks.len()
1368 );
1369 Ok(tracks)
1370 }
1371
1372 pub async fn edit_album(
1375 &self,
1376 old_album_name: &str,
1377 new_album_name: &str,
1378 artist_name: &str,
1379 ) -> Result<EditResponse> {
1380 log::debug!("Editing album '{old_album_name}' -> '{new_album_name}' by '{artist_name}'");
1381
1382 let edit = ScrobbleEdit::for_album(old_album_name, artist_name, artist_name)
1383 .with_album_name(new_album_name);
1384
1385 self.edit_scrobble(&edit).await
1386 }
1387
1388 pub async fn edit_artist(
1391 &self,
1392 old_artist_name: &str,
1393 new_artist_name: &str,
1394 ) -> Result<EditResponse> {
1395 log::debug!("Editing artist '{old_artist_name}' -> '{new_artist_name}'");
1396
1397 let edit = ScrobbleEdit::for_artist(old_artist_name, new_artist_name);
1398
1399 self.edit_scrobble(&edit).await
1400 }
1401
1402 pub async fn edit_artist_for_track(
1405 &self,
1406 track_name: &str,
1407 old_artist_name: &str,
1408 new_artist_name: &str,
1409 ) -> Result<EditResponse> {
1410 log::debug!("Editing artist for track '{track_name}' from '{old_artist_name}' -> '{new_artist_name}'");
1411
1412 let edit = ScrobbleEdit::from_track_and_artist(track_name, old_artist_name)
1413 .with_artist_name(new_artist_name);
1414
1415 self.edit_scrobble(&edit).await
1416 }
1417
1418 pub async fn edit_artist_for_album(
1421 &self,
1422 album_name: &str,
1423 old_artist_name: &str,
1424 new_artist_name: &str,
1425 ) -> Result<EditResponse> {
1426 log::debug!("Editing artist for album '{album_name}' from '{old_artist_name}' -> '{new_artist_name}'");
1427
1428 let edit = ScrobbleEdit::for_album(album_name, old_artist_name, old_artist_name)
1429 .with_artist_name(new_artist_name);
1430
1431 self.edit_scrobble(&edit).await
1432 }
1433
1434 pub async fn get_artist_tracks_page(&self, artist: &str, page: u32) -> Result<TrackPage> {
1435 let url = {
1437 let session = self.session.lock().unwrap();
1438 format!(
1439 "{}/user/{}/library/music/{}/+tracks?page={}&ajax=true",
1440 session.base_url,
1441 session.username,
1442 artist.replace(" ", "+"),
1443 page
1444 )
1445 };
1446
1447 log::debug!("Fetching tracks page {page} for artist: {artist}");
1448 let mut response = self.get(&url).await?;
1449 let content = response
1450 .body_string()
1451 .await
1452 .map_err(|e| LastFmError::Http(e.to_string()))?;
1453
1454 log::debug!(
1455 "AJAX response: {} status, {} chars",
1456 response.status(),
1457 content.len()
1458 );
1459
1460 if content.trim_start().starts_with("{") || content.trim_start().starts_with("[") {
1462 log::debug!("Parsing JSON response from AJAX endpoint");
1463 self.parse_json_tracks_page(&content, page, artist)
1464 } else {
1465 log::debug!("Parsing HTML response from AJAX endpoint");
1466 let document = Html::parse_document(&content);
1467 self.parser.parse_tracks_page(&document, page, artist, None)
1468 }
1469 }
1470
1471 fn parse_json_tracks_page(
1473 &self,
1474 _json_content: &str,
1475 page_number: u32,
1476 _artist: &str,
1477 ) -> Result<TrackPage> {
1478 log::debug!("JSON parsing not implemented, returning empty page");
1480 Ok(TrackPage {
1481 tracks: Vec::new(),
1482 page_number,
1483 has_next_page: false,
1484 total_pages: Some(1),
1485 })
1486 }
1487
1488 pub fn extract_tracks_from_document(
1490 &self,
1491 document: &Html,
1492 artist: &str,
1493 album: Option<&str>,
1494 ) -> Result<Vec<Track>> {
1495 self.parser
1496 .extract_tracks_from_document(document, artist, album)
1497 }
1498
1499 pub fn parse_tracks_page(
1501 &self,
1502 document: &Html,
1503 page_number: u32,
1504 artist: &str,
1505 album: Option<&str>,
1506 ) -> Result<TrackPage> {
1507 self.parser
1508 .parse_tracks_page(document, page_number, artist, album)
1509 }
1510
1511 pub fn parse_recent_scrobbles(&self, document: &Html) -> Result<Vec<Track>> {
1513 self.parser.parse_recent_scrobbles(document)
1514 }
1515
1516 fn extract_csrf_token(&self, document: &Html) -> Result<String> {
1517 let csrf_selector = Selector::parse("input[name=\"csrfmiddlewaretoken\"]").unwrap();
1518
1519 document
1520 .select(&csrf_selector)
1521 .next()
1522 .and_then(|input| input.value().attr("value"))
1523 .map(|token| token.to_string())
1524 .ok_or(LastFmError::CsrfNotFound)
1525 }
1526
1527 fn extract_login_form_data(&self, html: &str) -> Result<(String, Option<String>)> {
1529 let document = Html::parse_document(html);
1530
1531 let csrf_token = self.extract_csrf_token(&document)?;
1532
1533 let next_selector = Selector::parse("input[name=\"next\"]").unwrap();
1535 let next_field = document
1536 .select(&next_selector)
1537 .next()
1538 .and_then(|input| input.value().attr("value"))
1539 .map(|s| s.to_string());
1540
1541 Ok((csrf_token, next_field))
1542 }
1543
1544 fn parse_login_error(&self, html: &str) -> String {
1546 let document = Html::parse_document(html);
1547
1548 let error_selector = Selector::parse(".alert-danger, .form-error, .error-message").unwrap();
1549
1550 let mut error_messages = Vec::new();
1551 for error in document.select(&error_selector) {
1552 let error_text = error.text().collect::<String>().trim().to_string();
1553 if !error_text.is_empty() {
1554 error_messages.push(error_text);
1555 }
1556 }
1557
1558 if error_messages.is_empty() {
1559 "Login failed - please check your credentials".to_string()
1560 } else {
1561 format!("Login failed: {}", error_messages.join("; "))
1562 }
1563 }
1564
1565 fn check_for_login_form(&self, html: &str) -> bool {
1567 let document = Html::parse_document(html);
1568 let login_form_selector =
1569 Selector::parse("form[action*=\"login\"], input[name=\"username_or_email\"]").unwrap();
1570 document.select(&login_form_selector).next().is_some()
1571 }
1572
1573 pub async fn get(&self, url: &str) -> Result<Response> {
1575 self.get_with_retry(url, 3).await
1576 }
1577
1578 async fn get_with_retry(&self, url: &str, max_retries: u32) -> Result<Response> {
1580 let mut retries = 0;
1581
1582 loop {
1583 match self.get_with_redirects(url, 0).await {
1584 Ok(mut response) => {
1585 let body = self.extract_response_body(url, &mut response).await?;
1587
1588 if response.status().is_success() && self.is_rate_limit_response(&body) {
1590 log::debug!("Response body contains rate limit patterns");
1591 if retries < max_retries {
1592 let delay = 60 + (retries as u64 * 30); log::info!("Rate limit detected in response body, retrying in {delay}s (attempt {}/{max_retries})", retries + 1);
1594 retries += 1;
1596 continue;
1597 }
1598 return Err(crate::LastFmError::RateLimit { retry_after: 60 });
1599 }
1600
1601 let mut new_response = http_types::Response::new(response.status());
1603 for (name, values) in response.iter() {
1604 for value in values {
1605 let _ = new_response.insert_header(name.clone(), value.clone());
1606 }
1607 }
1608 new_response.set_body(body);
1609
1610 return Ok(new_response);
1611 }
1612 Err(crate::LastFmError::RateLimit { retry_after }) => {
1613 if retries < max_retries {
1614 let delay = retry_after + (retries as u64 * 30); log::info!(
1616 "Rate limit detected, retrying in {delay}s (attempt {}/{max_retries})",
1617 retries + 1
1618 );
1619 retries += 1;
1621 } else {
1622 return Err(crate::LastFmError::RateLimit { retry_after });
1623 }
1624 }
1625 Err(e) => return Err(e),
1626 }
1627 }
1628 }
1629
1630 async fn get_with_redirects(&self, url: &str, redirect_count: u32) -> Result<Response> {
1631 if redirect_count > 5 {
1632 return Err(LastFmError::Http("Too many redirects".to_string()));
1633 }
1634
1635 let mut request = Request::new(Method::Get, url.parse::<Url>().unwrap());
1636 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");
1637
1638 {
1640 let session = self.session.lock().unwrap();
1641 if !session.cookies.is_empty() {
1642 let cookie_header = session.cookies.join("; ");
1643 let _ = request.insert_header("Cookie", &cookie_header);
1644 } else if url.contains("page=") {
1645 log::debug!("No cookies available for paginated request!");
1646 }
1647 }
1648
1649 if url.contains("ajax=true") {
1651 let _ = request.insert_header("Accept", "*/*");
1653 let _ = request.insert_header("X-Requested-With", "XMLHttpRequest");
1654 } else {
1655 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");
1657 }
1658 let _ = request.insert_header("Accept-Language", "en-US,en;q=0.9");
1659 let _ = request.insert_header("Accept-Encoding", "gzip, deflate, br");
1660 let _ = request.insert_header("DNT", "1");
1661 let _ = request.insert_header("Connection", "keep-alive");
1662 let _ = request.insert_header("Upgrade-Insecure-Requests", "1");
1663
1664 if url.contains("page=") {
1666 let base_url = url.split('?').next().unwrap_or(url);
1667 let _ = request.insert_header("Referer", base_url);
1668 }
1669
1670 let response = self
1671 .client
1672 .send(request)
1673 .await
1674 .map_err(|e| LastFmError::Http(e.to_string()))?;
1675
1676 self.extract_cookies(&response);
1678
1679 if response.status() == 302 || response.status() == 301 {
1681 if let Some(location) = response.header("location") {
1682 if let Some(redirect_url) = location.get(0) {
1683 let redirect_url_str = redirect_url.as_str();
1684 if url.contains("page=") {
1685 log::debug!("Following redirect from {url} to {redirect_url_str}");
1686
1687 if redirect_url_str.contains("/login") {
1689 log::debug!("Redirect to login page - authentication failed for paginated request");
1690 return Err(LastFmError::Auth(
1691 "Session expired or invalid for paginated request".to_string(),
1692 ));
1693 }
1694 }
1695
1696 let full_redirect_url = if redirect_url_str.starts_with('/') {
1698 let base_url = self.session.lock().unwrap().base_url.clone();
1699 format!("{base_url}{redirect_url_str}")
1700 } else if redirect_url_str.starts_with("http") {
1701 redirect_url_str.to_string()
1702 } else {
1703 let base_url = url
1705 .rsplit('/')
1706 .skip(1)
1707 .collect::<Vec<_>>()
1708 .into_iter()
1709 .rev()
1710 .collect::<Vec<_>>()
1711 .join("/");
1712 format!("{base_url}/{redirect_url_str}")
1713 };
1714
1715 return Box::pin(
1717 self.get_with_redirects(&full_redirect_url, redirect_count + 1),
1718 )
1719 .await;
1720 }
1721 }
1722 }
1723
1724 if response.status() == 429 {
1726 let retry_after = response
1727 .header("retry-after")
1728 .and_then(|h| h.get(0))
1729 .and_then(|v| v.as_str().parse::<u64>().ok())
1730 .unwrap_or(60);
1731 return Err(LastFmError::RateLimit { retry_after });
1732 }
1733
1734 if response.status() == 403 {
1736 log::debug!("Got 403 response, checking if it's a rate limit");
1737 {
1739 let session = self.session.lock().unwrap();
1740 if !session.cookies.is_empty() {
1741 log::debug!("403 on authenticated request - likely rate limit");
1742 return Err(LastFmError::RateLimit { retry_after: 60 });
1743 }
1744 }
1745 }
1746
1747 Ok(response)
1748 }
1749
1750 fn is_rate_limit_response(&self, response_body: &str) -> bool {
1752 let body_lower = response_body.to_lowercase();
1753
1754 for pattern in &self.rate_limit_patterns {
1756 if body_lower.contains(&pattern.to_lowercase()) {
1757 return true;
1758 }
1759 }
1760
1761 false
1762 }
1763
1764 fn extract_cookies(&self, response: &Response) {
1765 if let Some(cookie_headers) = response.header("set-cookie") {
1767 let mut new_cookies = 0;
1768 for cookie_header in cookie_headers {
1769 let cookie_str = cookie_header.as_str();
1770 if let Some(cookie_value) = cookie_str.split(';').next() {
1772 let cookie_name = cookie_value.split('=').next().unwrap_or("");
1773
1774 {
1776 let mut session = self.session.lock().unwrap();
1777 session
1778 .cookies
1779 .retain(|existing| !existing.starts_with(&format!("{cookie_name}=")));
1780 session.cookies.push(cookie_value.to_string());
1781 }
1782 new_cookies += 1;
1783 }
1784 }
1785 if new_cookies > 0 {
1786 {
1787 let session = self.session.lock().unwrap();
1788 log::trace!(
1789 "Extracted {} new cookies, total: {}",
1790 new_cookies,
1791 session.cookies.len()
1792 );
1793 log::trace!("Updated cookies: {:?}", &session.cookies);
1794
1795 for cookie in &session.cookies {
1797 if cookie.starts_with("sessionid=") {
1798 log::trace!("Current sessionid: {}", &cookie[10..50.min(cookie.len())]);
1799 break;
1800 }
1801 }
1802 }
1803 }
1804 }
1805 }
1806
1807 async fn extract_response_body(&self, url: &str, response: &mut Response) -> Result<String> {
1809 let body = response
1810 .body_string()
1811 .await
1812 .map_err(|e| LastFmError::Http(e.to_string()))?;
1813
1814 if self.debug_save_responses {
1815 self.save_debug_response(url, response.status().into(), &body);
1816 }
1817
1818 Ok(body)
1819 }
1820
1821 fn save_debug_response(&self, url: &str, status_code: u16, body: &str) {
1823 if let Err(e) = self.try_save_debug_response(url, status_code, body) {
1824 log::warn!("Failed to save debug response: {e}");
1825 }
1826 }
1827
1828 fn try_save_debug_response(&self, url: &str, status_code: u16, body: &str) -> Result<()> {
1830 let debug_dir = Path::new("debug_responses");
1832 if !debug_dir.exists() {
1833 fs::create_dir_all(debug_dir)
1834 .map_err(|e| LastFmError::Http(format!("Failed to create debug directory: {e}")))?;
1835 }
1836
1837 let url_path = {
1839 let session = self.session.lock().unwrap();
1840 if url.starts_with(&session.base_url) {
1841 &url[session.base_url.len()..]
1842 } else {
1843 url
1844 }
1845 };
1846
1847 let now = chrono::Utc::now();
1849 let timestamp = now.format("%Y%m%d_%H%M%S_%3f");
1850 let safe_path = url_path.replace(['/', '?', '&', '=', '%', '+'], "_");
1851
1852 let filename = format!("{timestamp}_{safe_path}_status{status_code}.html");
1853 let file_path = debug_dir.join(filename);
1854
1855 fs::write(&file_path, body)
1857 .map_err(|e| LastFmError::Http(format!("Failed to write debug file: {e}")))?;
1858
1859 log::debug!(
1860 "Saved HTTP response to {file_path:?} (status: {status_code}, url: {url_path})"
1861 );
1862
1863 Ok(())
1864 }
1865
1866 pub async fn get_artist_albums_page(&self, artist: &str, page: u32) -> Result<AlbumPage> {
1867 let url = {
1869 let session = self.session.lock().unwrap();
1870 format!(
1871 "{}/user/{}/library/music/{}/+albums?page={}&ajax=true",
1872 session.base_url,
1873 session.username,
1874 artist.replace(" ", "+"),
1875 page
1876 )
1877 };
1878
1879 log::debug!("Fetching albums page {page} for artist: {artist}");
1880 let mut response = self.get(&url).await?;
1881 let content = response
1882 .body_string()
1883 .await
1884 .map_err(|e| LastFmError::Http(e.to_string()))?;
1885
1886 log::debug!(
1887 "AJAX response: {} status, {} chars",
1888 response.status(),
1889 content.len()
1890 );
1891
1892 if content.trim_start().starts_with("{") || content.trim_start().starts_with("[") {
1894 log::debug!("Parsing JSON response from AJAX endpoint");
1895 self.parse_json_albums_page(&content, page, artist)
1896 } else {
1897 log::debug!("Parsing HTML response from AJAX endpoint");
1898 let document = Html::parse_document(&content);
1899 self.parser.parse_albums_page(&document, page, artist)
1900 }
1901 }
1902
1903 fn parse_json_albums_page(
1904 &self,
1905 _json_content: &str,
1906 page_number: u32,
1907 _artist: &str,
1908 ) -> Result<AlbumPage> {
1909 log::debug!("JSON parsing not implemented, returning empty page");
1911 Ok(AlbumPage {
1912 albums: Vec::new(),
1913 page_number,
1914 has_next_page: false,
1915 total_pages: Some(1),
1916 })
1917 }
1918}
1919
1920#[async_trait(?Send)]
1921impl LastFmEditClient for LastFmEditClientImpl {
1922 async fn login(&self, username: &str, password: &str) -> Result<()> {
1923 self.login(username, password).await
1924 }
1925
1926 fn username(&self) -> String {
1927 self.username()
1928 }
1929
1930 fn is_logged_in(&self) -> bool {
1931 self.is_logged_in()
1932 }
1933
1934 async fn get_recent_scrobbles(&self, page: u32) -> Result<Vec<Track>> {
1935 self.get_recent_scrobbles(page).await
1936 }
1937
1938 async fn find_recent_scrobble_for_track(
1939 &self,
1940 track_name: &str,
1941 artist_name: &str,
1942 max_pages: u32,
1943 ) -> Result<Option<Track>> {
1944 self.find_recent_scrobble_for_track(track_name, artist_name, max_pages)
1945 .await
1946 }
1947
1948 async fn edit_scrobble(&self, edit: &ScrobbleEdit) -> Result<EditResponse> {
1949 self.edit_scrobble(edit).await
1950 }
1951
1952 async fn edit_scrobble_single(
1953 &self,
1954 exact_edit: &ExactScrobbleEdit,
1955 max_retries: u32,
1956 ) -> Result<EditResponse> {
1957 self.edit_scrobble_single(exact_edit, max_retries).await
1958 }
1959
1960 async fn discover_scrobble_edit_variations(
1961 &self,
1962 edit: &ScrobbleEdit,
1963 ) -> Result<Vec<ExactScrobbleEdit>> {
1964 self.discover_scrobble_edit_variations(edit).await
1965 }
1966
1967 async fn get_album_tracks(&self, album_name: &str, artist_name: &str) -> Result<Vec<Track>> {
1968 self.get_album_tracks(album_name, artist_name).await
1969 }
1970
1971 async fn edit_album(
1972 &self,
1973 old_album_name: &str,
1974 new_album_name: &str,
1975 artist_name: &str,
1976 ) -> Result<EditResponse> {
1977 self.edit_album(old_album_name, new_album_name, artist_name)
1978 .await
1979 }
1980
1981 async fn edit_artist(
1982 &self,
1983 old_artist_name: &str,
1984 new_artist_name: &str,
1985 ) -> Result<EditResponse> {
1986 self.edit_artist(old_artist_name, new_artist_name).await
1987 }
1988
1989 async fn edit_artist_for_track(
1990 &self,
1991 track_name: &str,
1992 old_artist_name: &str,
1993 new_artist_name: &str,
1994 ) -> Result<EditResponse> {
1995 self.edit_artist_for_track(track_name, old_artist_name, new_artist_name)
1996 .await
1997 }
1998
1999 async fn edit_artist_for_album(
2000 &self,
2001 album_name: &str,
2002 old_artist_name: &str,
2003 new_artist_name: &str,
2004 ) -> Result<EditResponse> {
2005 self.edit_artist_for_album(album_name, old_artist_name, new_artist_name)
2006 .await
2007 }
2008
2009 async fn get_artist_tracks_page(&self, artist: &str, page: u32) -> Result<TrackPage> {
2010 self.get_artist_tracks_page(artist, page).await
2011 }
2012
2013 async fn get_artist_albums_page(&self, artist: &str, page: u32) -> Result<AlbumPage> {
2014 self.get_artist_albums_page(artist, page).await
2015 }
2016
2017 async fn get_recent_tracks_page(&self, page: u32) -> Result<TrackPage> {
2018 self.get_recent_tracks_page(page).await
2019 }
2020
2021 fn get_session(&self) -> LastFmEditSession {
2022 self.get_session()
2023 }
2024
2025 fn restore_session(&self, session: LastFmEditSession) {
2026 self.restore_session(session)
2027 }
2028
2029 fn artist_tracks(&self, artist: &str) -> crate::ArtistTracksIterator {
2030 crate::ArtistTracksIterator::new(self.clone(), artist.to_string())
2031 }
2032
2033 fn artist_albums(&self, artist: &str) -> crate::ArtistAlbumsIterator {
2034 crate::ArtistAlbumsIterator::new(self.clone(), artist.to_string())
2035 }
2036
2037 fn recent_tracks(&self) -> crate::RecentTracksIterator {
2038 crate::RecentTracksIterator::new(self.clone())
2039 }
2040
2041 fn recent_tracks_from_page(&self, starting_page: u32) -> crate::RecentTracksIterator {
2042 crate::RecentTracksIterator::with_starting_page(self.clone(), starting_page)
2043 }
2044}