1use crate::edit::{ExactScrobbleEdit, SingleEditResponse};
2use crate::parsing::LastFmParser;
3use crate::r#trait::LastFmEditClient;
4use crate::session::LastFmEditSession;
5use crate::{AlbumPage, EditResponse, LastFmError, Result, ScrobbleEdit, Track, TrackPage};
6use async_trait::async_trait;
7use http_client::{HttpClient, Request, Response};
8use http_types::{Method, Url};
9use scraper::{Html, Selector};
10use std::collections::HashMap;
11use std::fs;
12use std::path::Path;
13use std::sync::{Arc, Mutex};
14
15#[derive(Clone)]
21pub struct LastFmEditClientImpl {
22 client: Arc<dyn HttpClient + Send + Sync>,
23 session: Arc<Mutex<LastFmEditSession>>,
24 rate_limit_patterns: Vec<String>,
25 debug_save_responses: bool,
26 parser: LastFmParser,
27}
28
29impl LastFmEditClientImpl {
30 pub fn new(client: Box<dyn HttpClient + Send + Sync>) -> Self {
40 Self::with_base_url(client, "https://www.last.fm".to_string())
41 }
42
43 pub fn with_base_url(client: Box<dyn HttpClient + Send + Sync>, base_url: String) -> Self {
55 Self::with_rate_limit_patterns(
56 client,
57 base_url,
58 vec![
59 "you've tried to log in too many times".to_string(),
60 "you're requesting too many pages".to_string(),
61 "slow down".to_string(),
62 "too fast".to_string(),
63 "rate limit".to_string(),
64 "throttled".to_string(),
65 "temporarily blocked".to_string(),
66 "temporarily restricted".to_string(),
67 "captcha".to_string(),
68 "verify you're human".to_string(),
69 "prove you're not a robot".to_string(),
70 "security check".to_string(),
71 "service temporarily unavailable".to_string(),
72 "quota exceeded".to_string(),
73 "limit exceeded".to_string(),
74 "daily limit".to_string(),
75 ],
76 )
77 }
78
79 pub fn with_rate_limit_patterns(
87 client: Box<dyn HttpClient + Send + Sync>,
88 base_url: String,
89 rate_limit_patterns: Vec<String>,
90 ) -> Self {
91 Self {
92 client: Arc::from(client),
93 session: Arc::new(Mutex::new(LastFmEditSession::new(
94 String::new(),
95 Vec::new(),
96 None,
97 base_url,
98 ))),
99 rate_limit_patterns,
100 debug_save_responses: std::env::var("LASTFM_DEBUG_SAVE_RESPONSES").is_ok(),
101 parser: LastFmParser::new(),
102 }
103 }
104
105 pub async fn login_with_credentials(
120 client: Box<dyn HttpClient + Send + Sync>,
121 username: &str,
122 password: &str,
123 ) -> Result<Self> {
124 let new_client = Self::new(client);
125 new_client.login(username, password).await?;
126 Ok(new_client)
127 }
128
129 pub fn from_session(
143 client: Box<dyn HttpClient + Send + Sync>,
144 session: LastFmEditSession,
145 ) -> Self {
146 Self {
147 client: Arc::from(client),
148 session: Arc::new(Mutex::new(session)),
149 rate_limit_patterns: vec![
150 "you've tried to log in too many times".to_string(),
151 "you're requesting too many pages".to_string(),
152 "slow down".to_string(),
153 "too fast".to_string(),
154 "rate limit".to_string(),
155 "throttled".to_string(),
156 "temporarily blocked".to_string(),
157 "temporarily restricted".to_string(),
158 "captcha".to_string(),
159 "verify you're human".to_string(),
160 "prove you're not a robot".to_string(),
161 "security check".to_string(),
162 "service temporarily unavailable".to_string(),
163 "quota exceeded".to_string(),
164 "limit exceeded".to_string(),
165 "daily limit".to_string(),
166 ],
167 debug_save_responses: std::env::var("LASTFM_DEBUG_SAVE_RESPONSES").is_ok(),
168 parser: LastFmParser::new(),
169 }
170 }
171
172 pub fn get_session(&self) -> LastFmEditSession {
182 self.session.lock().unwrap().clone()
183 }
184
185 pub fn restore_session(&self, session: LastFmEditSession) {
194 *self.session.lock().unwrap() = session;
195 }
196
197 pub async fn login(&self, username: &str, password: &str) -> Result<()> {
215 let login_url = {
217 let session = self.session.lock().unwrap();
218 format!("{}/login", session.base_url)
219 };
220 let mut response = self.get(&login_url).await?;
221
222 self.extract_cookies(&response);
224
225 let html = response
226 .body_string()
227 .await
228 .map_err(|e| LastFmError::Http(e.to_string()))?;
229
230 let (csrf_token, next_field) = self.extract_login_form_data(&html)?;
232
233 let mut form_data = HashMap::new();
235 form_data.insert("csrfmiddlewaretoken", csrf_token.as_str());
236 form_data.insert("username_or_email", username);
237 form_data.insert("password", password);
238
239 if let Some(ref next_value) = next_field {
241 form_data.insert("next", next_value);
242 }
243
244 let mut request = Request::new(Method::Post, login_url.parse::<Url>().unwrap());
245 let _ = request.insert_header("Referer", &login_url);
246 {
247 let session = self.session.lock().unwrap();
248 let _ = request.insert_header("Origin", &session.base_url);
249 }
250 let _ = request.insert_header("Content-Type", "application/x-www-form-urlencoded");
251 let _ = request.insert_header(
252 "User-Agent",
253 "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36"
254 );
255 let _ = request.insert_header(
256 "Accept",
257 "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"
258 );
259 let _ = request.insert_header("Accept-Language", "en-US,en;q=0.9");
260 let _ = request.insert_header("Accept-Encoding", "gzip, deflate, br");
261 let _ = request.insert_header("DNT", "1");
262 let _ = request.insert_header("Connection", "keep-alive");
263 let _ = request.insert_header("Upgrade-Insecure-Requests", "1");
264 let _ = request.insert_header(
265 "sec-ch-ua",
266 "\"Not)A;Brand\";v=\"8\", \"Chromium\";v=\"138\", \"Google Chrome\";v=\"138\"",
267 );
268 let _ = request.insert_header("sec-ch-ua-mobile", "?0");
269 let _ = request.insert_header("sec-ch-ua-platform", "\"Linux\"");
270 let _ = request.insert_header("Sec-Fetch-Dest", "document");
271 let _ = request.insert_header("Sec-Fetch-Mode", "navigate");
272 let _ = request.insert_header("Sec-Fetch-Site", "same-origin");
273 let _ = request.insert_header("Sec-Fetch-User", "?1");
274
275 {
277 let session = self.session.lock().unwrap();
278 if !session.cookies.is_empty() {
279 let cookie_header = session.cookies.join("; ");
280 let _ = request.insert_header("Cookie", &cookie_header);
281 }
282 }
283
284 let form_string: String = form_data
286 .iter()
287 .map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v)))
288 .collect::<Vec<_>>()
289 .join("&");
290
291 request.set_body(form_string);
292
293 let mut response = self
294 .client
295 .send(request)
296 .await
297 .map_err(|e| LastFmError::Http(e.to_string()))?;
298
299 self.extract_cookies(&response);
301
302 log::debug!("Login response status: {}", response.status());
303
304 if response.status() == 403 {
306 let response_html = response
308 .body_string()
309 .await
310 .map_err(|e| LastFmError::Http(e.to_string()))?;
311
312 if self.is_rate_limit_response(&response_html) {
314 log::debug!("403 response appears to be rate limiting");
315 return Err(LastFmError::RateLimit { retry_after: 60 });
316 }
317 log::debug!("403 response appears to be authentication failure");
318
319 let login_error = self.parse_login_error(&response_html);
321 return Err(LastFmError::Auth(login_error));
322 }
323
324 let has_real_session = {
326 let session = self.session.lock().unwrap();
327 session
328 .cookies
329 .iter()
330 .any(|cookie| cookie.starts_with("sessionid=.") && cookie.len() > 50)
331 };
332
333 if has_real_session && (response.status() == 302 || response.status() == 200) {
334 {
336 let mut session = self.session.lock().unwrap();
337 session.username = username.to_string();
338 session.csrf_token = Some(csrf_token);
339 }
340 log::debug!("Login successful - authenticated session established");
341 return Ok(());
342 }
343
344 let response_html = response
346 .body_string()
347 .await
348 .map_err(|e| LastFmError::Http(e.to_string()))?;
349
350 let has_login_form = self.check_for_login_form(&response_html);
352
353 if !has_login_form && response.status() == 200 {
354 {
355 let mut session = self.session.lock().unwrap();
356 session.username = username.to_string();
357 session.csrf_token = Some(csrf_token);
358 }
359 Ok(())
360 } else {
361 let error_msg = self.parse_login_error(&response_html);
363 Err(LastFmError::Auth(error_msg))
364 }
365 }
366
367 pub fn username(&self) -> String {
371 self.session.lock().unwrap().username.clone()
372 }
373
374 pub fn is_logged_in(&self) -> bool {
378 self.session.lock().unwrap().is_valid()
379 }
380
381 pub async fn get_recent_scrobbles(&self, page: u32) -> Result<Vec<Track>> {
384 let url = {
385 let session = self.session.lock().unwrap();
386 format!(
387 "{}/user/{}/library?page={}",
388 session.base_url, session.username, page
389 )
390 };
391
392 log::debug!("Fetching recent scrobbles page {page}");
393 let mut response = self.get(&url).await?;
394 let content = response
395 .body_string()
396 .await
397 .map_err(|e| LastFmError::Http(e.to_string()))?;
398
399 log::debug!(
400 "Recent scrobbles response: {} status, {} chars",
401 response.status(),
402 content.len()
403 );
404
405 let document = Html::parse_document(&content);
406 self.parser.parse_recent_scrobbles(&document)
407 }
408
409 pub async fn get_recent_tracks_page(&self, page: u32) -> Result<TrackPage> {
411 let tracks = self.get_recent_scrobbles(page).await?;
412
413 let has_next_page = !tracks.is_empty(); Ok(TrackPage {
418 tracks,
419 page_number: page,
420 has_next_page,
421 total_pages: None, })
423 }
424
425 pub async fn find_recent_scrobble_for_track(
428 &self,
429 track_name: &str,
430 artist_name: &str,
431 max_pages: u32,
432 ) -> Result<Option<Track>> {
433 log::debug!("Searching for recent scrobble: '{track_name}' by '{artist_name}'");
434
435 for page in 1..=max_pages {
436 let scrobbles = self.get_recent_scrobbles(page).await?;
437
438 for scrobble in scrobbles {
439 if scrobble.name == track_name && scrobble.artist == artist_name {
440 log::debug!(
441 "Found recent scrobble: '{}' with timestamp {:?}",
442 scrobble.name,
443 scrobble.timestamp
444 );
445 return Ok(Some(scrobble));
446 }
447 }
448 }
449
450 log::debug!(
451 "No recent scrobble found for '{track_name}' by '{artist_name}' in {max_pages} pages"
452 );
453 Ok(None)
454 }
455
456 pub async fn edit_scrobble(&self, edit: &ScrobbleEdit) -> Result<EditResponse> {
457 let discovered_edits = self.discover_scrobble_edit_variations(edit).await?;
459
460 if discovered_edits.is_empty() {
461 let context = match (&edit.track_name_original, &edit.album_name_original) {
462 (Some(track_name), _) => {
463 format!("track '{}' by '{}'", track_name, edit.artist_name_original)
464 }
465 (None, Some(album_name)) => {
466 format!("album '{}' by '{}'", album_name, edit.artist_name_original)
467 }
468 (None, None) => format!("artist '{}'", edit.artist_name_original),
469 };
470 return Err(LastFmError::Parse(format!(
471 "No scrobbles found for {context}. Make sure the names are correct and that you have scrobbled recently."
472 )));
473 }
474
475 log::info!(
476 "Discovered {} scrobble instances to edit",
477 discovered_edits.len()
478 );
479
480 let mut all_results = Vec::new();
481
482 for (index, discovered_edit) in discovered_edits.iter().enumerate() {
484 log::debug!(
485 "Processing scrobble {}/{}: '{}' from '{}'",
486 index + 1,
487 discovered_edits.len(),
488 discovered_edit.track_name_original,
489 discovered_edit.album_name_original
490 );
491
492 let mut modified_exact_edit = discovered_edit.clone();
494
495 if let Some(new_track_name) = &edit.track_name {
497 modified_exact_edit.track_name = new_track_name.clone();
498 }
499 if let Some(new_album_name) = &edit.album_name {
500 modified_exact_edit.album_name = new_album_name.clone();
501 }
502 modified_exact_edit.artist_name = edit.artist_name.clone();
503 if let Some(new_album_artist_name) = &edit.album_artist_name {
504 modified_exact_edit.album_artist_name = new_album_artist_name.clone();
505 }
506 modified_exact_edit.edit_all = edit.edit_all;
507
508 let album_info = format!(
509 "{} by {}",
510 modified_exact_edit.album_name_original,
511 modified_exact_edit.album_artist_name_original
512 );
513
514 let single_response = self.edit_scrobble_single(&modified_exact_edit, 3).await?;
515 let success = single_response.success();
516 let message = single_response.message();
517
518 all_results.push(SingleEditResponse {
519 success,
520 message,
521 album_info: Some(album_info),
522 exact_scrobble_edit: modified_exact_edit.clone(),
523 });
524
525 if index < discovered_edits.len() - 1 {
527 tokio::time::sleep(std::time::Duration::from_millis(1000)).await;
528 }
529 }
530
531 Ok(EditResponse::from_results(all_results))
532 }
533
534 pub async fn edit_scrobble_single(
545 &self,
546 exact_edit: &ExactScrobbleEdit,
547 max_retries: u32,
548 ) -> Result<EditResponse> {
549 let mut retries = 0;
550
551 loop {
552 match self.edit_scrobble_impl(exact_edit).await {
553 Ok(success) => {
554 return Ok(EditResponse::single(
555 success,
556 None,
557 None,
558 exact_edit.clone(),
559 ));
560 }
561 Err(LastFmError::RateLimit { retry_after }) => {
562 if retries >= max_retries {
563 log::warn!("Max retries ({max_retries}) exceeded for edit operation");
564 return Ok(EditResponse::single(
565 false,
566 Some(format!("Rate limit exceeded after {max_retries} retries")),
567 None,
568 exact_edit.clone(),
569 ));
570 }
571
572 let delay = std::cmp::min(retry_after, 2_u64.pow(retries + 1) * 5);
573 log::info!(
574 "Edit rate limited. Waiting {} seconds before retry {} of {}",
575 delay,
576 retries + 1,
577 max_retries
578 );
579 tokio::time::sleep(std::time::Duration::from_secs(delay)).await;
580 retries += 1;
581 }
582 Err(other_error) => {
583 return Ok(EditResponse::single(
584 false,
585 Some(other_error.to_string()),
586 None,
587 exact_edit.clone(),
588 ));
589 }
590 }
591 }
592 }
593
594 async fn edit_scrobble_impl(&self, exact_edit: &ExactScrobbleEdit) -> Result<bool> {
595 if !self.is_logged_in() {
596 return Err(LastFmError::Auth(
597 "Must be logged in to edit scrobbles".to_string(),
598 ));
599 }
600
601 let edit_url = {
602 let session = self.session.lock().unwrap();
603 format!(
604 "{}/user/{}/library/edit?edited-variation=library-track-scrobble",
605 session.base_url, session.username
606 )
607 };
608
609 log::debug!("Getting fresh CSRF token for edit");
610
611 let form_html = self.get_edit_form_html(&edit_url).await?;
613
614 let form_document = Html::parse_document(&form_html);
616 let fresh_csrf_token = self.extract_csrf_token(&form_document)?;
617
618 log::debug!("Submitting edit with fresh token");
619
620 let mut form_data = HashMap::new();
621
622 form_data.insert("csrfmiddlewaretoken", fresh_csrf_token.as_str());
624
625 form_data.insert("track_name_original", &exact_edit.track_name_original);
627 form_data.insert("track_name", &exact_edit.track_name);
628 form_data.insert("artist_name_original", &exact_edit.artist_name_original);
629 form_data.insert("artist_name", &exact_edit.artist_name);
630 form_data.insert("album_name_original", &exact_edit.album_name_original);
631 form_data.insert("album_name", &exact_edit.album_name);
632 form_data.insert(
633 "album_artist_name_original",
634 &exact_edit.album_artist_name_original,
635 );
636 form_data.insert("album_artist_name", &exact_edit.album_artist_name);
637
638 let timestamp_str = exact_edit.timestamp.to_string();
640 form_data.insert("timestamp", ×tamp_str);
641
642 if exact_edit.edit_all {
644 form_data.insert("edit_all", "1");
645 }
646 form_data.insert("submit", "edit-scrobble");
647 form_data.insert("ajax", "1");
648
649 log::debug!(
650 "Editing scrobble: '{}' -> '{}'",
651 exact_edit.track_name_original,
652 exact_edit.track_name
653 );
654 {
655 let session = self.session.lock().unwrap();
656 log::trace!("Session cookies count: {}", session.cookies.len());
657 }
658
659 let mut request = Request::new(Method::Post, edit_url.parse::<Url>().unwrap());
660
661 let _ = request.insert_header("Accept", "*/*");
663 let _ = request.insert_header("Accept-Language", "en-US,en;q=0.9");
664 let _ = request.insert_header(
665 "Content-Type",
666 "application/x-www-form-urlencoded;charset=UTF-8",
667 );
668 let _ = request.insert_header("Priority", "u=1, i");
669 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");
670 let _ = request.insert_header("X-Requested-With", "XMLHttpRequest");
671 let _ = request.insert_header("Sec-Fetch-Dest", "empty");
672 let _ = request.insert_header("Sec-Fetch-Mode", "cors");
673 let _ = request.insert_header("Sec-Fetch-Site", "same-origin");
674 let _ = request.insert_header(
675 "sec-ch-ua",
676 "\"Not)A;Brand\";v=\"8\", \"Chromium\";v=\"138\", \"Google Chrome\";v=\"138\"",
677 );
678 let _ = request.insert_header("sec-ch-ua-mobile", "?0");
679 let _ = request.insert_header("sec-ch-ua-platform", "\"Linux\"");
680
681 {
683 let session = self.session.lock().unwrap();
684 if !session.cookies.is_empty() {
685 let cookie_header = session.cookies.join("; ");
686 let _ = request.insert_header("Cookie", &cookie_header);
687 }
688 }
689
690 {
692 let session = self.session.lock().unwrap();
693 let _ = request.insert_header(
694 "Referer",
695 format!("{}/user/{}/library", session.base_url, session.username),
696 );
697 }
698
699 let form_string: String = form_data
701 .iter()
702 .map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v)))
703 .collect::<Vec<_>>()
704 .join("&");
705
706 request.set_body(form_string);
707
708 let mut response = self
709 .client
710 .send(request)
711 .await
712 .map_err(|e| LastFmError::Http(e.to_string()))?;
713
714 log::debug!("Edit response status: {}", response.status());
715
716 let response_text = response
717 .body_string()
718 .await
719 .map_err(|e| LastFmError::Http(e.to_string()))?;
720
721 let document = Html::parse_document(&response_text);
723
724 let success_selector = Selector::parse(".alert-success").unwrap();
726 let error_selector = Selector::parse(".alert-danger, .alert-error, .error").unwrap();
727
728 let has_success_alert = document.select(&success_selector).next().is_some();
729 let has_error_alert = document.select(&error_selector).next().is_some();
730
731 let mut actual_track_name = None;
734 let mut actual_album_name = None;
735
736 let track_name_selector = Selector::parse("td.chartlist-name a").unwrap();
738 let album_name_selector = Selector::parse("td.chartlist-album a").unwrap();
739
740 if let Some(track_element) = document.select(&track_name_selector).next() {
741 actual_track_name = Some(track_element.text().collect::<String>().trim().to_string());
742 }
743
744 if let Some(album_element) = document.select(&album_name_selector).next() {
745 actual_album_name = Some(album_element.text().collect::<String>().trim().to_string());
746 }
747
748 if actual_track_name.is_none() || actual_album_name.is_none() {
750 let track_pattern = regex::Regex::new(r#"href="/music/[^"]+/_/([^"]+)""#).unwrap();
753 if let Some(captures) = track_pattern.captures(&response_text) {
754 if let Some(track_match) = captures.get(1) {
755 let raw_track = track_match.as_str();
756 let decoded_track = urlencoding::decode(raw_track)
758 .unwrap_or_else(|_| raw_track.into())
759 .replace("+", " ");
760 actual_track_name = Some(decoded_track);
761 }
762 }
763
764 let album_pattern =
767 regex::Regex::new(r#"href="/music/[^"]+/([^"/_]+)"[^>]*>[^<]*</a>"#).unwrap();
768 if let Some(captures) = album_pattern.captures(&response_text) {
769 if let Some(album_match) = captures.get(1) {
770 let raw_album = album_match.as_str();
771 let decoded_album = urlencoding::decode(raw_album)
773 .unwrap_or_else(|_| raw_album.into())
774 .replace("+", " ");
775 actual_album_name = Some(decoded_album);
776 }
777 }
778 }
779
780 log::debug!(
781 "Response analysis: success_alert={}, error_alert={}, track='{}', album='{}'",
782 has_success_alert,
783 has_error_alert,
784 actual_track_name.as_deref().unwrap_or("not found"),
785 actual_album_name.as_deref().unwrap_or("not found")
786 );
787
788 let final_success = response.status().is_success() && has_success_alert && !has_error_alert;
790
791 let _message = if has_error_alert {
793 if let Some(error_element) = document.select(&error_selector).next() {
795 Some(format!(
796 "Edit failed: {}",
797 error_element.text().collect::<String>().trim()
798 ))
799 } else {
800 Some("Edit failed with unknown error".to_string())
801 }
802 } else if final_success {
803 Some(format!(
804 "Edit successful - Track: '{}', Album: '{}'",
805 actual_track_name.as_deref().unwrap_or("unknown"),
806 actual_album_name.as_deref().unwrap_or("unknown")
807 ))
808 } else {
809 Some(format!("Edit failed with status: {}", response.status()))
810 };
811
812 Ok(final_success)
813 }
814
815 async fn get_edit_form_html(&self, edit_url: &str) -> Result<String> {
818 let mut form_response = self.get(edit_url).await?;
819 let form_html = form_response
820 .body_string()
821 .await
822 .map_err(|e| LastFmError::Http(e.to_string()))?;
823
824 log::debug!("Edit form response status: {}", form_response.status());
825 Ok(form_html)
826 }
827
828 pub async fn load_edit_form_values_internal(
831 &self,
832 track_name: &str,
833 artist_name: &str,
834 ) -> Result<Vec<ExactScrobbleEdit>> {
835 log::debug!("Loading edit form values for '{track_name}' by '{artist_name}'");
836
837 let base_track_url = {
841 let session = self.session.lock().unwrap();
842 format!(
843 "{}/user/{}/library/music/+noredirect/{}/_/{}",
844 session.base_url,
845 session.username,
846 urlencoding::encode(artist_name),
847 urlencoding::encode(track_name)
848 )
849 };
850
851 log::debug!("Fetching track page: {base_track_url}");
852
853 let mut response = self.get(&base_track_url).await?;
854 let html = response
855 .body_string()
856 .await
857 .map_err(|e| crate::LastFmError::Http(e.to_string()))?;
858
859 let document = Html::parse_document(&html);
860
861 let mut all_scrobble_edits = Vec::new();
863 let mut unique_albums = std::collections::HashSet::new();
864 let max_pages = 5;
865
866 let page_edits = self.extract_scrobble_edits_from_page(
868 &document,
869 track_name,
870 artist_name,
871 &mut unique_albums,
872 )?;
873 all_scrobble_edits.extend(page_edits);
874
875 log::debug!(
876 "Page 1: found {} unique album variations",
877 all_scrobble_edits.len()
878 );
879
880 let pagination_selector = Selector::parse(".pagination .pagination-next").unwrap();
882 let mut has_next_page = document.select(&pagination_selector).next().is_some();
883 let mut page = 2;
884
885 while has_next_page && page <= max_pages {
886 let page_url = {
888 let session = self.session.lock().unwrap();
889 format!(
890 "{}/user/{}/library/music/{}/_/{}?page={page}",
891 session.base_url,
892 session.username,
893 urlencoding::encode(artist_name),
894 urlencoding::encode(track_name)
895 )
896 };
897
898 log::debug!("Fetching page {page} for additional album variations");
899
900 let mut response = self.get(&page_url).await?;
901 let html = response
902 .body_string()
903 .await
904 .map_err(|e| crate::LastFmError::Http(e.to_string()))?;
905
906 let document = Html::parse_document(&html);
907
908 let page_edits = self.extract_scrobble_edits_from_page(
909 &document,
910 track_name,
911 artist_name,
912 &mut unique_albums,
913 )?;
914
915 let initial_count = all_scrobble_edits.len();
916 all_scrobble_edits.extend(page_edits);
917 let found_new_unique_albums = all_scrobble_edits.len() > initial_count;
918
919 has_next_page = document.select(&pagination_selector).next().is_some();
921
922 log::debug!(
923 "Page {page}: found {} total unique albums ({})",
924 all_scrobble_edits.len(),
925 if found_new_unique_albums {
926 "new albums found"
927 } else {
928 "no new unique albums"
929 }
930 );
931
932 page += 1;
935 }
936
937 if all_scrobble_edits.is_empty() {
938 return Err(crate::LastFmError::Parse(format!(
939 "No scrobble forms found for track '{track_name}' by '{artist_name}'"
940 )));
941 }
942
943 log::debug!(
944 "Final result: found {} unique album variations for '{track_name}' by '{artist_name}'",
945 all_scrobble_edits.len(),
946 );
947
948 Ok(all_scrobble_edits)
949 }
950
951 fn extract_scrobble_edits_from_page(
954 &self,
955 document: &Html,
956 expected_track: &str,
957 expected_artist: &str,
958 unique_albums: &mut std::collections::HashSet<(String, String)>,
959 ) -> Result<Vec<ExactScrobbleEdit>> {
960 let mut scrobble_edits = Vec::new();
961 let table_selector =
963 Selector::parse("table.chartlist:not(.chartlist__placeholder)").unwrap();
964 let table = document.select(&table_selector).next().ok_or_else(|| {
965 crate::LastFmError::Parse("No chartlist table found on track page".to_string())
966 })?;
967
968 let row_selector = Selector::parse("tr").unwrap();
970 for row in table.select(&row_selector) {
971 let count_bar_link_selector = Selector::parse(".chartlist-count-bar-link").unwrap();
973 if row.select(&count_bar_link_selector).next().is_some() {
974 log::debug!("Found count bar link, skipping aggregated row");
975 continue;
976 }
977
978 let form_selector = Selector::parse("form[data-edit-scrobble]").unwrap();
980 if let Some(form) = row.select(&form_selector).next() {
981 let extract_form_value = |name: &str| -> Option<String> {
983 let selector = Selector::parse(&format!("input[name='{name}']")).unwrap();
984 form.select(&selector)
985 .next()
986 .and_then(|input| input.value().attr("value"))
987 .map(|s| s.to_string())
988 };
989
990 let form_track = extract_form_value("track_name").unwrap_or_default();
992 let form_artist = extract_form_value("artist_name").unwrap_or_default();
993 let form_album = extract_form_value("album_name").unwrap_or_default();
994 let form_album_artist =
995 extract_form_value("album_artist_name").unwrap_or_else(|| form_artist.clone());
996 let form_timestamp = extract_form_value("timestamp").unwrap_or_default();
997
998 log::debug!(
999 "Found scrobble form - Track: '{form_track}', Artist: '{form_artist}', Album: '{form_album}', Timestamp: {form_timestamp}"
1000 );
1001
1002 if form_track == expected_track && form_artist == expected_artist {
1004 let album_key = (form_album.clone(), form_album_artist.clone());
1006 if unique_albums.insert(album_key) {
1007 let timestamp = if form_timestamp.is_empty() {
1009 None
1010 } else {
1011 form_timestamp.parse::<u64>().ok()
1012 };
1013
1014 if let Some(timestamp) = timestamp {
1015 log::debug!(
1016 "✅ Found unique album variation: '{form_album}' by '{form_album_artist}' for '{expected_track}' by '{expected_artist}'"
1017 );
1018
1019 let scrobble_edit = ExactScrobbleEdit::new(
1021 form_track.clone(),
1022 form_album.clone(),
1023 form_artist.clone(),
1024 form_album_artist.clone(),
1025 form_track,
1026 form_album,
1027 form_artist,
1028 form_album_artist,
1029 timestamp,
1030 true,
1031 );
1032 scrobble_edits.push(scrobble_edit);
1033 } else {
1034 log::debug!(
1035 "⚠️ Skipping album variation without valid timestamp: '{form_album}' by '{form_album_artist}'"
1036 );
1037 }
1038 }
1039 }
1040 }
1041 }
1042
1043 Ok(scrobble_edits)
1044 }
1045
1046 pub async fn discover_scrobble_edit_variations(
1060 &self,
1061 edit: &ScrobbleEdit,
1062 ) -> Result<Vec<ExactScrobbleEdit>> {
1063 let mut discovery_iterator = self.discover_scrobbles(edit.clone());
1065 discovery_iterator.collect_all().await
1066 }
1067
1068 pub async fn get_album_tracks(
1071 &self,
1072 album_name: &str,
1073 artist_name: &str,
1074 ) -> Result<Vec<Track>> {
1075 log::debug!("Getting tracks from album '{album_name}' by '{artist_name}'");
1076
1077 let album_url = {
1079 let session = self.session.lock().unwrap();
1080 format!(
1081 "{}/user/{}/library/music/{}/{}",
1082 session.base_url,
1083 session.username,
1084 urlencoding::encode(artist_name),
1085 urlencoding::encode(album_name)
1086 )
1087 };
1088
1089 log::debug!("Fetching album page: {album_url}");
1090
1091 let mut response = self.get(&album_url).await?;
1092 let html = response
1093 .body_string()
1094 .await
1095 .map_err(|e| LastFmError::Http(e.to_string()))?;
1096
1097 let document = Html::parse_document(&html);
1098
1099 let tracks =
1101 self.parser
1102 .extract_tracks_from_document(&document, artist_name, Some(album_name))?;
1103
1104 log::debug!(
1105 "Successfully parsed {} tracks from album page",
1106 tracks.len()
1107 );
1108 Ok(tracks)
1109 }
1110
1111 pub async fn edit_album(
1114 &self,
1115 old_album_name: &str,
1116 new_album_name: &str,
1117 artist_name: &str,
1118 ) -> Result<EditResponse> {
1119 log::debug!("Editing album '{old_album_name}' -> '{new_album_name}' by '{artist_name}'");
1120
1121 let edit = ScrobbleEdit::for_album(old_album_name, artist_name, artist_name)
1122 .with_album_name(new_album_name);
1123
1124 self.edit_scrobble(&edit).await
1125 }
1126
1127 pub async fn edit_artist(
1130 &self,
1131 old_artist_name: &str,
1132 new_artist_name: &str,
1133 ) -> Result<EditResponse> {
1134 log::debug!("Editing artist '{old_artist_name}' -> '{new_artist_name}'");
1135
1136 let edit = ScrobbleEdit::for_artist(old_artist_name, new_artist_name);
1137
1138 self.edit_scrobble(&edit).await
1139 }
1140
1141 pub async fn edit_artist_for_track(
1144 &self,
1145 track_name: &str,
1146 old_artist_name: &str,
1147 new_artist_name: &str,
1148 ) -> Result<EditResponse> {
1149 log::debug!("Editing artist for track '{track_name}' from '{old_artist_name}' -> '{new_artist_name}'");
1150
1151 let edit = ScrobbleEdit::from_track_and_artist(track_name, old_artist_name)
1152 .with_artist_name(new_artist_name);
1153
1154 self.edit_scrobble(&edit).await
1155 }
1156
1157 pub async fn edit_artist_for_album(
1160 &self,
1161 album_name: &str,
1162 old_artist_name: &str,
1163 new_artist_name: &str,
1164 ) -> Result<EditResponse> {
1165 log::debug!("Editing artist for album '{album_name}' from '{old_artist_name}' -> '{new_artist_name}'");
1166
1167 let edit = ScrobbleEdit::for_album(album_name, old_artist_name, old_artist_name)
1168 .with_artist_name(new_artist_name);
1169
1170 self.edit_scrobble(&edit).await
1171 }
1172
1173 pub async fn get_artist_tracks_page(&self, artist: &str, page: u32) -> Result<TrackPage> {
1174 let url = {
1176 let session = self.session.lock().unwrap();
1177 format!(
1178 "{}/user/{}/library/music/{}/+tracks?page={}&ajax=true",
1179 session.base_url,
1180 session.username,
1181 artist.replace(" ", "+"),
1182 page
1183 )
1184 };
1185
1186 log::debug!("Fetching tracks page {page} for artist: {artist}");
1187 let mut response = self.get(&url).await?;
1188 let content = response
1189 .body_string()
1190 .await
1191 .map_err(|e| LastFmError::Http(e.to_string()))?;
1192
1193 log::debug!(
1194 "AJAX response: {} status, {} chars",
1195 response.status(),
1196 content.len()
1197 );
1198
1199 if content.trim_start().starts_with("{") || content.trim_start().starts_with("[") {
1201 log::debug!("Parsing JSON response from AJAX endpoint");
1202 self.parse_json_tracks_page(&content, page, artist)
1203 } else {
1204 log::debug!("Parsing HTML response from AJAX endpoint");
1205 let document = Html::parse_document(&content);
1206 self.parser.parse_tracks_page(&document, page, artist, None)
1207 }
1208 }
1209
1210 fn parse_json_tracks_page(
1212 &self,
1213 _json_content: &str,
1214 page_number: u32,
1215 _artist: &str,
1216 ) -> Result<TrackPage> {
1217 log::debug!("JSON parsing not implemented, returning empty page");
1219 Ok(TrackPage {
1220 tracks: Vec::new(),
1221 page_number,
1222 has_next_page: false,
1223 total_pages: Some(1),
1224 })
1225 }
1226
1227 pub fn extract_tracks_from_document(
1229 &self,
1230 document: &Html,
1231 artist: &str,
1232 album: Option<&str>,
1233 ) -> Result<Vec<Track>> {
1234 self.parser
1235 .extract_tracks_from_document(document, artist, album)
1236 }
1237
1238 pub fn parse_tracks_page(
1240 &self,
1241 document: &Html,
1242 page_number: u32,
1243 artist: &str,
1244 album: Option<&str>,
1245 ) -> Result<TrackPage> {
1246 self.parser
1247 .parse_tracks_page(document, page_number, artist, album)
1248 }
1249
1250 pub fn parse_recent_scrobbles(&self, document: &Html) -> Result<Vec<Track>> {
1252 self.parser.parse_recent_scrobbles(document)
1253 }
1254
1255 fn extract_csrf_token(&self, document: &Html) -> Result<String> {
1256 let csrf_selector = Selector::parse("input[name=\"csrfmiddlewaretoken\"]").unwrap();
1257
1258 document
1259 .select(&csrf_selector)
1260 .next()
1261 .and_then(|input| input.value().attr("value"))
1262 .map(|token| token.to_string())
1263 .ok_or(LastFmError::CsrfNotFound)
1264 }
1265
1266 fn extract_login_form_data(&self, html: &str) -> Result<(String, Option<String>)> {
1268 let document = Html::parse_document(html);
1269
1270 let csrf_token = self.extract_csrf_token(&document)?;
1271
1272 let next_selector = Selector::parse("input[name=\"next\"]").unwrap();
1274 let next_field = document
1275 .select(&next_selector)
1276 .next()
1277 .and_then(|input| input.value().attr("value"))
1278 .map(|s| s.to_string());
1279
1280 Ok((csrf_token, next_field))
1281 }
1282
1283 fn parse_login_error(&self, html: &str) -> String {
1285 let document = Html::parse_document(html);
1286
1287 let error_selector = Selector::parse(".alert-danger, .form-error, .error-message").unwrap();
1288
1289 let mut error_messages = Vec::new();
1290 for error in document.select(&error_selector) {
1291 let error_text = error.text().collect::<String>().trim().to_string();
1292 if !error_text.is_empty() {
1293 error_messages.push(error_text);
1294 }
1295 }
1296
1297 if error_messages.is_empty() {
1298 "Login failed - please check your credentials".to_string()
1299 } else {
1300 format!("Login failed: {}", error_messages.join("; "))
1301 }
1302 }
1303
1304 fn check_for_login_form(&self, html: &str) -> bool {
1306 let document = Html::parse_document(html);
1307 let login_form_selector =
1308 Selector::parse("form[action*=\"login\"], input[name=\"username_or_email\"]").unwrap();
1309 document.select(&login_form_selector).next().is_some()
1310 }
1311
1312 pub async fn get(&self, url: &str) -> Result<Response> {
1314 self.get_with_retry(url, 3).await
1315 }
1316
1317 async fn get_with_retry(&self, url: &str, max_retries: u32) -> Result<Response> {
1319 let mut retries = 0;
1320
1321 loop {
1322 match self.get_with_redirects(url, 0).await {
1323 Ok(mut response) => {
1324 let body = self.extract_response_body(url, &mut response).await?;
1326
1327 if response.status().is_success() && self.is_rate_limit_response(&body) {
1329 log::debug!("Response body contains rate limit patterns");
1330 if retries < max_retries {
1331 let delay = 60 + (retries as u64 * 30); log::info!("Rate limit detected in response body, retrying in {delay}s (attempt {}/{max_retries})", retries + 1);
1333 tokio::time::sleep(std::time::Duration::from_secs(delay)).await;
1334 retries += 1;
1335 continue;
1336 }
1337 return Err(crate::LastFmError::RateLimit { retry_after: 60 });
1338 }
1339
1340 let mut new_response = http_types::Response::new(response.status());
1342 for (name, values) in response.iter() {
1343 for value in values {
1344 let _ = new_response.insert_header(name.clone(), value.clone());
1345 }
1346 }
1347 new_response.set_body(body);
1348
1349 return Ok(new_response);
1350 }
1351 Err(crate::LastFmError::RateLimit { retry_after }) => {
1352 if retries < max_retries {
1353 let delay = retry_after + (retries as u64 * 30); log::info!(
1355 "Rate limit detected, retrying in {delay}s (attempt {}/{max_retries})",
1356 retries + 1
1357 );
1358 tokio::time::sleep(std::time::Duration::from_secs(delay)).await;
1359 retries += 1;
1360 } else {
1361 return Err(crate::LastFmError::RateLimit { retry_after });
1362 }
1363 }
1364 Err(e) => return Err(e),
1365 }
1366 }
1367 }
1368
1369 async fn get_with_redirects(&self, url: &str, redirect_count: u32) -> Result<Response> {
1370 if redirect_count > 5 {
1371 return Err(LastFmError::Http("Too many redirects".to_string()));
1372 }
1373
1374 let mut request = Request::new(Method::Get, url.parse::<Url>().unwrap());
1375 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");
1376
1377 {
1379 let session = self.session.lock().unwrap();
1380 if !session.cookies.is_empty() {
1381 let cookie_header = session.cookies.join("; ");
1382 let _ = request.insert_header("Cookie", &cookie_header);
1383 } else if url.contains("page=") {
1384 log::debug!("No cookies available for paginated request!");
1385 }
1386 }
1387
1388 if url.contains("ajax=true") {
1390 let _ = request.insert_header("Accept", "*/*");
1392 let _ = request.insert_header("X-Requested-With", "XMLHttpRequest");
1393 } else {
1394 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");
1396 }
1397 let _ = request.insert_header("Accept-Language", "en-US,en;q=0.9");
1398 let _ = request.insert_header("Accept-Encoding", "gzip, deflate, br");
1399 let _ = request.insert_header("DNT", "1");
1400 let _ = request.insert_header("Connection", "keep-alive");
1401 let _ = request.insert_header("Upgrade-Insecure-Requests", "1");
1402
1403 if url.contains("page=") {
1405 let base_url = url.split('?').next().unwrap_or(url);
1406 let _ = request.insert_header("Referer", base_url);
1407 }
1408
1409 let response = self
1410 .client
1411 .send(request)
1412 .await
1413 .map_err(|e| LastFmError::Http(e.to_string()))?;
1414
1415 self.extract_cookies(&response);
1417
1418 if response.status() == 302 || response.status() == 301 {
1420 if let Some(location) = response.header("location") {
1421 if let Some(redirect_url) = location.get(0) {
1422 let redirect_url_str = redirect_url.as_str();
1423 if url.contains("page=") {
1424 log::debug!("Following redirect from {url} to {redirect_url_str}");
1425
1426 if redirect_url_str.contains("/login") {
1428 log::debug!("Redirect to login page - authentication failed for paginated request");
1429 return Err(LastFmError::Auth(
1430 "Session expired or invalid for paginated request".to_string(),
1431 ));
1432 }
1433 }
1434
1435 let full_redirect_url = if redirect_url_str.starts_with('/') {
1437 let base_url = self.session.lock().unwrap().base_url.clone();
1438 format!("{base_url}{redirect_url_str}")
1439 } else if redirect_url_str.starts_with("http") {
1440 redirect_url_str.to_string()
1441 } else {
1442 let base_url = url
1444 .rsplit('/')
1445 .skip(1)
1446 .collect::<Vec<_>>()
1447 .into_iter()
1448 .rev()
1449 .collect::<Vec<_>>()
1450 .join("/");
1451 format!("{base_url}/{redirect_url_str}")
1452 };
1453
1454 return Box::pin(
1456 self.get_with_redirects(&full_redirect_url, redirect_count + 1),
1457 )
1458 .await;
1459 }
1460 }
1461 }
1462
1463 if response.status() == 429 {
1465 let retry_after = response
1466 .header("retry-after")
1467 .and_then(|h| h.get(0))
1468 .and_then(|v| v.as_str().parse::<u64>().ok())
1469 .unwrap_or(60);
1470 return Err(LastFmError::RateLimit { retry_after });
1471 }
1472
1473 if response.status() == 403 {
1475 log::debug!("Got 403 response, checking if it's a rate limit");
1476 {
1478 let session = self.session.lock().unwrap();
1479 if !session.cookies.is_empty() {
1480 log::debug!("403 on authenticated request - likely rate limit");
1481 return Err(LastFmError::RateLimit { retry_after: 60 });
1482 }
1483 }
1484 }
1485
1486 Ok(response)
1487 }
1488
1489 fn is_rate_limit_response(&self, response_body: &str) -> bool {
1491 let body_lower = response_body.to_lowercase();
1492
1493 for pattern in &self.rate_limit_patterns {
1495 if body_lower.contains(&pattern.to_lowercase()) {
1496 return true;
1497 }
1498 }
1499
1500 false
1501 }
1502
1503 fn extract_cookies(&self, response: &Response) {
1504 if let Some(cookie_headers) = response.header("set-cookie") {
1506 let mut new_cookies = 0;
1507 for cookie_header in cookie_headers {
1508 let cookie_str = cookie_header.as_str();
1509 if let Some(cookie_value) = cookie_str.split(';').next() {
1511 let cookie_name = cookie_value.split('=').next().unwrap_or("");
1512
1513 {
1515 let mut session = self.session.lock().unwrap();
1516 session
1517 .cookies
1518 .retain(|existing| !existing.starts_with(&format!("{cookie_name}=")));
1519 session.cookies.push(cookie_value.to_string());
1520 }
1521 new_cookies += 1;
1522 }
1523 }
1524 if new_cookies > 0 {
1525 {
1526 let session = self.session.lock().unwrap();
1527 log::trace!(
1528 "Extracted {} new cookies, total: {}",
1529 new_cookies,
1530 session.cookies.len()
1531 );
1532 log::trace!("Updated cookies: {:?}", &session.cookies);
1533
1534 for cookie in &session.cookies {
1536 if cookie.starts_with("sessionid=") {
1537 log::trace!("Current sessionid: {}", &cookie[10..50.min(cookie.len())]);
1538 break;
1539 }
1540 }
1541 }
1542 }
1543 }
1544 }
1545
1546 async fn extract_response_body(&self, url: &str, response: &mut Response) -> Result<String> {
1548 let body = response
1549 .body_string()
1550 .await
1551 .map_err(|e| LastFmError::Http(e.to_string()))?;
1552
1553 if self.debug_save_responses {
1554 self.save_debug_response(url, response.status().into(), &body);
1555 }
1556
1557 Ok(body)
1558 }
1559
1560 fn save_debug_response(&self, url: &str, status_code: u16, body: &str) {
1562 if let Err(e) = self.try_save_debug_response(url, status_code, body) {
1563 log::warn!("Failed to save debug response: {e}");
1564 }
1565 }
1566
1567 fn try_save_debug_response(&self, url: &str, status_code: u16, body: &str) -> Result<()> {
1569 let debug_dir = Path::new("debug_responses");
1571 if !debug_dir.exists() {
1572 fs::create_dir_all(debug_dir)
1573 .map_err(|e| LastFmError::Http(format!("Failed to create debug directory: {e}")))?;
1574 }
1575
1576 let url_path = {
1578 let session = self.session.lock().unwrap();
1579 if url.starts_with(&session.base_url) {
1580 &url[session.base_url.len()..]
1581 } else {
1582 url
1583 }
1584 };
1585
1586 let now = chrono::Utc::now();
1588 let timestamp = now.format("%Y%m%d_%H%M%S_%3f");
1589 let safe_path = url_path.replace(['/', '?', '&', '=', '%', '+'], "_");
1590
1591 let filename = format!("{timestamp}_{safe_path}_status{status_code}.html");
1592 let file_path = debug_dir.join(filename);
1593
1594 fs::write(&file_path, body)
1596 .map_err(|e| LastFmError::Http(format!("Failed to write debug file: {e}")))?;
1597
1598 log::debug!(
1599 "Saved HTTP response to {file_path:?} (status: {status_code}, url: {url_path})"
1600 );
1601
1602 Ok(())
1603 }
1604
1605 pub async fn get_artist_albums_page(&self, artist: &str, page: u32) -> Result<AlbumPage> {
1606 let url = {
1608 let session = self.session.lock().unwrap();
1609 format!(
1610 "{}/user/{}/library/music/{}/+albums?page={}&ajax=true",
1611 session.base_url,
1612 session.username,
1613 artist.replace(" ", "+"),
1614 page
1615 )
1616 };
1617
1618 log::debug!("Fetching albums page {page} for artist: {artist}");
1619 let mut response = self.get(&url).await?;
1620 let content = response
1621 .body_string()
1622 .await
1623 .map_err(|e| LastFmError::Http(e.to_string()))?;
1624
1625 log::debug!(
1626 "AJAX response: {} status, {} chars",
1627 response.status(),
1628 content.len()
1629 );
1630
1631 if content.trim_start().starts_with("{") || content.trim_start().starts_with("[") {
1633 log::debug!("Parsing JSON response from AJAX endpoint");
1634 self.parse_json_albums_page(&content, page, artist)
1635 } else {
1636 log::debug!("Parsing HTML response from AJAX endpoint");
1637 let document = Html::parse_document(&content);
1638 self.parser.parse_albums_page(&document, page, artist)
1639 }
1640 }
1641
1642 fn parse_json_albums_page(
1643 &self,
1644 _json_content: &str,
1645 page_number: u32,
1646 _artist: &str,
1647 ) -> Result<AlbumPage> {
1648 log::debug!("JSON parsing not implemented, returning empty page");
1650 Ok(AlbumPage {
1651 albums: Vec::new(),
1652 page_number,
1653 has_next_page: false,
1654 total_pages: Some(1),
1655 })
1656 }
1657}
1658
1659#[async_trait(?Send)]
1660impl LastFmEditClient for LastFmEditClientImpl {
1661 async fn login(&self, username: &str, password: &str) -> Result<()> {
1662 self.login(username, password).await
1663 }
1664
1665 fn username(&self) -> String {
1666 self.username()
1667 }
1668
1669 fn is_logged_in(&self) -> bool {
1670 self.is_logged_in()
1671 }
1672
1673 async fn get_recent_scrobbles(&self, page: u32) -> Result<Vec<Track>> {
1674 self.get_recent_scrobbles(page).await
1675 }
1676
1677 async fn find_recent_scrobble_for_track(
1678 &self,
1679 track_name: &str,
1680 artist_name: &str,
1681 max_pages: u32,
1682 ) -> Result<Option<Track>> {
1683 self.find_recent_scrobble_for_track(track_name, artist_name, max_pages)
1684 .await
1685 }
1686
1687 async fn edit_scrobble(&self, edit: &ScrobbleEdit) -> Result<EditResponse> {
1688 self.edit_scrobble(edit).await
1689 }
1690
1691 async fn edit_scrobble_single(
1692 &self,
1693 exact_edit: &ExactScrobbleEdit,
1694 max_retries: u32,
1695 ) -> Result<EditResponse> {
1696 self.edit_scrobble_single(exact_edit, max_retries).await
1697 }
1698
1699 async fn discover_scrobble_edit_variations(
1700 &self,
1701 edit: &ScrobbleEdit,
1702 ) -> Result<Vec<ExactScrobbleEdit>> {
1703 self.discover_scrobble_edit_variations(edit).await
1704 }
1705
1706 async fn get_album_tracks(&self, album_name: &str, artist_name: &str) -> Result<Vec<Track>> {
1707 self.get_album_tracks(album_name, artist_name).await
1708 }
1709
1710 async fn edit_album(
1711 &self,
1712 old_album_name: &str,
1713 new_album_name: &str,
1714 artist_name: &str,
1715 ) -> Result<EditResponse> {
1716 self.edit_album(old_album_name, new_album_name, artist_name)
1717 .await
1718 }
1719
1720 async fn edit_artist(
1721 &self,
1722 old_artist_name: &str,
1723 new_artist_name: &str,
1724 ) -> Result<EditResponse> {
1725 self.edit_artist(old_artist_name, new_artist_name).await
1726 }
1727
1728 async fn edit_artist_for_track(
1729 &self,
1730 track_name: &str,
1731 old_artist_name: &str,
1732 new_artist_name: &str,
1733 ) -> Result<EditResponse> {
1734 self.edit_artist_for_track(track_name, old_artist_name, new_artist_name)
1735 .await
1736 }
1737
1738 async fn edit_artist_for_album(
1739 &self,
1740 album_name: &str,
1741 old_artist_name: &str,
1742 new_artist_name: &str,
1743 ) -> Result<EditResponse> {
1744 self.edit_artist_for_album(album_name, old_artist_name, new_artist_name)
1745 .await
1746 }
1747
1748 async fn get_artist_tracks_page(&self, artist: &str, page: u32) -> Result<TrackPage> {
1749 self.get_artist_tracks_page(artist, page).await
1750 }
1751
1752 async fn get_artist_albums_page(&self, artist: &str, page: u32) -> Result<AlbumPage> {
1753 self.get_artist_albums_page(artist, page).await
1754 }
1755
1756 async fn get_recent_tracks_page(&self, page: u32) -> Result<TrackPage> {
1757 self.get_recent_tracks_page(page).await
1758 }
1759
1760 fn get_session(&self) -> LastFmEditSession {
1761 self.get_session()
1762 }
1763
1764 fn restore_session(&self, session: LastFmEditSession) {
1765 self.restore_session(session)
1766 }
1767
1768 fn artist_tracks(&self, artist: &str) -> crate::ArtistTracksIterator {
1769 crate::ArtistTracksIterator::new(self.clone(), artist.to_string())
1770 }
1771
1772 fn artist_albums(&self, artist: &str) -> crate::ArtistAlbumsIterator {
1773 crate::ArtistAlbumsIterator::new(self.clone(), artist.to_string())
1774 }
1775
1776 fn album_tracks(&self, album_name: &str, artist_name: &str) -> crate::AlbumTracksIterator {
1777 crate::AlbumTracksIterator::new(
1778 self.clone(),
1779 album_name.to_string(),
1780 artist_name.to_string(),
1781 )
1782 }
1783
1784 fn recent_tracks(&self) -> crate::RecentTracksIterator {
1785 crate::RecentTracksIterator::new(self.clone())
1786 }
1787
1788 fn recent_tracks_from_page(&self, starting_page: u32) -> crate::RecentTracksIterator {
1789 crate::RecentTracksIterator::with_starting_page(self.clone(), starting_page)
1790 }
1791}