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