1use crate::parsing::LastFmParser;
2use crate::{
3 AlbumPage, ArtistAlbumsIterator, ArtistTracksIterator, AsyncPaginatedIterator, EditResponse,
4 LastFmError, RecentTracksIterator, Result, ScrobbleEdit, Track, TrackPage,
5};
6use http_client::{HttpClient, Request, Response};
7use http_types::{Method, Url};
8use scraper::{Html, Selector};
9use std::collections::HashMap;
10use std::fs;
11use std::path::Path;
12
13pub struct LastFmEditClient {
39 client: Box<dyn HttpClient>,
40 username: String,
41 csrf_token: Option<String>,
42 base_url: String,
43 session_cookies: Vec<String>,
44 rate_limit_patterns: Vec<String>,
45 debug_save_responses: bool,
46 parser: LastFmParser,
47}
48
49impl LastFmEditClient {
50 pub fn new(client: Box<dyn HttpClient>) -> Self {
65 Self::with_base_url(client, "https://www.last.fm".to_string())
66 }
67
68 pub fn with_base_url(client: Box<dyn HttpClient>, base_url: String) -> Self {
77 Self::with_rate_limit_patterns(
78 client,
79 base_url,
80 vec![
81 "you've tried to log in too many times".to_string(),
82 "you're requesting too many pages".to_string(),
83 "slow down".to_string(),
84 "too fast".to_string(),
85 "rate limit".to_string(),
86 "throttled".to_string(),
87 "temporarily blocked".to_string(),
88 "temporarily restricted".to_string(),
89 "captcha".to_string(),
90 "verify you're human".to_string(),
91 "prove you're not a robot".to_string(),
92 "security check".to_string(),
93 "service temporarily unavailable".to_string(),
94 "quota exceeded".to_string(),
95 "limit exceeded".to_string(),
96 "daily limit".to_string(),
97 ],
98 )
99 }
100
101 pub fn with_rate_limit_patterns(
109 client: Box<dyn HttpClient>,
110 base_url: String,
111 rate_limit_patterns: Vec<String>,
112 ) -> Self {
113 Self {
114 client,
115 username: String::new(),
116 csrf_token: None,
117 base_url,
118 session_cookies: Vec::new(),
119 rate_limit_patterns,
120 debug_save_responses: std::env::var("LASTFM_DEBUG_SAVE_RESPONSES").is_ok(),
121 parser: LastFmParser::new(),
122 }
123 }
124
125 pub async fn login(&mut self, username: &str, password: &str) -> Result<()> {
154 let login_url = format!("{}/login", self.base_url);
156 let mut response = self.get(&login_url).await?;
157
158 self.extract_cookies(&response);
160
161 let html = response
162 .body_string()
163 .await
164 .map_err(|e| LastFmError::Http(e.to_string()))?;
165 let document = Html::parse_document(&html);
166
167 let csrf_token = self.extract_csrf_token(&document)?;
168
169 let mut form_data = HashMap::new();
171 form_data.insert("csrfmiddlewaretoken", csrf_token.as_str());
172 form_data.insert("username_or_email", username);
173 form_data.insert("password", password);
174
175 let next_selector = Selector::parse("input[name=\"next\"]").unwrap();
177 if let Some(next_input) = document.select(&next_selector).next() {
178 if let Some(next_value) = next_input.value().attr("value") {
179 form_data.insert("next", next_value);
180 }
181 }
182
183 let mut request = Request::new(Method::Post, login_url.parse::<Url>().unwrap());
184 let _ = request.insert_header("Referer", &login_url);
185 let _ = request.insert_header("Origin", &self.base_url);
186 let _ = request.insert_header("Content-Type", "application/x-www-form-urlencoded");
187 let _ = request.insert_header(
188 "User-Agent",
189 "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36"
190 );
191 let _ = request.insert_header(
192 "Accept",
193 "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"
194 );
195 let _ = request.insert_header("Accept-Language", "en-US,en;q=0.9");
196 let _ = request.insert_header("Accept-Encoding", "gzip, deflate, br");
197 let _ = request.insert_header("DNT", "1");
198 let _ = request.insert_header("Connection", "keep-alive");
199 let _ = request.insert_header("Upgrade-Insecure-Requests", "1");
200 let _ = request.insert_header(
201 "sec-ch-ua",
202 "\"Not)A;Brand\";v=\"8\", \"Chromium\";v=\"138\", \"Google Chrome\";v=\"138\"",
203 );
204 let _ = request.insert_header("sec-ch-ua-mobile", "?0");
205 let _ = request.insert_header("sec-ch-ua-platform", "\"Linux\"");
206 let _ = request.insert_header("Sec-Fetch-Dest", "document");
207 let _ = request.insert_header("Sec-Fetch-Mode", "navigate");
208 let _ = request.insert_header("Sec-Fetch-Site", "same-origin");
209 let _ = request.insert_header("Sec-Fetch-User", "?1");
210
211 if !self.session_cookies.is_empty() {
213 let cookie_header = self.session_cookies.join("; ");
214 let _ = request.insert_header("Cookie", &cookie_header);
215 }
216
217 let form_string: String = form_data
219 .iter()
220 .map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v)))
221 .collect::<Vec<_>>()
222 .join("&");
223
224 request.set_body(form_string);
225
226 let mut response = self
227 .client
228 .send(request)
229 .await
230 .map_err(|e| LastFmError::Http(e.to_string()))?;
231
232 self.extract_cookies(&response);
234
235 log::debug!("Login response status: {}", response.status());
236
237 if response.status() == 403 {
239 let response_html = response
241 .body_string()
242 .await
243 .map_err(|e| LastFmError::Http(e.to_string()))?;
244
245 if self.is_rate_limit_response(&response_html) {
247 log::debug!("403 response appears to be rate limiting");
248 return Err(LastFmError::RateLimit { retry_after: 60 });
249 } else {
250 log::debug!("403 response appears to be authentication failure");
251
252 let success_doc = Html::parse_document(&response_html);
254 let login_form_selector =
255 Selector::parse("form[action*=\"login\"], input[name=\"username_or_email\"]")
256 .unwrap();
257 let has_login_form = success_doc.select(&login_form_selector).next().is_some();
258
259 if !has_login_form {
260 return Err(LastFmError::Auth(
261 "Login failed - 403 Forbidden. Check credentials.".to_string(),
262 ));
263 } else {
264 let error_selector =
266 Selector::parse(".alert-danger, .form-error, .error-message").unwrap();
267 let mut error_messages = Vec::new();
268 for error in success_doc.select(&error_selector) {
269 let error_text = error.text().collect::<String>().trim().to_string();
270 if !error_text.is_empty() {
271 error_messages.push(error_text);
272 }
273 }
274 let error_msg = if error_messages.is_empty() {
275 "Login failed - 403 Forbidden. Check credentials.".to_string()
276 } else {
277 format!("Login failed: {}", error_messages.join("; "))
278 };
279 return Err(LastFmError::Auth(error_msg));
280 }
281 }
282 }
283
284 let has_real_session = self
286 .session_cookies
287 .iter()
288 .any(|cookie| cookie.starts_with("sessionid=.") && cookie.len() > 50);
289
290 if has_real_session && (response.status() == 302 || response.status() == 200) {
291 self.username = username.to_string();
293 self.csrf_token = Some(csrf_token);
294 log::debug!("Login successful - authenticated session established");
295 return Ok(());
296 }
297
298 let response_html = response
300 .body_string()
301 .await
302 .map_err(|e| LastFmError::Http(e.to_string()))?;
303
304 let success_doc = Html::parse_document(&response_html);
306 let login_form_selector =
307 Selector::parse("form[action*=\"login\"], input[name=\"username_or_email\"]").unwrap();
308 let has_login_form = success_doc.select(&login_form_selector).next().is_some();
309
310 if !has_login_form && response.status() == 200 {
311 self.username = username.to_string();
312 self.csrf_token = Some(csrf_token);
313 Ok(())
314 } else {
315 let error_doc = success_doc;
317 let error_selector =
318 Selector::parse(".alert-danger, .form-error, .error-message").unwrap();
319
320 let mut error_messages = Vec::new();
321 for error in error_doc.select(&error_selector) {
322 let error_text = error.text().collect::<String>().trim().to_string();
323 if !error_text.is_empty() {
324 error_messages.push(error_text);
325 }
326 }
327
328 let error_msg = if error_messages.is_empty() {
329 "Login failed - please check your credentials".to_string()
330 } else {
331 format!("Login failed: {}", error_messages.join("; "))
332 };
333
334 Err(LastFmError::Auth(error_msg))
335 }
336 }
337
338 pub fn username(&self) -> &str {
342 &self.username
343 }
344
345 pub fn is_logged_in(&self) -> bool {
349 !self.username.is_empty() && self.csrf_token.is_some()
350 }
351
352 pub fn artist_tracks<'a>(&'a mut self, artist: &str) -> ArtistTracksIterator<'a> {
362 ArtistTracksIterator::new(self, artist.to_string())
363 }
364
365 pub fn artist_albums<'a>(&'a mut self, artist: &str) -> ArtistAlbumsIterator<'a> {
375 ArtistAlbumsIterator::new(self, artist.to_string())
376 }
377
378 pub fn recent_tracks<'a>(&'a mut self) -> RecentTracksIterator<'a> {
387 RecentTracksIterator::new(self)
388 }
389
390 pub async fn get_recent_scrobbles(&mut self, page: u32) -> Result<Vec<Track>> {
393 let url = format!(
394 "{}/user/{}/library?page={}",
395 self.base_url, self.username, page
396 );
397
398 log::debug!("Fetching recent scrobbles page {page}");
399 let mut response = self.get(&url).await?;
400 let content = response
401 .body_string()
402 .await
403 .map_err(|e| LastFmError::Http(e.to_string()))?;
404
405 log::debug!(
406 "Recent scrobbles response: {} status, {} chars",
407 response.status(),
408 content.len()
409 );
410
411 let document = Html::parse_document(&content);
412 self.parser.parse_recent_scrobbles(&document)
413 }
414
415 pub async fn find_recent_scrobble_for_track(
418 &mut self,
419 track_name: &str,
420 artist_name: &str,
421 max_pages: u32,
422 ) -> Result<Option<Track>> {
423 log::debug!("Searching for recent scrobble: '{track_name}' by '{artist_name}'");
424
425 for page in 1..=max_pages {
426 let scrobbles = self.get_recent_scrobbles(page).await?;
427
428 for scrobble in scrobbles {
429 if scrobble.name == track_name && scrobble.artist == artist_name {
430 log::debug!(
431 "Found recent scrobble: '{}' with timestamp {:?}",
432 scrobble.name,
433 scrobble.timestamp
434 );
435 return Ok(Some(scrobble));
436 }
437 }
438
439 }
441
442 log::debug!(
443 "No recent scrobble found for '{track_name}' by '{artist_name}' in {max_pages} pages"
444 );
445 Ok(None)
446 }
447
448 pub async fn edit_scrobble(&mut self, edit: &ScrobbleEdit) -> Result<EditResponse> {
449 self.edit_scrobble_with_retry(edit, 3).await
450 }
451
452 pub async fn edit_scrobble_with_retry(
453 &mut self,
454 edit: &ScrobbleEdit,
455 max_retries: u32,
456 ) -> Result<EditResponse> {
457 let mut retries = 0;
458
459 loop {
460 match self.edit_scrobble_impl(edit).await {
461 Ok(result) => return Ok(result),
462 Err(LastFmError::RateLimit { retry_after }) => {
463 if retries >= max_retries {
464 log::warn!("Max retries ({max_retries}) exceeded for edit operation");
465 return Err(LastFmError::RateLimit { retry_after });
466 }
467
468 let delay = std::cmp::min(retry_after, 2_u64.pow(retries + 1) * 5);
469 log::info!(
470 "Edit rate limited. Waiting {} seconds before retry {} of {}",
471 delay,
472 retries + 1,
473 max_retries
474 );
475 retries += 1;
477 }
478 Err(other_error) => return Err(other_error),
479 }
480 }
481 }
482
483 async fn edit_scrobble_impl(&mut self, edit: &ScrobbleEdit) -> Result<EditResponse> {
484 if !self.is_logged_in() {
485 return Err(LastFmError::Auth(
486 "Must be logged in to edit scrobbles".to_string(),
487 ));
488 }
489
490 let edit_url = format!(
491 "{}/user/{}/library/edit?edited-variation=library-track-scrobble",
492 self.base_url, self.username
493 );
494
495 log::debug!("Getting fresh CSRF token for edit");
496
497 let mut form_response = self.get(&edit_url).await?;
499 let form_html = form_response
500 .body_string()
501 .await
502 .map_err(|e| LastFmError::Http(e.to_string()))?;
503
504 log::debug!("Edit form response status: {}", form_response.status());
505
506 let form_document = Html::parse_document(&form_html);
508 let fresh_csrf_token = self.extract_csrf_token(&form_document)?;
509
510 log::debug!("Submitting edit with fresh token");
511
512 let mut form_data = HashMap::new();
513
514 form_data.insert("csrfmiddlewaretoken", fresh_csrf_token.as_str());
516
517 form_data.insert("track_name_original", &edit.track_name_original);
519 form_data.insert("track_name", &edit.track_name);
520 form_data.insert("artist_name_original", &edit.artist_name_original);
521 form_data.insert("artist_name", &edit.artist_name);
522 form_data.insert("album_name_original", &edit.album_name_original);
523 form_data.insert("album_name", &edit.album_name);
524 form_data.insert(
525 "album_artist_name_original",
526 &edit.album_artist_name_original,
527 );
528 form_data.insert("album_artist_name", &edit.album_artist_name);
529
530 let timestamp_str = edit.timestamp.to_string();
532 form_data.insert("timestamp", ×tamp_str);
533
534 if edit.edit_all {
536 form_data.insert("edit_all", "1");
537 }
538 form_data.insert("submit", "edit-scrobble");
539 form_data.insert("ajax", "1");
540
541 log::debug!(
542 "Editing scrobble: '{}' -> '{}'",
543 edit.track_name_original,
544 edit.track_name
545 );
546 log::trace!("Session cookies count: {}", self.session_cookies.len());
547
548 let mut request = Request::new(Method::Post, edit_url.parse::<Url>().unwrap());
549
550 let _ = request.insert_header("Accept", "*/*");
552 let _ = request.insert_header("Accept-Language", "en-US,en;q=0.9");
553 let _ = request.insert_header(
554 "Content-Type",
555 "application/x-www-form-urlencoded;charset=UTF-8",
556 );
557 let _ = request.insert_header("Priority", "u=1, i");
558 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");
559 let _ = request.insert_header("X-Requested-With", "XMLHttpRequest");
560 let _ = request.insert_header("Sec-Fetch-Dest", "empty");
561 let _ = request.insert_header("Sec-Fetch-Mode", "cors");
562 let _ = request.insert_header("Sec-Fetch-Site", "same-origin");
563 let _ = request.insert_header(
564 "sec-ch-ua",
565 "\"Not)A;Brand\";v=\"8\", \"Chromium\";v=\"138\", \"Google Chrome\";v=\"138\"",
566 );
567 let _ = request.insert_header("sec-ch-ua-mobile", "?0");
568 let _ = request.insert_header("sec-ch-ua-platform", "\"Linux\"");
569
570 if !self.session_cookies.is_empty() {
572 let cookie_header = self.session_cookies.join("; ");
573 let _ = request.insert_header("Cookie", &cookie_header);
574 }
575
576 let _ = request.insert_header(
578 "Referer",
579 format!("{}/user/{}/library", self.base_url, self.username),
580 );
581
582 let form_string: String = form_data
584 .iter()
585 .map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v)))
586 .collect::<Vec<_>>()
587 .join("&");
588
589 request.set_body(form_string);
590
591 let mut response = self
592 .client
593 .send(request)
594 .await
595 .map_err(|e| LastFmError::Http(e.to_string()))?;
596
597 log::debug!("Edit response status: {}", response.status());
598
599 let response_text = response
600 .body_string()
601 .await
602 .map_err(|e| LastFmError::Http(e.to_string()))?;
603
604 let document = Html::parse_document(&response_text);
606
607 let success_selector = Selector::parse(".alert-success").unwrap();
609 let error_selector = Selector::parse(".alert-danger, .alert-error, .error").unwrap();
610
611 let has_success_alert = document.select(&success_selector).next().is_some();
612 let has_error_alert = document.select(&error_selector).next().is_some();
613
614 let mut actual_track_name = None;
617 let mut actual_album_name = None;
618
619 let track_name_selector = Selector::parse("td.chartlist-name a").unwrap();
621 let album_name_selector = Selector::parse("td.chartlist-album a").unwrap();
622
623 if let Some(track_element) = document.select(&track_name_selector).next() {
624 actual_track_name = Some(track_element.text().collect::<String>().trim().to_string());
625 }
626
627 if let Some(album_element) = document.select(&album_name_selector).next() {
628 actual_album_name = Some(album_element.text().collect::<String>().trim().to_string());
629 }
630
631 if actual_track_name.is_none() || actual_album_name.is_none() {
633 let track_pattern = regex::Regex::new(r#"href="/music/[^"]+/_/([^"]+)""#).unwrap();
636 if let Some(captures) = track_pattern.captures(&response_text) {
637 if let Some(track_match) = captures.get(1) {
638 let raw_track = track_match.as_str();
639 let decoded_track = urlencoding::decode(raw_track)
641 .unwrap_or_else(|_| raw_track.into())
642 .replace("+", " ");
643 actual_track_name = Some(decoded_track);
644 }
645 }
646
647 let album_pattern =
650 regex::Regex::new(r#"href="/music/[^"]+/([^"/_]+)"[^>]*>[^<]*</a>"#).unwrap();
651 if let Some(captures) = album_pattern.captures(&response_text) {
652 if let Some(album_match) = captures.get(1) {
653 let raw_album = album_match.as_str();
654 let decoded_album = urlencoding::decode(raw_album)
656 .unwrap_or_else(|_| raw_album.into())
657 .replace("+", " ");
658 actual_album_name = Some(decoded_album);
659 }
660 }
661 }
662
663 log::debug!(
664 "Response analysis: success_alert={}, error_alert={}, track='{}', album='{}'",
665 has_success_alert,
666 has_error_alert,
667 actual_track_name.as_deref().unwrap_or("not found"),
668 actual_album_name.as_deref().unwrap_or("not found")
669 );
670
671 let final_success = response.status().is_success() && has_success_alert && !has_error_alert;
673
674 let message = if has_error_alert {
676 if let Some(error_element) = document.select(&error_selector).next() {
678 Some(format!(
679 "Edit failed: {}",
680 error_element.text().collect::<String>().trim()
681 ))
682 } else {
683 Some("Edit failed with unknown error".to_string())
684 }
685 } else if final_success {
686 Some(format!(
687 "Edit successful - Track: '{}', Album: '{}'",
688 actual_track_name.as_deref().unwrap_or("unknown"),
689 actual_album_name.as_deref().unwrap_or("unknown")
690 ))
691 } else {
692 Some(format!("Edit failed with status: {}", response.status()))
693 };
694
695 Ok(EditResponse {
696 success: final_success,
697 message,
698 })
699 }
700
701 pub async fn load_edit_form_values(
704 &mut self,
705 track_name: &str,
706 artist_name: &str,
707 ) -> Result<crate::ScrobbleEdit> {
708 log::debug!("Loading edit form values for '{track_name}' by '{artist_name}'");
709
710 let track_url = format!(
714 "{}/user/{}/library/music/+noredirect/{}/_/{}",
715 self.base_url,
716 self.username,
717 urlencoding::encode(artist_name),
718 urlencoding::encode(track_name)
719 );
720
721 log::debug!("Fetching track page: {track_url}");
722
723 let mut response = self.get(&track_url).await?;
724 let html = response
725 .body_string()
726 .await
727 .map_err(|e| crate::LastFmError::Http(e.to_string()))?;
728
729 let document = Html::parse_document(&html);
730
731 self.extract_scrobble_data_from_track_page(&document, track_name, artist_name)
733 }
734
735 fn extract_scrobble_data_from_track_page(
738 &self,
739 document: &Html,
740 expected_track: &str,
741 expected_artist: &str,
742 ) -> Result<crate::ScrobbleEdit> {
743 let table_selector =
745 Selector::parse("table.chartlist:not(.chartlist__placeholder)").unwrap();
746 let table = document.select(&table_selector).next().ok_or_else(|| {
747 crate::LastFmError::Parse("No chartlist table found on track page".to_string())
748 })?;
749
750 let row_selector = Selector::parse("tr").unwrap();
752 for row in table.select(&row_selector) {
753 let count_bar_link_selector = Selector::parse(".chartlist-count-bar-link").unwrap();
755 if row.select(&count_bar_link_selector).next().is_some() {
756 log::debug!("Found count bar link, skipping aggregated row");
757 continue;
758 }
759
760 let form_selector = Selector::parse("form[data-edit-scrobble]").unwrap();
762 if let Some(form) = row.select(&form_selector).next() {
763 let extract_form_value = |name: &str| -> Option<String> {
765 let selector = Selector::parse(&format!("input[name='{name}']")).unwrap();
766 form.select(&selector)
767 .next()
768 .and_then(|input| input.value().attr("value"))
769 .map(|s| s.to_string())
770 };
771
772 let form_track = extract_form_value("track_name").unwrap_or_default();
774 let form_artist = extract_form_value("artist_name").unwrap_or_default();
775 let form_album = extract_form_value("album_name").unwrap_or_default();
776 let form_album_artist =
777 extract_form_value("album_artist_name").unwrap_or_else(|| form_artist.clone());
778 let form_timestamp = extract_form_value("timestamp").unwrap_or_default();
779
780 log::debug!(
781 "Found scrobble form - Track: '{form_track}', Artist: '{form_artist}', Album: '{form_album}', Timestamp: {form_timestamp}"
782 );
783
784 if form_track == expected_track && form_artist == expected_artist {
786 let timestamp = form_timestamp.parse::<u64>().map_err(|_| {
787 crate::LastFmError::Parse("Invalid timestamp in form".to_string())
788 })?;
789
790 log::debug!(
791 "✅ Found matching scrobble form for '{expected_track}' by '{expected_artist}'"
792 );
793
794 return Ok(crate::ScrobbleEdit::new(
796 form_track.clone(),
797 form_album.clone(),
798 form_artist.clone(),
799 form_album_artist.clone(),
800 form_track,
801 form_album,
802 form_artist,
803 form_album_artist,
804 timestamp,
805 true,
806 ));
807 }
808 }
809 }
810
811 Err(crate::LastFmError::Parse(format!(
812 "No scrobble form found for track '{expected_track}' by '{expected_artist}'"
813 )))
814 }
815
816 pub async fn get_album_tracks(
819 &mut self,
820 album_name: &str,
821 artist_name: &str,
822 ) -> Result<Vec<Track>> {
823 log::debug!("Getting tracks from album '{album_name}' by '{artist_name}'");
824
825 let album_url = format!(
827 "{}/user/{}/library/music/{}/{}",
828 self.base_url,
829 self.username,
830 urlencoding::encode(artist_name),
831 urlencoding::encode(album_name)
832 );
833
834 log::debug!("Fetching album page: {album_url}");
835
836 let mut response = self.get(&album_url).await?;
837 let html = response
838 .body_string()
839 .await
840 .map_err(|e| LastFmError::Http(e.to_string()))?;
841
842 let document = Html::parse_document(&html);
843
844 let tracks = self
846 .parser
847 .extract_tracks_from_document(&document, artist_name)?;
848
849 log::debug!(
850 "Successfully parsed {} tracks from album page",
851 tracks.len()
852 );
853 Ok(tracks)
854 }
855
856 pub async fn edit_album(
859 &mut self,
860 old_album_name: &str,
861 new_album_name: &str,
862 artist_name: &str,
863 ) -> Result<EditResponse> {
864 log::debug!("Editing album '{old_album_name}' -> '{new_album_name}' by '{artist_name}'");
865
866 let tracks = self.get_album_tracks(old_album_name, artist_name).await?;
868
869 if tracks.is_empty() {
870 return Ok(EditResponse {
871 success: false,
872 message: Some(format!(
873 "No tracks found for album '{old_album_name}' by '{artist_name}'. Make sure the album name matches exactly."
874 )),
875 });
876 }
877
878 log::info!(
879 "Found {} tracks in album '{}'",
880 tracks.len(),
881 old_album_name
882 );
883
884 let mut successful_edits = 0;
885 let mut failed_edits = 0;
886 let mut error_messages = Vec::new();
887 let mut skipped_tracks = 0;
888
889 for (index, track) in tracks.iter().enumerate() {
891 log::debug!(
892 "Processing track {}/{}: '{}'",
893 index + 1,
894 tracks.len(),
895 track.name
896 );
897
898 match self.load_edit_form_values(&track.name, artist_name).await {
899 Ok(mut edit_data) => {
900 edit_data.album_name = new_album_name.to_string();
902
903 match self.edit_scrobble(&edit_data).await {
905 Ok(response) => {
906 if response.success {
907 successful_edits += 1;
908 log::info!("✅ Successfully edited track '{}'", track.name);
909 } else {
910 failed_edits += 1;
911 let error_msg = format!(
912 "Failed to edit track '{}': {}",
913 track.name,
914 response
915 .message
916 .unwrap_or_else(|| "Unknown error".to_string())
917 );
918 error_messages.push(error_msg);
919 log::debug!("❌ {}", error_messages.last().unwrap());
920 }
921 }
922 Err(e) => {
923 failed_edits += 1;
924 let error_msg = format!("Error editing track '{}': {}", track.name, e);
925 error_messages.push(error_msg);
926 log::info!("❌ {}", error_messages.last().unwrap());
927 }
928 }
929 }
930 Err(e) => {
931 skipped_tracks += 1;
932 log::debug!("Could not load edit form for track '{}': {e}", track.name);
933 }
935 }
936
937 }
939
940 let total_processed = successful_edits + failed_edits;
941 let success = successful_edits > 0 && failed_edits == 0;
942
943 let message = if success {
944 Some(format!(
945 "Successfully renamed album '{old_album_name}' to '{new_album_name}' for all {successful_edits} editable tracks ({skipped_tracks} tracks were not in recent scrobbles)"
946 ))
947 } else if successful_edits > 0 {
948 Some(format!(
949 "Partially successful: {} of {} editable tracks renamed ({} skipped, {} failed). Errors: {}",
950 successful_edits,
951 total_processed,
952 skipped_tracks,
953 failed_edits,
954 error_messages.join("; ")
955 ))
956 } else if total_processed == 0 {
957 Some(format!(
958 "No editable tracks found for album '{}' by '{}'. All {} tracks were skipped because they're not in recent scrobbles.",
959 old_album_name, artist_name, tracks.len()
960 ))
961 } else {
962 Some(format!(
963 "Failed to rename any tracks. Errors: {}",
964 error_messages.join("; ")
965 ))
966 };
967
968 Ok(EditResponse { success, message })
969 }
970
971 pub async fn edit_artist(
974 &mut self,
975 old_artist_name: &str,
976 new_artist_name: &str,
977 ) -> Result<EditResponse> {
978 log::debug!("Editing artist '{old_artist_name}' -> '{new_artist_name}'");
979
980 let mut tracks = Vec::new();
982 let mut iterator = self.artist_tracks(old_artist_name);
983
984 while tracks.len() < 200 {
986 match iterator.next().await {
987 Ok(Some(track)) => tracks.push(track),
988 Ok(None) => break,
989 Err(e) => {
990 log::warn!("Error fetching artist tracks: {e}");
991 break;
992 }
993 }
994 }
995
996 if tracks.is_empty() {
997 return Ok(EditResponse {
998 success: false,
999 message: Some(format!(
1000 "No tracks found for artist '{old_artist_name}'. Make sure the artist name matches exactly."
1001 )),
1002 });
1003 }
1004
1005 log::info!(
1006 "Found {} tracks for artist '{}'",
1007 tracks.len(),
1008 old_artist_name
1009 );
1010
1011 let mut successful_edits = 0;
1012 let mut failed_edits = 0;
1013 let mut error_messages = Vec::new();
1014 let mut skipped_tracks = 0;
1015
1016 for (index, track) in tracks.iter().enumerate() {
1018 log::debug!(
1019 "Processing track {}/{}: '{}'",
1020 index + 1,
1021 tracks.len(),
1022 track.name
1023 );
1024
1025 match self
1026 .load_edit_form_values(&track.name, old_artist_name)
1027 .await
1028 {
1029 Ok(mut edit_data) => {
1030 edit_data.artist_name = new_artist_name.to_string();
1032 edit_data.album_artist_name = new_artist_name.to_string();
1033
1034 match self.edit_scrobble(&edit_data).await {
1036 Ok(response) => {
1037 if response.success {
1038 successful_edits += 1;
1039 log::info!("✅ Successfully edited track '{}'", track.name);
1040 } else {
1041 failed_edits += 1;
1042 let error_msg = format!(
1043 "Failed to edit track '{}': {}",
1044 track.name,
1045 response
1046 .message
1047 .unwrap_or_else(|| "Unknown error".to_string())
1048 );
1049 error_messages.push(error_msg);
1050 log::debug!("❌ {}", error_messages.last().unwrap());
1051 }
1052 }
1053 Err(e) => {
1054 failed_edits += 1;
1055 let error_msg = format!("Error editing track '{}': {}", track.name, e);
1056 error_messages.push(error_msg);
1057 log::info!("❌ {}", error_messages.last().unwrap());
1058 }
1059 }
1060 }
1061 Err(e) => {
1062 skipped_tracks += 1;
1063 log::debug!("Could not load edit form for track '{}': {e}", track.name);
1064 }
1066 }
1067
1068 }
1070
1071 let total_processed = successful_edits + failed_edits;
1072 let success = successful_edits > 0 && failed_edits == 0;
1073
1074 let message = if success {
1075 Some(format!(
1076 "Successfully renamed artist '{old_artist_name}' to '{new_artist_name}' for all {successful_edits} editable tracks ({skipped_tracks} tracks were not in recent scrobbles)"
1077 ))
1078 } else if successful_edits > 0 {
1079 Some(format!(
1080 "Partially successful: {} of {} editable tracks renamed ({} skipped, {} failed). Errors: {}",
1081 successful_edits,
1082 total_processed,
1083 skipped_tracks,
1084 failed_edits,
1085 error_messages.join("; ")
1086 ))
1087 } else if total_processed == 0 {
1088 Some(format!(
1089 "No editable tracks found for artist '{}'. All {} tracks were skipped because they're not in recent scrobbles.",
1090 old_artist_name, tracks.len()
1091 ))
1092 } else {
1093 Some(format!(
1094 "Failed to rename any tracks. Errors: {}",
1095 error_messages.join("; ")
1096 ))
1097 };
1098
1099 Ok(EditResponse { success, message })
1100 }
1101
1102 pub async fn edit_artist_for_track(
1105 &mut self,
1106 track_name: &str,
1107 old_artist_name: &str,
1108 new_artist_name: &str,
1109 ) -> Result<EditResponse> {
1110 log::debug!("Editing artist for track '{track_name}' from '{old_artist_name}' -> '{new_artist_name}'");
1111
1112 match self.load_edit_form_values(track_name, old_artist_name).await {
1113 Ok(mut edit_data) => {
1114 edit_data.artist_name = new_artist_name.to_string();
1116 edit_data.album_artist_name = new_artist_name.to_string();
1117
1118 log::info!("Updating artist for track '{track_name}' from '{old_artist_name}' to '{new_artist_name}'");
1119
1120 match self.edit_scrobble(&edit_data).await {
1122 Ok(response) => {
1123 if response.success {
1124 Ok(EditResponse {
1125 success: true,
1126 message: Some(format!(
1127 "Successfully renamed artist for track '{track_name}' from '{old_artist_name}' to '{new_artist_name}'"
1128 )),
1129 })
1130 } else {
1131 Ok(EditResponse {
1132 success: false,
1133 message: Some(format!(
1134 "Failed to rename artist for track '{track_name}': {}",
1135 response.message.unwrap_or_else(|| "Unknown error".to_string())
1136 )),
1137 })
1138 }
1139 }
1140 Err(e) => Ok(EditResponse {
1141 success: false,
1142 message: Some(format!("Error editing track '{track_name}': {e}")),
1143 }),
1144 }
1145 }
1146 Err(e) => Ok(EditResponse {
1147 success: false,
1148 message: Some(format!(
1149 "Could not load edit form for track '{track_name}' by '{old_artist_name}': {e}. The track may not be in your recent scrobbles."
1150 )),
1151 }),
1152 }
1153 }
1154
1155 pub async fn edit_artist_for_album(
1158 &mut self,
1159 album_name: &str,
1160 old_artist_name: &str,
1161 new_artist_name: &str,
1162 ) -> Result<EditResponse> {
1163 log::debug!("Editing artist for album '{album_name}' from '{old_artist_name}' -> '{new_artist_name}'");
1164
1165 let tracks = self.get_album_tracks(album_name, old_artist_name).await?;
1167
1168 if tracks.is_empty() {
1169 return Ok(EditResponse {
1170 success: false,
1171 message: Some(format!(
1172 "No tracks found for album '{album_name}' by '{old_artist_name}'. Make sure the album name matches exactly."
1173 )),
1174 });
1175 }
1176
1177 log::info!(
1178 "Found {} tracks in album '{}' by '{}'",
1179 tracks.len(),
1180 album_name,
1181 old_artist_name
1182 );
1183
1184 let mut successful_edits = 0;
1185 let mut failed_edits = 0;
1186 let mut error_messages = Vec::new();
1187 let mut skipped_tracks = 0;
1188
1189 for (index, track) in tracks.iter().enumerate() {
1191 log::debug!(
1192 "Processing track {}/{}: '{}'",
1193 index + 1,
1194 tracks.len(),
1195 track.name
1196 );
1197
1198 match self
1199 .load_edit_form_values(&track.name, old_artist_name)
1200 .await
1201 {
1202 Ok(mut edit_data) => {
1203 edit_data.artist_name = new_artist_name.to_string();
1205 edit_data.album_artist_name = new_artist_name.to_string();
1206
1207 match self.edit_scrobble(&edit_data).await {
1209 Ok(response) => {
1210 if response.success {
1211 successful_edits += 1;
1212 log::info!("✅ Successfully edited track '{}'", track.name);
1213 } else {
1214 failed_edits += 1;
1215 let error_msg = format!(
1216 "Failed to edit track '{}': {}",
1217 track.name,
1218 response
1219 .message
1220 .unwrap_or_else(|| "Unknown error".to_string())
1221 );
1222 error_messages.push(error_msg);
1223 log::debug!("❌ {}", error_messages.last().unwrap());
1224 }
1225 }
1226 Err(e) => {
1227 failed_edits += 1;
1228 let error_msg = format!("Error editing track '{}': {}", track.name, e);
1229 error_messages.push(error_msg);
1230 log::info!("❌ {}", error_messages.last().unwrap());
1231 }
1232 }
1233 }
1234 Err(e) => {
1235 skipped_tracks += 1;
1236 log::debug!("Could not load edit form for track '{}': {e}", track.name);
1237 }
1239 }
1240
1241 }
1243
1244 let total_processed = successful_edits + failed_edits;
1245 let success = successful_edits > 0 && failed_edits == 0;
1246
1247 let message = if success {
1248 Some(format!(
1249 "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)"
1250 ))
1251 } else if successful_edits > 0 {
1252 Some(format!(
1253 "Partially successful: {} of {} editable tracks renamed ({} skipped, {} failed). Errors: {}",
1254 successful_edits,
1255 total_processed,
1256 skipped_tracks,
1257 failed_edits,
1258 error_messages.join("; ")
1259 ))
1260 } else if total_processed == 0 {
1261 Some(format!(
1262 "No editable tracks found for album '{album_name}' by '{old_artist_name}'. All {} tracks were skipped because they're not in recent scrobbles.",
1263 tracks.len()
1264 ))
1265 } else {
1266 Some(format!(
1267 "Failed to rename any tracks. Errors: {}",
1268 error_messages.join("; ")
1269 ))
1270 };
1271
1272 Ok(EditResponse { success, message })
1273 }
1274
1275 pub async fn get_artist_tracks_page(&mut self, artist: &str, page: u32) -> Result<TrackPage> {
1276 let url = format!(
1278 "{}/user/{}/library/music/{}/+tracks?page={}&ajax=true",
1279 self.base_url,
1280 self.username,
1281 artist.replace(" ", "+"),
1282 page
1283 );
1284
1285 log::debug!("Fetching tracks page {page} for artist: {artist}");
1286 let mut response = self.get(&url).await?;
1287 let content = response
1288 .body_string()
1289 .await
1290 .map_err(|e| LastFmError::Http(e.to_string()))?;
1291
1292 log::debug!(
1293 "AJAX response: {} status, {} chars",
1294 response.status(),
1295 content.len()
1296 );
1297
1298 if content.trim_start().starts_with("{") || content.trim_start().starts_with("[") {
1300 log::debug!("Parsing JSON response from AJAX endpoint");
1301 self.parse_json_tracks_page(&content, page, artist)
1302 } else {
1303 log::debug!("Parsing HTML response from AJAX endpoint");
1304 let document = Html::parse_document(&content);
1305 self.parser.parse_tracks_page(&document, page, artist)
1306 }
1307 }
1308
1309 fn parse_json_tracks_page(
1311 &self,
1312 _json_content: &str,
1313 page_number: u32,
1314 _artist: &str,
1315 ) -> Result<TrackPage> {
1316 log::debug!("JSON parsing not implemented, returning empty page");
1318 Ok(TrackPage {
1319 tracks: Vec::new(),
1320 page_number,
1321 has_next_page: false,
1322 total_pages: Some(1),
1323 })
1324 }
1325
1326 pub fn extract_tracks_from_document(
1328 &self,
1329 document: &Html,
1330 artist: &str,
1331 ) -> Result<Vec<Track>> {
1332 self.parser.extract_tracks_from_document(document, artist)
1333 }
1334
1335 pub fn parse_tracks_page(
1337 &self,
1338 document: &Html,
1339 page_number: u32,
1340 artist: &str,
1341 ) -> Result<TrackPage> {
1342 self.parser.parse_tracks_page(document, page_number, artist)
1343 }
1344
1345 pub fn parse_recent_scrobbles(&self, document: &Html) -> Result<Vec<Track>> {
1347 self.parser.parse_recent_scrobbles(document)
1348 }
1349
1350 fn extract_csrf_token(&self, document: &Html) -> Result<String> {
1351 let csrf_selector = Selector::parse("input[name=\"csrfmiddlewaretoken\"]").unwrap();
1352
1353 document
1354 .select(&csrf_selector)
1355 .next()
1356 .and_then(|input| input.value().attr("value"))
1357 .map(|token| token.to_string())
1358 .ok_or(LastFmError::CsrfNotFound)
1359 }
1360
1361 pub async fn get(&mut self, url: &str) -> Result<Response> {
1363 self.get_with_retry(url, 3).await
1364 }
1365
1366 async fn get_with_retry(&mut self, url: &str, max_retries: u32) -> Result<Response> {
1368 let mut retries = 0;
1369
1370 loop {
1371 match self.get_with_redirects(url, 0).await {
1372 Ok(mut response) => {
1373 let body = self.extract_response_body(url, &mut response).await?;
1375
1376 if response.status().is_success() && self.is_rate_limit_response(&body) {
1378 log::debug!("Response body contains rate limit patterns");
1379 if retries < max_retries {
1380 let delay = 60 + (retries as u64 * 30); log::info!("Rate limit detected in response body, retrying in {delay}s (attempt {}/{max_retries})", retries + 1);
1382 retries += 1;
1384 continue;
1385 } else {
1386 return Err(crate::LastFmError::RateLimit { retry_after: 60 });
1387 }
1388 }
1389
1390 let mut new_response = http_types::Response::new(response.status());
1392 for (name, values) in response.iter() {
1393 for value in values {
1394 let _ = new_response.insert_header(name.clone(), value.clone());
1395 }
1396 }
1397 new_response.set_body(body);
1398
1399 return Ok(new_response);
1400 }
1401 Err(crate::LastFmError::RateLimit { retry_after }) => {
1402 if retries < max_retries {
1403 let delay = retry_after + (retries as u64 * 30); log::info!(
1405 "Rate limit detected, retrying in {delay}s (attempt {}/{max_retries})",
1406 retries + 1
1407 );
1408 retries += 1;
1410 } else {
1411 return Err(crate::LastFmError::RateLimit { retry_after });
1412 }
1413 }
1414 Err(e) => return Err(e),
1415 }
1416 }
1417 }
1418
1419 async fn get_with_redirects(&mut self, url: &str, redirect_count: u32) -> Result<Response> {
1420 if redirect_count > 5 {
1421 return Err(LastFmError::Http("Too many redirects".to_string()));
1422 }
1423
1424 let mut request = Request::new(Method::Get, url.parse::<Url>().unwrap());
1425 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");
1426
1427 if !self.session_cookies.is_empty() {
1429 let cookie_header = self.session_cookies.join("; ");
1430 let _ = request.insert_header("Cookie", &cookie_header);
1431 } else if url.contains("page=") {
1432 log::debug!("No cookies available for paginated request!");
1433 }
1434
1435 if url.contains("ajax=true") {
1437 let _ = request.insert_header("Accept", "*/*");
1439 let _ = request.insert_header("X-Requested-With", "XMLHttpRequest");
1440 } else {
1441 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");
1443 }
1444 let _ = request.insert_header("Accept-Language", "en-US,en;q=0.9");
1445 let _ = request.insert_header("Accept-Encoding", "gzip, deflate, br");
1446 let _ = request.insert_header("DNT", "1");
1447 let _ = request.insert_header("Connection", "keep-alive");
1448 let _ = request.insert_header("Upgrade-Insecure-Requests", "1");
1449
1450 if url.contains("page=") {
1452 let base_url = url.split('?').next().unwrap_or(url);
1453 let _ = request.insert_header("Referer", base_url);
1454 }
1455
1456 let response = self
1457 .client
1458 .send(request)
1459 .await
1460 .map_err(|e| LastFmError::Http(e.to_string()))?;
1461
1462 self.extract_cookies(&response);
1464
1465 if response.status() == 302 || response.status() == 301 {
1467 if let Some(location) = response.header("location") {
1468 if let Some(redirect_url) = location.get(0) {
1469 let redirect_url_str = redirect_url.as_str();
1470 if url.contains("page=") {
1471 log::debug!("Following redirect from {url} to {redirect_url_str}");
1472
1473 if redirect_url_str.contains("/login") {
1475 log::debug!("Redirect to login page - authentication failed for paginated request");
1476 return Err(LastFmError::Auth(
1477 "Session expired or invalid for paginated request".to_string(),
1478 ));
1479 }
1480 }
1481
1482 let full_redirect_url = if redirect_url_str.starts_with('/') {
1484 format!("{}{redirect_url_str}", self.base_url)
1485 } else if redirect_url_str.starts_with("http") {
1486 redirect_url_str.to_string()
1487 } else {
1488 let base_url = url
1490 .rsplit('/')
1491 .skip(1)
1492 .collect::<Vec<_>>()
1493 .into_iter()
1494 .rev()
1495 .collect::<Vec<_>>()
1496 .join("/");
1497 format!("{base_url}/{redirect_url_str}")
1498 };
1499
1500 return Box::pin(
1502 self.get_with_redirects(&full_redirect_url, redirect_count + 1),
1503 )
1504 .await;
1505 }
1506 }
1507 }
1508
1509 if response.status() == 429 {
1511 let retry_after = response
1512 .header("retry-after")
1513 .and_then(|h| h.get(0))
1514 .and_then(|v| v.as_str().parse::<u64>().ok())
1515 .unwrap_or(60);
1516 return Err(LastFmError::RateLimit { retry_after });
1517 }
1518
1519 if response.status() == 403 {
1521 log::debug!("Got 403 response, checking if it's a rate limit");
1522 if !self.session_cookies.is_empty() {
1524 log::debug!("403 on authenticated request - likely rate limit");
1525 return Err(LastFmError::RateLimit { retry_after: 60 });
1526 }
1527 }
1528
1529 Ok(response)
1530 }
1531
1532 fn is_rate_limit_response(&self, response_body: &str) -> bool {
1534 let body_lower = response_body.to_lowercase();
1535
1536 for pattern in &self.rate_limit_patterns {
1538 if body_lower.contains(&pattern.to_lowercase()) {
1539 return true;
1540 }
1541 }
1542
1543 false
1544 }
1545
1546 fn extract_cookies(&mut self, response: &Response) {
1547 if let Some(cookie_headers) = response.header("set-cookie") {
1549 let mut new_cookies = 0;
1550 for cookie_header in cookie_headers {
1551 let cookie_str = cookie_header.as_str();
1552 if let Some(cookie_value) = cookie_str.split(';').next() {
1554 let cookie_name = cookie_value.split('=').next().unwrap_or("");
1555
1556 self.session_cookies
1558 .retain(|existing| !existing.starts_with(&format!("{cookie_name}=")));
1559
1560 self.session_cookies.push(cookie_value.to_string());
1561 new_cookies += 1;
1562 }
1563 }
1564 if new_cookies > 0 {
1565 log::trace!(
1566 "Extracted {} new cookies, total: {}",
1567 new_cookies,
1568 self.session_cookies.len()
1569 );
1570 log::trace!("Updated cookies: {:?}", &self.session_cookies);
1571
1572 for cookie in &self.session_cookies {
1574 if cookie.starts_with("sessionid=") {
1575 log::trace!("Current sessionid: {}", &cookie[10..50.min(cookie.len())]);
1576 break;
1577 }
1578 }
1579 }
1580 }
1581 }
1582
1583 async fn extract_response_body(&self, url: &str, response: &mut Response) -> Result<String> {
1585 let body = response
1586 .body_string()
1587 .await
1588 .map_err(|e| LastFmError::Http(e.to_string()))?;
1589
1590 if self.debug_save_responses {
1591 self.save_debug_response(url, response.status().into(), &body);
1592 }
1593
1594 Ok(body)
1595 }
1596
1597 fn save_debug_response(&self, url: &str, status_code: u16, body: &str) {
1599 if let Err(e) = self.try_save_debug_response(url, status_code, body) {
1600 log::warn!("Failed to save debug response: {e}");
1601 }
1602 }
1603
1604 fn try_save_debug_response(&self, url: &str, status_code: u16, body: &str) -> Result<()> {
1606 let debug_dir = Path::new("debug_responses");
1608 if !debug_dir.exists() {
1609 fs::create_dir_all(debug_dir)
1610 .map_err(|e| LastFmError::Http(format!("Failed to create debug directory: {e}")))?;
1611 }
1612
1613 let url_path = if url.starts_with(&self.base_url) {
1615 &url[self.base_url.len()..]
1616 } else {
1617 url
1618 };
1619
1620 let now = chrono::Utc::now();
1622 let timestamp = now.format("%Y%m%d_%H%M%S_%3f");
1623 let safe_path = url_path.replace(['/', '?', '&', '=', '%', '+'], "_");
1624
1625 let filename = format!("{timestamp}_{safe_path}_status{status_code}.html");
1626 let file_path = debug_dir.join(filename);
1627
1628 fs::write(&file_path, body)
1630 .map_err(|e| LastFmError::Http(format!("Failed to write debug file: {e}")))?;
1631
1632 log::debug!(
1633 "Saved HTTP response to {file_path:?} (status: {status_code}, url: {url_path})"
1634 );
1635
1636 Ok(())
1637 }
1638
1639 pub async fn get_artist_albums_page(&mut self, artist: &str, page: u32) -> Result<AlbumPage> {
1640 let url = format!(
1642 "{}/user/{}/library/music/{}/+albums?page={}&ajax=true",
1643 self.base_url,
1644 self.username,
1645 artist.replace(" ", "+"),
1646 page
1647 );
1648
1649 log::debug!("Fetching albums page {page} for artist: {artist}");
1650 let mut response = self.get(&url).await?;
1651 let content = response
1652 .body_string()
1653 .await
1654 .map_err(|e| LastFmError::Http(e.to_string()))?;
1655
1656 log::debug!(
1657 "AJAX response: {} status, {} chars",
1658 response.status(),
1659 content.len()
1660 );
1661
1662 if content.trim_start().starts_with("{") || content.trim_start().starts_with("[") {
1664 log::debug!("Parsing JSON response from AJAX endpoint");
1665 self.parse_json_albums_page(&content, page, artist)
1666 } else {
1667 log::debug!("Parsing HTML response from AJAX endpoint");
1668 let document = Html::parse_document(&content);
1669 self.parser.parse_albums_page(&document, page, artist)
1670 }
1671 }
1672
1673 fn parse_json_albums_page(
1674 &self,
1675 _json_content: &str,
1676 page_number: u32,
1677 _artist: &str,
1678 ) -> Result<AlbumPage> {
1679 log::debug!("JSON parsing not implemented, returning empty page");
1681 Ok(AlbumPage {
1682 albums: Vec::new(),
1683 page_number,
1684 has_next_page: false,
1685 total_pages: Some(1),
1686 })
1687 }
1688}