1use crate::edit_analysis;
2use crate::headers;
3use crate::login::extract_cookies_from_response;
4use crate::parsing::LastFmParser;
5use crate::r#trait::LastFmEditClient;
6use crate::retry;
7use crate::types::{
8 AlbumPage, ClientConfig, ClientEvent, ClientEventReceiver, EditResponse, ExactScrobbleEdit,
9 LastFmEditSession, LastFmError, RateLimitConfig, RateLimitType, RequestInfo, RetryConfig,
10 ScrobbleEdit, SharedEventBroadcaster, SingleEditResponse, Track, TrackPage,
11};
12use crate::Result;
13use async_trait::async_trait;
14use http_client::{HttpClient, Request, Response};
15use http_types::{Method, Url};
16use scraper::{Html, Selector};
17use std::sync::{Arc, Mutex};
18
19#[derive(Clone)]
20pub struct LastFmEditClientImpl {
21 client: Arc<dyn HttpClient + Send + Sync>,
22 session: Arc<Mutex<LastFmEditSession>>,
23 parser: LastFmParser,
24 broadcaster: Arc<SharedEventBroadcaster>,
25 config: ClientConfig,
26}
27
28impl LastFmEditClientImpl {
29 pub fn from_session(
30 client: Box<dyn HttpClient + Send + Sync>,
31 session: LastFmEditSession,
32 ) -> Self {
33 Self::from_session_with_arc(Arc::from(client), session)
34 }
35
36 fn from_session_with_arc(
37 client: Arc<dyn HttpClient + Send + Sync>,
38 session: LastFmEditSession,
39 ) -> Self {
40 Self::from_session_with_broadcaster_arc(
41 client,
42 session,
43 Arc::new(SharedEventBroadcaster::new()),
44 )
45 }
46
47 pub fn from_session_with_rate_limit_patterns(
48 client: Box<dyn HttpClient + Send + Sync>,
49 session: LastFmEditSession,
50 rate_limit_patterns: Vec<String>,
51 ) -> Self {
52 let config = ClientConfig::default()
53 .with_rate_limit_config(RateLimitConfig::default().with_patterns(rate_limit_patterns));
54 Self::from_session_with_client_config(client, session, config)
55 }
56
57 pub async fn login_with_credentials(
58 client: Box<dyn HttpClient + Send + Sync>,
59 username: &str,
60 password: &str,
61 ) -> Result<Self> {
62 let client_arc: Arc<dyn HttpClient + Send + Sync> = Arc::from(client);
63 let login_manager =
64 crate::login::LoginManager::new(client_arc.clone(), "https://www.last.fm".to_string());
65 let session = login_manager.login(username, password).await?;
66 Ok(Self::from_session_with_arc(client_arc, session))
67 }
68
69 pub fn from_session_with_client_config(
70 client: Box<dyn HttpClient + Send + Sync>,
71 session: LastFmEditSession,
72 config: ClientConfig,
73 ) -> Self {
74 Self::from_session_with_client_config_arc(Arc::from(client), session, config)
75 }
76
77 pub async fn login_with_credentials_and_client_config(
78 client: Box<dyn HttpClient + Send + Sync>,
79 username: &str,
80 password: &str,
81 config: ClientConfig,
82 ) -> Result<Self> {
83 let client_arc: Arc<dyn HttpClient + Send + Sync> = Arc::from(client);
84 let login_manager =
85 crate::login::LoginManager::new(client_arc.clone(), "https://www.last.fm".to_string());
86 let session = login_manager.login(username, password).await?;
87 Ok(Self::from_session_with_client_config_arc(
88 client_arc, session, config,
89 ))
90 }
91
92 pub fn from_session_with_config(
93 client: Box<dyn HttpClient + Send + Sync>,
94 session: LastFmEditSession,
95 retry_config: RetryConfig,
96 rate_limit_config: RateLimitConfig,
97 ) -> Self {
98 Self::from_session_with_config_arc(
99 Arc::from(client),
100 session,
101 retry_config,
102 rate_limit_config,
103 )
104 }
105
106 pub async fn login_with_credentials_and_config(
107 client: Box<dyn HttpClient + Send + Sync>,
108 username: &str,
109 password: &str,
110 retry_config: RetryConfig,
111 rate_limit_config: RateLimitConfig,
112 ) -> Result<Self> {
113 let client_arc: Arc<dyn HttpClient + Send + Sync> = Arc::from(client);
114 let login_manager =
115 crate::login::LoginManager::new(client_arc.clone(), "https://www.last.fm".to_string());
116 let session = login_manager.login(username, password).await?;
117 Ok(Self::from_session_with_config_arc(
118 client_arc,
119 session,
120 retry_config,
121 rate_limit_config,
122 ))
123 }
124
125 fn from_session_with_broadcaster(
126 client: Box<dyn HttpClient + Send + Sync>,
127 session: LastFmEditSession,
128 broadcaster: Arc<SharedEventBroadcaster>,
129 ) -> Self {
130 Self::from_session_with_broadcaster_arc(Arc::from(client), session, broadcaster)
131 }
132
133 fn from_session_with_client_config_arc(
134 client: Arc<dyn HttpClient + Send + Sync>,
135 session: LastFmEditSession,
136 config: ClientConfig,
137 ) -> Self {
138 Self::from_session_with_client_config_and_broadcaster_arc(
139 client,
140 session,
141 config,
142 Arc::new(SharedEventBroadcaster::new()),
143 )
144 }
145
146 fn from_session_with_config_arc(
147 client: Arc<dyn HttpClient + Send + Sync>,
148 session: LastFmEditSession,
149 retry_config: RetryConfig,
150 rate_limit_config: RateLimitConfig,
151 ) -> Self {
152 let config = ClientConfig {
153 retry: retry_config,
154 rate_limit: rate_limit_config,
155 };
156 Self::from_session_with_client_config_arc(client, session, config)
157 }
158
159 fn from_session_with_broadcaster_arc(
160 client: Arc<dyn HttpClient + Send + Sync>,
161 session: LastFmEditSession,
162 broadcaster: Arc<SharedEventBroadcaster>,
163 ) -> Self {
164 Self::from_session_with_client_config_and_broadcaster_arc(
165 client,
166 session,
167 ClientConfig::default(),
168 broadcaster,
169 )
170 }
171
172 fn from_session_with_client_config_and_broadcaster_arc(
173 client: Arc<dyn HttpClient + Send + Sync>,
174 session: LastFmEditSession,
175 config: ClientConfig,
176 broadcaster: Arc<SharedEventBroadcaster>,
177 ) -> Self {
178 Self {
179 client,
180 session: Arc::new(Mutex::new(session)),
181 parser: LastFmParser::new(),
182 broadcaster,
183 config,
184 }
185 }
186
187 pub fn get_session(&self) -> LastFmEditSession {
188 self.session.lock().unwrap().clone()
189 }
190
191 pub fn restore_session(&self, session: LastFmEditSession) {
192 *self.session.lock().unwrap() = session;
193 }
194
195 pub fn with_shared_broadcaster(&self, client: Box<dyn HttpClient + Send + Sync>) -> Self {
196 let session = self.get_session();
197 Self::from_session_with_broadcaster(client, session, self.broadcaster.clone())
198 }
199
200 pub fn username(&self) -> String {
201 self.session.lock().unwrap().username.clone()
202 }
203
204 pub async fn validate_session(&self) -> bool {
205 let test_url = {
206 let session = self.session.lock().unwrap();
207 format!(
208 "{}/settings/subscription/automatic-edits/tracks",
209 session.base_url
210 )
211 };
212
213 let mut request = Request::new(Method::Get, test_url.parse::<Url>().unwrap());
214
215 {
216 let session = self.session.lock().unwrap();
217 headers::add_cookies(&mut request, &session.cookies);
218 }
219
220 headers::add_get_headers(&mut request, false, None);
221
222 match self.client.send(request).await {
223 Ok(response) => {
224 if response.status() == 302 || response.status() == 301 {
225 if let Some(location) = response.header("location") {
226 if let Some(redirect_url) = location.get(0) {
227 let redirect_url_str = redirect_url.as_str();
228 let is_valid = !redirect_url_str.contains("/login");
229
230 return is_valid;
231 }
232 }
233 }
234 true
235 }
236 Err(_e) => false,
237 }
238 }
239
240 pub async fn delete_scrobble(
241 &self,
242 artist_name: &str,
243 track_name: &str,
244 timestamp: u64,
245 ) -> Result<bool> {
246 let config = RetryConfig {
247 max_retries: 3,
248 base_delay: 5,
249 max_delay: 300,
250 enabled: true,
251 };
252
253 let artist_name = artist_name.to_string();
254 let track_name = track_name.to_string();
255 let client = self.clone();
256
257 match retry::retry_with_backoff(
258 config,
259 "Delete scrobble",
260 || client.delete_scrobble_impl(&artist_name, &track_name, timestamp),
261 |delay, rate_limit_timestamp, operation_name| {
262 self.broadcast_event(ClientEvent::RateLimited {
263 delay_seconds: delay,
264 request: None,
265 rate_limit_type: RateLimitType::ResponsePattern,
266 rate_limit_timestamp,
267 });
268 log::debug!("{operation_name} rate limited, waiting {delay} seconds");
269 },
270 |total_duration, _operation_name| {
271 self.broadcast_event(ClientEvent::RateLimitEnded {
272 request: crate::types::RequestInfo::from_url_and_method(
273 &format!("delete_scrobble/{artist_name}/{track_name}/{timestamp}"),
274 "POST",
275 ),
276 rate_limit_type: RateLimitType::ResponsePattern,
277 total_rate_limit_duration_seconds: total_duration,
278 });
279 },
280 )
281 .await
282 {
283 Ok(retry_result) => Ok(retry_result.result),
284 Err(_) => Ok(false),
285 }
286 }
287
288 async fn delete_scrobble_impl(
289 &self,
290 artist_name: &str,
291 track_name: &str,
292 timestamp: u64,
293 ) -> Result<bool> {
294 let delete_url = {
295 let session = self.session.lock().unwrap();
296 format!(
297 "{}/user/{}/library/delete",
298 session.base_url, session.username
299 )
300 };
301
302 log::debug!("Getting fresh CSRF token for delete");
303 let library_url = {
304 let session = self.session.lock().unwrap();
305 format!("{}/user/{}/library", session.base_url, session.username)
306 };
307
308 let mut response = self.get(&library_url).await?;
309 let content = response
310 .body_string()
311 .await
312 .map_err(|e| LastFmError::Http(e.to_string()))?;
313
314 let document = Html::parse_document(&content);
315 let fresh_csrf_token = self.extract_csrf_token(&document)?;
316
317 log::debug!("Submitting delete request with fresh token");
318
319 let mut request = Request::new(Method::Post, delete_url.parse::<Url>().unwrap());
320
321 let referer_url = {
322 let session = self.session.lock().unwrap();
323 headers::add_cookies(&mut request, &session.cookies);
324 format!("{}/user/{}", session.base_url, session.username)
325 };
326
327 headers::add_edit_headers(&mut request, &referer_url);
328
329 let form_data = [
330 ("csrfmiddlewaretoken", fresh_csrf_token.as_str()),
331 ("artist_name", artist_name),
332 ("track_name", track_name),
333 ("timestamp", ×tamp.to_string()),
334 ("ajax", "1"),
335 ];
336
337 let form_string: String = form_data
338 .iter()
339 .map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v)))
340 .collect::<Vec<_>>()
341 .join("&");
342
343 request.set_body(form_string);
344
345 log::debug!(
346 "Deleting scrobble: '{track_name}' by '{artist_name}' with timestamp {timestamp}"
347 );
348
349 let request_info = RequestInfo::from_url_and_method(&delete_url, "POST");
350 let request_start = std::time::Instant::now();
351
352 self.broadcast_event(ClientEvent::RequestStarted {
353 request: request_info.clone(),
354 });
355
356 let mut response = self
357 .client
358 .send(request)
359 .await
360 .map_err(|e| LastFmError::Http(e.to_string()))?;
361
362 self.broadcast_event(ClientEvent::RequestCompleted {
363 request: request_info.clone(),
364 status_code: response.status().into(),
365 duration_ms: request_start.elapsed().as_millis() as u64,
366 });
367
368 log::debug!("Delete response status: {}", response.status());
369
370 let response_text = response
371 .body_string()
372 .await
373 .map_err(|e| LastFmError::Http(e.to_string()))?;
374
375 let success = response.status().is_success();
376
377 if success {
378 log::debug!("Successfully deleted scrobble");
379 } else {
380 log::debug!("Delete failed with response: {response_text}");
381 }
382
383 Ok(success)
384 }
385
386 pub fn subscribe(&self) -> ClientEventReceiver {
387 self.broadcaster.subscribe()
388 }
389
390 pub fn latest_event(&self) -> Option<ClientEvent> {
391 self.broadcaster.latest_event()
392 }
393
394 fn broadcast_event(&self, event: ClientEvent) {
395 self.broadcaster.broadcast_event(event);
396 }
397
398 pub async fn get_recent_scrobbles(&self, page: u32) -> Result<Vec<Track>> {
399 let url = {
400 let session = self.session.lock().unwrap();
401 format!(
402 "{}/user/{}/library?page={}",
403 session.base_url, session.username, page
404 )
405 };
406
407 log::debug!("Fetching recent scrobbles page {page}");
408 let mut response = self.get(&url).await?;
409 let content = response
410 .body_string()
411 .await
412 .map_err(|e| LastFmError::Http(e.to_string()))?;
413
414 log::debug!(
415 "Recent scrobbles response: {} status, {} chars",
416 response.status(),
417 content.len()
418 );
419
420 let document = Html::parse_document(&content);
421 self.parser.parse_recent_scrobbles(&document)
422 }
423
424 pub async fn get_recent_tracks_page(&self, page: u32) -> Result<TrackPage> {
425 let tracks = self.get_recent_scrobbles(page).await?;
426
427 let has_next_page = !tracks.is_empty();
428
429 Ok(TrackPage {
430 tracks,
431 page_number: page,
432 has_next_page,
433 total_pages: None,
434 })
435 }
436
437 pub async fn find_recent_scrobble_for_track(
438 &self,
439 track_name: &str,
440 artist_name: &str,
441 max_pages: u32,
442 ) -> Result<Option<Track>> {
443 log::debug!("Searching for recent scrobble: '{track_name}' by '{artist_name}'");
444
445 for page in 1..=max_pages {
446 let scrobbles = self.get_recent_scrobbles(page).await?;
447
448 for scrobble in scrobbles {
449 if scrobble.name == track_name && scrobble.artist == artist_name {
450 log::debug!(
451 "Found recent scrobble: '{}' with timestamp {:?}",
452 scrobble.name,
453 scrobble.timestamp
454 );
455 return Ok(Some(scrobble));
456 }
457 }
458 }
459
460 log::debug!(
461 "No recent scrobble found for '{track_name}' by '{artist_name}' in {max_pages} pages"
462 );
463 Ok(None)
464 }
465
466 pub async fn edit_scrobble(&self, edit: &ScrobbleEdit) -> Result<EditResponse> {
467 let discovered_edits = self.discover_scrobble_edit_variations(edit).await?;
468
469 if discovered_edits.is_empty() {
470 let context = match (&edit.track_name_original, &edit.album_name_original) {
471 (Some(track_name), _) => {
472 format!("track '{}' by '{}'", track_name, edit.artist_name_original)
473 }
474 (None, Some(album_name)) => {
475 format!("album '{}' by '{}'", album_name, edit.artist_name_original)
476 }
477 (None, None) => format!("artist '{}'", edit.artist_name_original),
478 };
479 return Err(LastFmError::Parse(format!(
480 "No scrobbles found for {context}. Make sure the names are correct and that you have scrobbled recently."
481 )));
482 }
483
484 log::info!(
485 "Discovered {} scrobble instances to edit",
486 discovered_edits.len()
487 );
488
489 let mut all_results = Vec::new();
490
491 for (index, discovered_edit) in discovered_edits.iter().enumerate() {
492 log::debug!(
493 "Processing scrobble {}/{}: '{}' from '{}'",
494 index + 1,
495 discovered_edits.len(),
496 discovered_edit.track_name_original,
497 discovered_edit.album_name_original
498 );
499
500 let mut modified_exact_edit = discovered_edit.clone();
501
502 if let Some(new_track_name) = &edit.track_name {
503 modified_exact_edit.track_name = new_track_name.clone();
504 }
505 if let Some(new_album_name) = &edit.album_name {
506 modified_exact_edit.album_name = new_album_name.clone();
507 }
508 modified_exact_edit.artist_name = edit.artist_name.clone();
509 if let Some(new_album_artist_name) = &edit.album_artist_name {
510 modified_exact_edit.album_artist_name = new_album_artist_name.clone();
511 }
512 modified_exact_edit.edit_all = edit.edit_all;
513
514 let album_info = format!(
515 "{} by {}",
516 modified_exact_edit.album_name_original,
517 modified_exact_edit.album_artist_name_original
518 );
519
520 let single_response = self.edit_scrobble_single(&modified_exact_edit, 3).await?;
521 let success = single_response.success();
522 let message = single_response.message();
523
524 all_results.push(SingleEditResponse {
525 success,
526 message,
527 album_info: Some(album_info),
528 exact_scrobble_edit: modified_exact_edit.clone(),
529 });
530
531 if index < discovered_edits.len() - 1 {
532 tokio::time::sleep(std::time::Duration::from_millis(1000)).await;
533 }
534 }
535
536 Ok(EditResponse::from_results(all_results))
537 }
538
539 pub async fn edit_scrobble_single(
540 &self,
541 exact_edit: &ExactScrobbleEdit,
542 max_retries: u32,
543 ) -> Result<EditResponse> {
544 let config = RetryConfig {
545 max_retries,
546 base_delay: 5,
547 max_delay: 300,
548 enabled: true,
549 };
550
551 let edit_clone = exact_edit.clone();
552 let client = self.clone();
553
554 match retry::retry_with_backoff(
555 config,
556 "Edit scrobble",
557 || client.edit_scrobble_impl(&edit_clone),
558 |delay, rate_limit_timestamp, operation_name| {
559 self.broadcast_event(ClientEvent::RateLimited {
560 delay_seconds: delay,
561 request: None, rate_limit_type: RateLimitType::ResponsePattern,
563 rate_limit_timestamp,
564 });
565 log::debug!("{operation_name} rate limited, waiting {delay} seconds");
566 },
567 |total_duration, _operation_name| {
568 self.broadcast_event(ClientEvent::RateLimitEnded {
569 request: crate::types::RequestInfo::from_url_and_method(
570 &format!(
571 "edit_scrobble/{}/{}",
572 edit_clone.artist_name, edit_clone.track_name
573 ),
574 "POST",
575 ),
576 rate_limit_type: RateLimitType::ResponsePattern,
577 total_rate_limit_duration_seconds: total_duration,
578 });
579 },
580 )
581 .await
582 {
583 Ok(retry_result) => Ok(EditResponse::single(
584 retry_result.result,
585 None,
586 None,
587 exact_edit.clone(),
588 )),
589 Err(LastFmError::RateLimit { .. }) => Ok(EditResponse::single(
590 false,
591 Some(format!("Rate limit exceeded after {max_retries} retries")),
592 None,
593 exact_edit.clone(),
594 )),
595 Err(other_error) => Ok(EditResponse::single(
596 false,
597 Some(other_error.to_string()),
598 None,
599 exact_edit.clone(),
600 )),
601 }
602 }
603
604 async fn edit_scrobble_impl(&self, exact_edit: &ExactScrobbleEdit) -> Result<bool> {
605 let start_time = std::time::Instant::now();
606 let result = self.edit_scrobble_impl_internal(exact_edit).await;
607 let duration_ms = start_time.elapsed().as_millis() as u64;
608
609 match &result {
610 Ok(success) => {
611 self.broadcast_event(ClientEvent::EditAttempted {
612 edit: exact_edit.clone(),
613 success: *success,
614 error_message: None,
615 duration_ms,
616 });
617 }
618 Err(error) => {
619 self.broadcast_event(ClientEvent::EditAttempted {
620 edit: exact_edit.clone(),
621 success: false,
622 error_message: Some(error.to_string()),
623 duration_ms,
624 });
625 }
626 }
627
628 result
629 }
630
631 async fn edit_scrobble_impl_internal(&self, exact_edit: &ExactScrobbleEdit) -> Result<bool> {
632 let edit_url = {
633 let session = self.session.lock().unwrap();
634 format!(
635 "{}/user/{}/library/edit?edited-variation=library-track-scrobble",
636 session.base_url, session.username
637 )
638 };
639
640 log::debug!("Getting fresh CSRF token for edit");
641 let form_html = self.get_edit_form_html(&edit_url).await?;
642
643 let form_document = Html::parse_document(&form_html);
644 let fresh_csrf_token = self.extract_csrf_token(&form_document)?;
645
646 log::debug!("Submitting edit with fresh token");
647
648 let form_data = exact_edit.build_form_data(&fresh_csrf_token);
649
650 log::debug!(
651 "Editing scrobble: '{}' -> '{}'",
652 exact_edit.track_name_original,
653 exact_edit.track_name
654 );
655 {
656 let session = self.session.lock().unwrap();
657 log::trace!("Session cookies count: {}", session.cookies.len());
658 }
659
660 let mut request = Request::new(Method::Post, edit_url.parse::<Url>().unwrap());
661
662 let referer_url = {
663 let session = self.session.lock().unwrap();
664 headers::add_cookies(&mut request, &session.cookies);
665 format!("{}/user/{}/library", session.base_url, session.username)
666 };
667
668 headers::add_edit_headers(&mut request, &referer_url);
669
670 let form_string: String = form_data
671 .iter()
672 .map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v)))
673 .collect::<Vec<_>>()
674 .join("&");
675
676 request.set_body(form_string);
677
678 let request_info = RequestInfo::from_url_and_method(&edit_url, "POST");
679 let request_start = std::time::Instant::now();
680
681 self.broadcast_event(ClientEvent::RequestStarted {
682 request: request_info.clone(),
683 });
684
685 let mut response = self
686 .client
687 .send(request)
688 .await
689 .map_err(|e| LastFmError::Http(e.to_string()))?;
690
691 self.broadcast_event(ClientEvent::RequestCompleted {
692 request: request_info.clone(),
693 status_code: response.status().into(),
694 duration_ms: request_start.elapsed().as_millis() as u64,
695 });
696
697 log::debug!("Edit response status: {}", response.status());
698
699 let response_text = response
700 .body_string()
701 .await
702 .map_err(|e| LastFmError::Http(e.to_string()))?;
703
704 let analysis = edit_analysis::analyze_edit_response(&response_text, response.status());
705
706 Ok(analysis.success)
707 }
708
709 async fn get_edit_form_html(&self, edit_url: &str) -> Result<String> {
710 let mut form_response = self.get(edit_url).await?;
711 let form_html = form_response
712 .body_string()
713 .await
714 .map_err(|e| LastFmError::Http(e.to_string()))?;
715
716 log::debug!("Edit form response status: {}", form_response.status());
717 Ok(form_html)
718 }
719
720 pub async fn load_edit_form_values_internal(
721 &self,
722 track_name: &str,
723 artist_name: &str,
724 ) -> Result<Vec<ExactScrobbleEdit>> {
725 log::debug!("Loading edit form values for '{track_name}' by '{artist_name}'");
726
727 let base_track_url = {
728 let session = self.session.lock().unwrap();
729 format!(
730 "{}/user/{}/library/music/+noredirect/{}/_/{}",
731 session.base_url,
732 session.username,
733 urlencoding::encode(artist_name),
734 urlencoding::encode(track_name)
735 )
736 };
737
738 log::debug!("Fetching track page: {base_track_url}");
739
740 let mut response = self.get(&base_track_url).await?;
741 let html = response
742 .body_string()
743 .await
744 .map_err(|e| crate::LastFmError::Http(e.to_string()))?;
745
746 let document = Html::parse_document(&html);
747
748 let mut all_scrobble_edits = Vec::new();
749 let mut unique_albums = std::collections::HashSet::new();
750 let max_pages = 5;
751
752 let page_edits = self.extract_scrobble_edits_from_page(
753 &document,
754 track_name,
755 artist_name,
756 &mut unique_albums,
757 )?;
758 all_scrobble_edits.extend(page_edits);
759
760 log::debug!(
761 "Page 1: found {} unique album variations",
762 all_scrobble_edits.len()
763 );
764
765 let pagination_selector = Selector::parse(".pagination .pagination-next").unwrap();
766 let mut has_next_page = document.select(&pagination_selector).next().is_some();
767 let mut page = 2;
768
769 while has_next_page && page <= max_pages {
770 let page_url = {
771 let session = self.session.lock().unwrap();
772 format!(
773 "{}/user/{}/library/music/{}/_/{}?page={page}",
774 session.base_url,
775 session.username,
776 urlencoding::encode(artist_name),
777 urlencoding::encode(track_name)
778 )
779 };
780
781 log::debug!("Fetching page {page} for additional album variations");
782
783 let mut response = self.get(&page_url).await?;
784 let html = response
785 .body_string()
786 .await
787 .map_err(|e| crate::LastFmError::Http(e.to_string()))?;
788
789 let document = Html::parse_document(&html);
790
791 let page_edits = self.extract_scrobble_edits_from_page(
792 &document,
793 track_name,
794 artist_name,
795 &mut unique_albums,
796 )?;
797
798 let initial_count = all_scrobble_edits.len();
799 all_scrobble_edits.extend(page_edits);
800 let found_new_unique_albums = all_scrobble_edits.len() > initial_count;
801
802 has_next_page = document.select(&pagination_selector).next().is_some();
803
804 log::debug!(
805 "Page {page}: found {} total unique albums ({})",
806 all_scrobble_edits.len(),
807 if found_new_unique_albums {
808 "new albums found"
809 } else {
810 "no new unique albums"
811 }
812 );
813
814 page += 1;
815 }
816
817 if all_scrobble_edits.is_empty() {
818 return Err(crate::LastFmError::Parse(format!(
819 "No scrobble forms found for track '{track_name}' by '{artist_name}'"
820 )));
821 }
822
823 log::debug!(
824 "Final result: found {} unique album variations for '{track_name}' by '{artist_name}'",
825 all_scrobble_edits.len(),
826 );
827
828 Ok(all_scrobble_edits)
829 }
830
831 fn extract_scrobble_edits_from_page(
832 &self,
833 document: &Html,
834 expected_track: &str,
835 expected_artist: &str,
836 unique_albums: &mut std::collections::HashSet<(String, String)>,
837 ) -> Result<Vec<ExactScrobbleEdit>> {
838 let mut scrobble_edits = Vec::new();
839 let table_selector =
840 Selector::parse("table.chartlist:not(.chartlist__placeholder)").unwrap();
841 let table = document.select(&table_selector).next().ok_or_else(|| {
842 crate::LastFmError::Parse("No chartlist table found on track page".to_string())
843 })?;
844
845 let row_selector = Selector::parse("tr").unwrap();
846 for row in table.select(&row_selector) {
847 let count_bar_link_selector = Selector::parse(".chartlist-count-bar-link").unwrap();
848 if row.select(&count_bar_link_selector).next().is_some() {
849 log::debug!("Found count bar link, skipping aggregated row");
850 continue;
851 }
852
853 let form_selector = Selector::parse("form[data-edit-scrobble]").unwrap();
854 if let Some(form) = row.select(&form_selector).next() {
855 let extract_form_value = |name: &str| -> Option<String> {
856 let selector = Selector::parse(&format!("input[name='{name}']")).unwrap();
857 form.select(&selector)
858 .next()
859 .and_then(|input| input.value().attr("value"))
860 .map(|s| s.to_string())
861 };
862
863 let form_track = extract_form_value("track_name").unwrap_or_default();
864 let form_artist = extract_form_value("artist_name").unwrap_or_default();
865 let form_album = extract_form_value("album_name").unwrap_or_default();
866 let form_album_artist =
867 extract_form_value("album_artist_name").unwrap_or_else(|| form_artist.clone());
868 let form_timestamp = extract_form_value("timestamp").unwrap_or_default();
869
870 if form_track == expected_track && form_artist == expected_artist {
871 let album_key = (form_album.clone(), form_album_artist.clone());
872 if unique_albums.insert(album_key) {
873 let timestamp = if form_timestamp.is_empty() {
874 None
875 } else {
876 form_timestamp.parse::<u64>().ok()
877 };
878
879 if let Some(timestamp) = timestamp {
880 let scrobble_edit = ExactScrobbleEdit::new(
881 form_track.clone(),
882 form_album.clone(),
883 form_artist.clone(),
884 form_album_artist.clone(),
885 form_track,
886 form_album,
887 form_artist,
888 form_album_artist,
889 timestamp,
890 true,
891 );
892 scrobble_edits.push(scrobble_edit);
893 } else {
894 log::warn!(
895 "⚠️ Skipping form without valid timestamp: '{form_album}' by '{form_album_artist}'"
896 );
897 }
898 }
899 }
900 }
901 }
902
903 Ok(scrobble_edits)
904 }
905
906 pub async fn get_artist_tracks_page(&self, artist: &str, page: u32) -> Result<TrackPage> {
907 let url = {
908 let session = self.session.lock().unwrap();
909 format!(
910 "{}/user/{}/library/music/{}/+tracks?page={}&ajax=true",
911 session.base_url,
912 session.username,
913 artist.replace(" ", "+"),
914 page
915 )
916 };
917
918 log::debug!("Fetching tracks page {page} for artist: {artist}");
919 let mut response = self.get(&url).await?;
920 let content = response
921 .body_string()
922 .await
923 .map_err(|e| LastFmError::Http(e.to_string()))?;
924
925 log::debug!(
926 "AJAX response: {} status, {} chars",
927 response.status(),
928 content.len()
929 );
930
931 log::debug!("Parsing HTML response from AJAX endpoint");
932 let document = Html::parse_document(&content);
933 self.parser.parse_tracks_page(&document, page, artist, None)
934 }
935
936 pub fn extract_tracks_from_document(
937 &self,
938 document: &Html,
939 artist: &str,
940 album: Option<&str>,
941 ) -> Result<Vec<Track>> {
942 self.parser
943 .extract_tracks_from_document(document, artist, album)
944 }
945
946 pub fn parse_tracks_page(
947 &self,
948 document: &Html,
949 page_number: u32,
950 artist: &str,
951 album: Option<&str>,
952 ) -> Result<TrackPage> {
953 self.parser
954 .parse_tracks_page(document, page_number, artist, album)
955 }
956
957 pub fn parse_recent_scrobbles(&self, document: &Html) -> Result<Vec<Track>> {
958 self.parser.parse_recent_scrobbles(document)
959 }
960
961 fn extract_csrf_token(&self, document: &Html) -> Result<String> {
962 let csrf_selector = Selector::parse("input[name=\"csrfmiddlewaretoken\"]").unwrap();
963
964 document
965 .select(&csrf_selector)
966 .next()
967 .and_then(|input| input.value().attr("value"))
968 .map(|token| token.to_string())
969 .ok_or(LastFmError::CsrfNotFound)
970 }
971
972 pub async fn get(&self, url: &str) -> Result<Response> {
973 self.get_with_retry(url).await
974 }
975
976 async fn get_with_retry(&self, url: &str) -> Result<Response> {
977 let config = self.config.retry.clone();
978
979 let url_string = url.to_string();
980 let client = self.clone();
981
982 let retry_result = retry::retry_with_backoff(
983 config,
984 &format!("GET {url}"),
985 || async {
986 let mut response = client.get_with_redirects(&url_string, 0).await?;
987
988 let body = client
989 .extract_response_body(&url_string, &mut response)
990 .await?;
991
992 if response.status().is_success() && client.is_rate_limit_response(&body) {
993 log::debug!("Response body contains rate limit patterns");
994 return Err(LastFmError::RateLimit { retry_after: 60 });
995 }
996
997 let mut new_response = http_types::Response::new(response.status());
998 for (name, values) in response.iter() {
999 for value in values {
1000 let _ = new_response.insert_header(name.clone(), value.clone());
1001 }
1002 }
1003 new_response.set_body(body);
1004
1005 Ok(new_response)
1006 },
1007 |delay, rate_limit_timestamp, operation_name| {
1008 self.broadcast_event(ClientEvent::RateLimited {
1009 delay_seconds: delay,
1010 request: None, rate_limit_type: RateLimitType::ResponsePattern,
1012 rate_limit_timestamp,
1013 });
1014 log::debug!("{operation_name} rate limited, waiting {delay} seconds");
1015 },
1016 |total_duration, _operation_name| {
1017 self.broadcast_event(ClientEvent::RateLimitEnded {
1018 request: crate::types::RequestInfo::from_url_and_method(&url_string, "GET"),
1019 rate_limit_type: RateLimitType::ResponsePattern,
1020 total_rate_limit_duration_seconds: total_duration,
1021 });
1022 },
1023 )
1024 .await?;
1025
1026 Ok(retry_result.result)
1027 }
1028
1029 async fn get_with_redirects(&self, url: &str, redirect_count: u32) -> Result<Response> {
1030 if redirect_count > 5 {
1031 return Err(LastFmError::Http("Too many redirects".to_string()));
1032 }
1033
1034 let mut request = Request::new(Method::Get, url.parse::<Url>().unwrap());
1035
1036 {
1037 let session = self.session.lock().unwrap();
1038 headers::add_cookies(&mut request, &session.cookies);
1039 if session.cookies.is_empty() && url.contains("page=") {
1040 log::debug!("No cookies available for paginated request!");
1041 }
1042 }
1043
1044 let is_ajax = url.contains("ajax=true");
1045 let referer_url = if url.contains("page=") {
1046 Some(url.split('?').next().unwrap_or(url))
1047 } else {
1048 None
1049 };
1050
1051 headers::add_get_headers(&mut request, is_ajax, referer_url);
1052
1053 let request_info = RequestInfo::from_url_and_method(url, "GET");
1054 let request_start = std::time::Instant::now();
1055
1056 self.broadcast_event(ClientEvent::RequestStarted {
1057 request: request_info.clone(),
1058 });
1059
1060 let response = self
1061 .client
1062 .send(request)
1063 .await
1064 .map_err(|e| LastFmError::Http(e.to_string()))?;
1065
1066 self.broadcast_event(ClientEvent::RequestCompleted {
1067 request: request_info.clone(),
1068 status_code: response.status().into(),
1069 duration_ms: request_start.elapsed().as_millis() as u64,
1070 });
1071
1072 self.extract_cookies(&response);
1073
1074 if response.status() == 302 || response.status() == 301 {
1075 if let Some(location) = response.header("location") {
1076 if let Some(redirect_url) = location.get(0) {
1077 let redirect_url_str = redirect_url.as_str();
1078 if url.contains("page=") {
1079 log::debug!("Following redirect from {url} to {redirect_url_str}");
1080
1081 if redirect_url_str.contains("/login") {
1082 log::debug!("Redirect to login page - authentication failed for paginated request");
1083 return Err(LastFmError::Auth(
1084 "Session expired or invalid for paginated request".to_string(),
1085 ));
1086 }
1087 }
1088
1089 let full_redirect_url = if redirect_url_str.starts_with('/') {
1090 let base_url = self.session.lock().unwrap().base_url.clone();
1091 format!("{base_url}{redirect_url_str}")
1092 } else if redirect_url_str.starts_with("http") {
1093 redirect_url_str.to_string()
1094 } else {
1095 let base_url = url
1096 .rsplit('/')
1097 .skip(1)
1098 .collect::<Vec<_>>()
1099 .into_iter()
1100 .rev()
1101 .collect::<Vec<_>>()
1102 .join("/");
1103 format!("{base_url}/{redirect_url_str}")
1104 };
1105
1106 return Box::pin(
1107 self.get_with_redirects(&full_redirect_url, redirect_count + 1),
1108 )
1109 .await;
1110 }
1111 }
1112 }
1113
1114 if self.config.rate_limit.detect_by_status && response.status() == 429 {
1115 let retry_after = response
1116 .header("retry-after")
1117 .and_then(|h| h.get(0))
1118 .and_then(|v| v.as_str().parse::<u64>().ok())
1119 .unwrap_or(60);
1120 self.broadcast_event(ClientEvent::RateLimited {
1121 delay_seconds: retry_after,
1122 request: Some(request_info.clone()),
1123 rate_limit_type: RateLimitType::Http429,
1124 rate_limit_timestamp: std::time::SystemTime::now()
1125 .duration_since(std::time::UNIX_EPOCH)
1126 .unwrap_or_default()
1127 .as_secs(),
1128 });
1129 return Err(LastFmError::RateLimit { retry_after });
1130 }
1131
1132 if self.config.rate_limit.detect_by_status && response.status() == 403 {
1133 log::debug!("Got 403 response, checking if it's a rate limit");
1134 {
1135 let session = self.session.lock().unwrap();
1136 if !session.cookies.is_empty() {
1137 log::debug!("403 on authenticated request - likely rate limit");
1138 self.broadcast_event(ClientEvent::RateLimited {
1139 delay_seconds: 60,
1140 request: Some(request_info.clone()),
1141 rate_limit_type: RateLimitType::Http403,
1142 rate_limit_timestamp: std::time::SystemTime::now()
1143 .duration_since(std::time::UNIX_EPOCH)
1144 .unwrap_or_default()
1145 .as_secs(),
1146 });
1147 return Err(LastFmError::RateLimit { retry_after: 60 });
1148 }
1149 }
1150 }
1151
1152 Ok(response)
1153 }
1154
1155 fn is_rate_limit_response(&self, response_body: &str) -> bool {
1156 let rate_limit_config = &self.config.rate_limit;
1157
1158 if !rate_limit_config.detect_by_patterns && rate_limit_config.custom_patterns.is_empty() {
1159 return false;
1160 }
1161
1162 let body_lower = response_body.to_lowercase();
1163
1164 for pattern in &rate_limit_config.custom_patterns {
1165 if body_lower.contains(&pattern.to_lowercase()) {
1166 return true;
1167 }
1168 }
1169
1170 if rate_limit_config.detect_by_patterns {
1171 for pattern in &rate_limit_config.patterns {
1172 if body_lower.contains(&pattern.to_lowercase()) {
1173 return true;
1174 }
1175 }
1176 }
1177
1178 false
1179 }
1180
1181 fn extract_cookies(&self, response: &Response) {
1182 let mut session = self.session.lock().unwrap();
1183 extract_cookies_from_response(response, &mut session.cookies);
1184 }
1185
1186 async fn extract_response_body(&self, _url: &str, response: &mut Response) -> Result<String> {
1187 let body = response
1188 .body_string()
1189 .await
1190 .map_err(|e| LastFmError::Http(e.to_string()))?;
1191
1192 Ok(body)
1193 }
1194
1195 pub async fn get_artist_albums_page(&self, artist: &str, page: u32) -> Result<AlbumPage> {
1196 let url = {
1197 let session = self.session.lock().unwrap();
1198 format!(
1199 "{}/user/{}/library/music/{}/+albums?page={}&ajax=true",
1200 session.base_url,
1201 session.username,
1202 artist.replace(" ", "+"),
1203 page
1204 )
1205 };
1206
1207 log::debug!("Fetching albums page {page} for artist: {artist}");
1208 let mut response = self.get(&url).await?;
1209 let content = response
1210 .body_string()
1211 .await
1212 .map_err(|e| LastFmError::Http(e.to_string()))?;
1213
1214 log::debug!(
1215 "AJAX response: {} status, {} chars",
1216 response.status(),
1217 content.len()
1218 );
1219
1220 log::debug!("Parsing HTML response from AJAX endpoint");
1221 let document = Html::parse_document(&content);
1222 self.parser.parse_albums_page(&document, page, artist)
1223 }
1224
1225 pub async fn get_album_tracks_page(
1226 &self,
1227 album_name: &str,
1228 artist_name: &str,
1229 page: u32,
1230 ) -> Result<TrackPage> {
1231 let url = {
1232 let session = self.session.lock().unwrap();
1233 format!(
1234 "{}/user/{}/library/music/{}/{}?page={}&ajax=true",
1235 session.base_url,
1236 session.username,
1237 artist_name.replace(" ", "+"),
1238 album_name.replace(" ", "+"),
1239 page
1240 )
1241 };
1242
1243 log::debug!("Fetching tracks page {page} for album '{album_name}' by '{artist_name}'");
1244 let mut response = self.get(&url).await?;
1245 let content = response
1246 .body_string()
1247 .await
1248 .map_err(|e| LastFmError::Http(e.to_string()))?;
1249
1250 log::debug!(
1251 "AJAX response: {} status, {} chars",
1252 response.status(),
1253 content.len()
1254 );
1255
1256 log::debug!("Parsing HTML response from AJAX endpoint");
1257 let document = Html::parse_document(&content);
1258 self.parser
1259 .parse_tracks_page(&document, page, artist_name, Some(album_name))
1260 }
1261
1262 pub async fn search_tracks_page(&self, query: &str, page: u32) -> Result<TrackPage> {
1263 let url = {
1264 let session = self.session.lock().unwrap();
1265 format!(
1266 "{}/user/{}/library/tracks/search?page={}&query={}&ajax=1",
1267 session.base_url,
1268 session.username,
1269 page,
1270 urlencoding::encode(query)
1271 )
1272 };
1273
1274 log::debug!("Searching tracks for query '{query}' on page {page}");
1275 let mut response = self.get(&url).await?;
1276 let content = response
1277 .body_string()
1278 .await
1279 .map_err(|e| LastFmError::Http(e.to_string()))?;
1280
1281 log::debug!(
1282 "Track search response: {} status, {} chars",
1283 response.status(),
1284 content.len()
1285 );
1286
1287 let document = Html::parse_document(&content);
1288 let tracks = self.parser.parse_track_search_results(&document)?;
1289
1290 let (has_next_page, total_pages) = self.parser.parse_pagination(&document, page)?;
1293
1294 Ok(TrackPage {
1295 tracks,
1296 page_number: page,
1297 has_next_page,
1298 total_pages,
1299 })
1300 }
1301
1302 pub async fn search_albums_page(&self, query: &str, page: u32) -> Result<AlbumPage> {
1303 let url = {
1304 let session = self.session.lock().unwrap();
1305 format!(
1306 "{}/user/{}/library/albums/search?page={}&query={}&ajax=1",
1307 session.base_url,
1308 session.username,
1309 page,
1310 urlencoding::encode(query)
1311 )
1312 };
1313
1314 log::debug!("Searching albums for query '{query}' on page {page}");
1315 let mut response = self.get(&url).await?;
1316 let content = response
1317 .body_string()
1318 .await
1319 .map_err(|e| LastFmError::Http(e.to_string()))?;
1320
1321 log::debug!(
1322 "Album search response: {} status, {} chars",
1323 response.status(),
1324 content.len()
1325 );
1326
1327 let document = Html::parse_document(&content);
1328 let albums = self.parser.parse_album_search_results(&document)?;
1329
1330 let (has_next_page, total_pages) = self.parser.parse_pagination(&document, page)?;
1332
1333 Ok(AlbumPage {
1334 albums,
1335 page_number: page,
1336 has_next_page,
1337 total_pages,
1338 })
1339 }
1340}
1341
1342#[async_trait(?Send)]
1343impl LastFmEditClient for LastFmEditClientImpl {
1344 fn username(&self) -> String {
1345 self.username()
1346 }
1347
1348 async fn get_recent_scrobbles(&self, page: u32) -> Result<Vec<Track>> {
1349 self.get_recent_scrobbles(page).await
1350 }
1351
1352 async fn find_recent_scrobble_for_track(
1353 &self,
1354 track_name: &str,
1355 artist_name: &str,
1356 max_pages: u32,
1357 ) -> Result<Option<Track>> {
1358 self.find_recent_scrobble_for_track(track_name, artist_name, max_pages)
1359 .await
1360 }
1361
1362 async fn edit_scrobble(&self, edit: &ScrobbleEdit) -> Result<EditResponse> {
1363 self.edit_scrobble(edit).await
1364 }
1365
1366 async fn edit_scrobble_single(
1367 &self,
1368 exact_edit: &ExactScrobbleEdit,
1369 max_retries: u32,
1370 ) -> Result<EditResponse> {
1371 self.edit_scrobble_single(exact_edit, max_retries).await
1372 }
1373
1374 fn get_session(&self) -> LastFmEditSession {
1375 self.get_session()
1376 }
1377
1378 fn restore_session(&self, session: LastFmEditSession) {
1379 self.restore_session(session)
1380 }
1381
1382 fn subscribe(&self) -> ClientEventReceiver {
1383 self.subscribe()
1384 }
1385
1386 fn latest_event(&self) -> Option<ClientEvent> {
1387 self.latest_event()
1388 }
1389
1390 fn discover_scrobbles(
1391 &self,
1392 edit: ScrobbleEdit,
1393 ) -> Box<dyn crate::AsyncDiscoveryIterator<crate::ExactScrobbleEdit>> {
1394 let track_name = edit.track_name_original.clone();
1395 let album_name = edit.album_name_original.clone();
1396
1397 match (&track_name, &album_name) {
1398 (Some(track_name), Some(album_name)) => Box::new(crate::ExactMatchDiscovery::new(
1399 self.clone(),
1400 edit,
1401 track_name.clone(),
1402 album_name.clone(),
1403 )),
1404
1405 (Some(track_name), None) => Box::new(crate::TrackVariationsDiscovery::new(
1406 self.clone(),
1407 edit,
1408 track_name.clone(),
1409 )),
1410
1411 (None, Some(album_name)) => Box::new(crate::AlbumTracksDiscovery::new(
1412 self.clone(),
1413 edit,
1414 album_name.clone(),
1415 )),
1416
1417 (None, None) => Box::new(crate::ArtistTracksDiscovery::new(self.clone(), edit)),
1418 }
1419 }
1420
1421 async fn get_artist_tracks_page(&self, artist: &str, page: u32) -> Result<TrackPage> {
1422 self.get_artist_tracks_page(artist, page).await
1423 }
1424
1425 async fn get_artist_albums_page(&self, artist: &str, page: u32) -> Result<AlbumPage> {
1426 self.get_artist_albums_page(artist, page).await
1427 }
1428
1429 async fn get_album_tracks_page(
1430 &self,
1431 album_name: &str,
1432 artist_name: &str,
1433 page: u32,
1434 ) -> Result<TrackPage> {
1435 self.get_album_tracks_page(album_name, artist_name, page)
1436 .await
1437 }
1438
1439 fn artist_tracks(&self, artist: &str) -> Box<dyn crate::AsyncPaginatedIterator<Track>> {
1440 Box::new(crate::ArtistTracksIterator::new(
1441 self.clone(),
1442 artist.to_string(),
1443 ))
1444 }
1445
1446 fn artist_albums(&self, artist: &str) -> Box<dyn crate::AsyncPaginatedIterator<crate::Album>> {
1447 Box::new(crate::ArtistAlbumsIterator::new(
1448 self.clone(),
1449 artist.to_string(),
1450 ))
1451 }
1452
1453 fn album_tracks(
1454 &self,
1455 album_name: &str,
1456 artist_name: &str,
1457 ) -> Box<dyn crate::AsyncPaginatedIterator<Track>> {
1458 Box::new(crate::AlbumTracksIterator::new(
1459 self.clone(),
1460 album_name.to_string(),
1461 artist_name.to_string(),
1462 ))
1463 }
1464
1465 fn recent_tracks(&self) -> Box<dyn crate::AsyncPaginatedIterator<Track>> {
1466 Box::new(crate::RecentTracksIterator::new(self.clone()))
1467 }
1468
1469 fn recent_tracks_from_page(
1470 &self,
1471 starting_page: u32,
1472 ) -> Box<dyn crate::AsyncPaginatedIterator<Track>> {
1473 Box::new(crate::RecentTracksIterator::with_starting_page(
1474 self.clone(),
1475 starting_page,
1476 ))
1477 }
1478
1479 fn search_tracks(&self, query: &str) -> Box<dyn crate::AsyncPaginatedIterator<Track>> {
1480 Box::new(crate::SearchTracksIterator::new(
1481 self.clone(),
1482 query.to_string(),
1483 ))
1484 }
1485
1486 fn search_albums(&self, query: &str) -> Box<dyn crate::AsyncPaginatedIterator<crate::Album>> {
1487 Box::new(crate::SearchAlbumsIterator::new(
1488 self.clone(),
1489 query.to_string(),
1490 ))
1491 }
1492
1493 async fn search_tracks_page(&self, query: &str, page: u32) -> Result<crate::TrackPage> {
1494 self.search_tracks_page(query, page).await
1495 }
1496
1497 async fn search_albums_page(&self, query: &str, page: u32) -> Result<crate::AlbumPage> {
1498 self.search_albums_page(query, page).await
1499 }
1500
1501 async fn validate_session(&self) -> bool {
1502 self.validate_session().await
1503 }
1504
1505 async fn delete_scrobble(
1506 &self,
1507 artist_name: &str,
1508 track_name: &str,
1509 timestamp: u64,
1510 ) -> Result<bool> {
1511 self.delete_scrobble(artist_name, track_name, timestamp)
1512 .await
1513 }
1514}