1use crate::edit_analysis;
2use crate::headers;
3use crate::login::extract_cookies_from_response;
4use crate::parsing::LastFmParser;
5use crate::r#trait::{LastFmBaseClient, LastFmEditClient};
6use crate::retry;
7use crate::types::{
8 AlbumPage, ArtistPage, ClientConfig, ClientEvent, ClientEventReceiver, DelayReason,
9 EditResponse, ExactScrobbleEdit, LastFmEditSession, LastFmError, RateLimitConfig,
10 RateLimitType, RequestInfo, RetryConfig, ScrobbleEdit, SharedEventBroadcaster,
11 SingleEditResponse, Track, TrackPage,
12};
13use crate::Result;
14use crate::{cancel, CancellationState};
15use async_trait::async_trait;
16use http_client::{HttpClient, Request, Response};
17use http_types::{Method, Url};
18use scraper::{Html, Selector};
19use std::sync::{Arc, Mutex};
20
21#[derive(Clone)]
22pub struct LastFmEditClientImpl {
23 client: Arc<dyn HttpClient + Send + Sync>,
24 session: Arc<Mutex<LastFmEditSession>>,
25 parser: LastFmParser,
26 broadcaster: Arc<SharedEventBroadcaster>,
27 config: ClientConfig,
28 cancel: CancellationState,
29 api_key: Option<String>,
30}
31
32impl LastFmEditClientImpl {
33 fn lastfm_encode(&self, input: &str) -> String {
35 urlencoding::encode(input).to_string()
36 }
37
38 pub fn from_session(
39 client: Box<dyn HttpClient + Send + Sync>,
40 session: LastFmEditSession,
41 ) -> Self {
42 Self::from_session_with_arc(Arc::from(client), session)
43 }
44
45 fn from_session_with_arc(
46 client: Arc<dyn HttpClient + Send + Sync>,
47 session: LastFmEditSession,
48 ) -> Self {
49 Self::from_session_with_broadcaster_arc(
50 client,
51 session,
52 Arc::new(SharedEventBroadcaster::new()),
53 )
54 }
55
56 pub fn from_session_with_rate_limit_patterns(
57 client: Box<dyn HttpClient + Send + Sync>,
58 session: LastFmEditSession,
59 rate_limit_patterns: Vec<String>,
60 ) -> Self {
61 let config = ClientConfig::default()
62 .with_rate_limit_config(RateLimitConfig::default().with_patterns(rate_limit_patterns));
63 Self::from_session_with_client_config(client, session, config)
64 }
65
66 pub async fn login_with_credentials(
67 client: Box<dyn HttpClient + Send + Sync>,
68 username: &str,
69 password: &str,
70 ) -> Result<Self> {
71 let client_arc: Arc<dyn HttpClient + Send + Sync> = Arc::from(client);
72 let login_manager =
73 crate::login::LoginManager::new(client_arc.clone(), "https://www.last.fm".to_string());
74 let session = login_manager.login(username, password).await?;
75 Ok(Self::from_session_with_arc(client_arc, session))
76 }
77
78 pub fn from_session_with_client_config(
79 client: Box<dyn HttpClient + Send + Sync>,
80 session: LastFmEditSession,
81 config: ClientConfig,
82 ) -> Self {
83 Self::from_session_with_client_config_arc(Arc::from(client), session, config)
84 }
85
86 pub async fn login_with_credentials_and_client_config(
87 client: Box<dyn HttpClient + Send + Sync>,
88 username: &str,
89 password: &str,
90 config: ClientConfig,
91 ) -> Result<Self> {
92 let client_arc: Arc<dyn HttpClient + Send + Sync> = Arc::from(client);
93 let login_manager =
94 crate::login::LoginManager::new(client_arc.clone(), "https://www.last.fm".to_string());
95 let session = login_manager.login(username, password).await?;
96 Ok(Self::from_session_with_client_config_arc(
97 client_arc, session, config,
98 ))
99 }
100
101 pub fn from_session_with_config(
102 client: Box<dyn HttpClient + Send + Sync>,
103 session: LastFmEditSession,
104 retry_config: RetryConfig,
105 rate_limit_config: RateLimitConfig,
106 ) -> Self {
107 Self::from_session_with_config_arc(
108 Arc::from(client),
109 session,
110 retry_config,
111 rate_limit_config,
112 )
113 }
114
115 pub async fn login_with_credentials_and_config(
116 client: Box<dyn HttpClient + Send + Sync>,
117 username: &str,
118 password: &str,
119 retry_config: RetryConfig,
120 rate_limit_config: RateLimitConfig,
121 ) -> Result<Self> {
122 let client_arc: Arc<dyn HttpClient + Send + Sync> = Arc::from(client);
123 let login_manager =
124 crate::login::LoginManager::new(client_arc.clone(), "https://www.last.fm".to_string());
125 let session = login_manager.login(username, password).await?;
126 Ok(Self::from_session_with_config_arc(
127 client_arc,
128 session,
129 retry_config,
130 rate_limit_config,
131 ))
132 }
133
134 fn from_session_with_broadcaster(
135 client: Box<dyn HttpClient + Send + Sync>,
136 session: LastFmEditSession,
137 broadcaster: Arc<SharedEventBroadcaster>,
138 ) -> Self {
139 Self::from_session_with_broadcaster_arc(Arc::from(client), session, broadcaster)
140 }
141
142 fn from_session_with_client_config_arc(
143 client: Arc<dyn HttpClient + Send + Sync>,
144 session: LastFmEditSession,
145 config: ClientConfig,
146 ) -> Self {
147 Self::from_session_with_client_config_and_broadcaster_arc(
148 client,
149 session,
150 config,
151 Arc::new(SharedEventBroadcaster::new()),
152 )
153 }
154
155 fn from_session_with_config_arc(
156 client: Arc<dyn HttpClient + Send + Sync>,
157 session: LastFmEditSession,
158 retry_config: RetryConfig,
159 rate_limit_config: RateLimitConfig,
160 ) -> Self {
161 let config = ClientConfig {
162 retry: retry_config,
163 rate_limit: rate_limit_config,
164 ..Default::default()
165 };
166 Self::from_session_with_client_config_arc(client, session, config)
167 }
168
169 fn from_session_with_broadcaster_arc(
170 client: Arc<dyn HttpClient + Send + Sync>,
171 session: LastFmEditSession,
172 broadcaster: Arc<SharedEventBroadcaster>,
173 ) -> Self {
174 Self::from_session_with_client_config_and_broadcaster_arc(
175 client,
176 session,
177 ClientConfig::default(),
178 broadcaster,
179 )
180 }
181
182 fn from_session_with_client_config_and_broadcaster_arc(
183 client: Arc<dyn HttpClient + Send + Sync>,
184 session: LastFmEditSession,
185 config: ClientConfig,
186 broadcaster: Arc<SharedEventBroadcaster>,
187 ) -> Self {
188 let api_key = config.api_key.clone();
189 Self {
190 client,
191 session: Arc::new(Mutex::new(session)),
192 parser: LastFmParser::new(),
193 broadcaster,
194 config,
195 cancel: CancellationState::new(),
196 api_key,
197 }
198 }
199
200 pub fn get_session(&self) -> LastFmEditSession {
201 self.session.lock().unwrap().clone()
202 }
203
204 pub fn cancel(&self) {
205 self.cancel.cancel();
206 }
207
208 pub fn reset_cancel(&self) {
209 self.cancel.reset();
210 }
211
212 pub fn is_cancelled(&self) -> bool {
213 self.cancel.is_cancelled()
214 }
215
216 fn cancel_rx(&self) -> tokio::sync::watch::Receiver<bool> {
217 self.cancel.subscribe()
218 }
219
220 async fn sleep_ms(&self, delay_ms: u64) -> Result<()> {
221 if delay_ms == 0 {
222 return Ok(());
223 }
224 cancel::sleep_with_cancel(self.cancel_rx(), std::time::Duration::from_millis(delay_ms))
225 .await
226 }
227
228 pub fn with_shared_broadcaster(&self, client: Box<dyn HttpClient + Send + Sync>) -> Self {
229 let session = self.get_session();
230 Self::from_session_with_broadcaster(client, session, self.broadcaster.clone())
231 }
232
233 pub fn username(&self) -> String {
234 self.session.lock().unwrap().username.clone()
235 }
236
237 pub async fn validate_session(&self) -> bool {
238 let test_url = {
239 let session = self.session.lock().unwrap();
240 format!(
241 "{}/settings/subscription/automatic-edits/tracks",
242 session.base_url
243 )
244 };
245
246 let mut request = Request::new(Method::Get, test_url.parse::<Url>().unwrap());
247
248 {
249 let session = self.session.lock().unwrap();
250 headers::add_cookies(&mut request, &session.cookies);
251 }
252
253 headers::add_get_headers(&mut request, false, None);
254
255 match self.client.send(request).await {
256 Ok(response) => {
257 if response.status() == 302 || response.status() == 301 {
258 if let Some(location) = response.header("location") {
259 if let Some(redirect_url) = location.get(0) {
260 let redirect_url_str = redirect_url.as_str();
261 let is_valid = !redirect_url_str.contains("/login");
262
263 return is_valid;
264 }
265 }
266 }
267 true
268 }
269 Err(_e) => false,
270 }
271 }
272
273 pub async fn delete_scrobble(
274 &self,
275 artist_name: &str,
276 track_name: &str,
277 timestamp: u64,
278 ) -> Result<bool> {
279 if !self.config.retry.enabled {
280 return self
281 .delete_scrobble_impl(artist_name, track_name, timestamp)
282 .await;
283 }
284
285 let config = self.config.retry.clone();
286
287 let artist_name = artist_name.to_string();
288 let track_name = track_name.to_string();
289 let client = self.clone();
290
291 match retry::retry_with_backoff_cancelable(
292 config,
293 "Delete scrobble",
294 || client.delete_scrobble_impl(&artist_name, &track_name, timestamp),
295 |delay, rate_limit_timestamp, operation_name| {
296 self.broadcast_event(ClientEvent::RateLimited {
297 delay_seconds: delay,
298 request: None,
299 rate_limit_type: RateLimitType::ResponsePattern,
300 rate_limit_timestamp,
301 });
302 self.broadcast_event(ClientEvent::Delaying {
303 delay_ms: delay * 1000,
304 reason: DelayReason::RetryBackoff,
305 request: None,
306 delay_timestamp: rate_limit_timestamp,
307 });
308 log::debug!("{operation_name} rate limited, waiting {delay} seconds");
309 },
310 |total_duration, _operation_name| {
311 self.broadcast_event(ClientEvent::RateLimitEnded {
312 request: crate::types::RequestInfo::from_url_and_method(
313 &format!("delete_scrobble/{artist_name}/{track_name}/{timestamp}"),
314 "POST",
315 ),
316 rate_limit_type: RateLimitType::ResponsePattern,
317 total_rate_limit_duration_seconds: total_duration,
318 });
319 },
320 Some(self.cancel_rx()),
321 )
322 .await
323 {
324 Ok(retry_result) => Ok(retry_result.result),
325 Err(_) => Ok(false),
326 }
327 }
328
329 async fn delete_scrobble_impl(
330 &self,
331 artist_name: &str,
332 track_name: &str,
333 timestamp: u64,
334 ) -> Result<bool> {
335 let delete_url = {
336 let session = self.session.lock().unwrap();
337 format!(
338 "{}/user/{}/library/delete",
339 session.base_url, session.username
340 )
341 };
342
343 log::debug!("Getting fresh CSRF token for delete");
344 let library_url = {
345 let session = self.session.lock().unwrap();
346 format!("{}/user/{}/library", session.base_url, session.username)
347 };
348
349 let mut response = self.get(&library_url).await?;
350 let content = response
351 .body_string()
352 .await
353 .map_err(|e| LastFmError::Http(e.to_string()))?;
354
355 let document = Html::parse_document(&content);
356 let fresh_csrf_token = self.extract_csrf_token(&document)?;
357
358 log::debug!("Submitting delete request with fresh token");
359
360 let mut request = Request::new(Method::Post, delete_url.parse::<Url>().unwrap());
361
362 let referer_url = {
363 let session = self.session.lock().unwrap();
364 headers::add_cookies(&mut request, &session.cookies);
365 format!("{}/user/{}", session.base_url, session.username)
366 };
367
368 headers::add_edit_headers(&mut request, &referer_url);
369
370 let form_data = [
371 ("csrfmiddlewaretoken", fresh_csrf_token.as_str()),
372 ("artist_name", artist_name),
373 ("track_name", track_name),
374 ("timestamp", ×tamp.to_string()),
375 ("ajax", "1"),
376 ];
377
378 let form_string: String = form_data
379 .iter()
380 .map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v)))
381 .collect::<Vec<_>>()
382 .join("&");
383
384 request.set_body(form_string);
385
386 log::debug!(
387 "Deleting scrobble: '{track_name}' by '{artist_name}' with timestamp {timestamp}"
388 );
389
390 let request_info = RequestInfo::from_url_and_method(&delete_url, "POST");
391 let request_start = std::time::Instant::now();
392
393 self.broadcast_event(ClientEvent::RequestStarted {
394 request: request_info.clone(),
395 });
396
397 let mut response = self
398 .client
399 .send(request)
400 .await
401 .map_err(|e| LastFmError::Http(e.to_string()))?;
402
403 self.broadcast_event(ClientEvent::RequestCompleted {
404 request: request_info.clone(),
405 status_code: response.status().into(),
406 duration_ms: request_start.elapsed().as_millis() as u64,
407 });
408
409 log::debug!("Delete response status: {}", response.status());
410
411 let response_text = response
412 .body_string()
413 .await
414 .map_err(|e| LastFmError::Http(e.to_string()))?;
415
416 let success = response.status().is_success();
417
418 if success {
419 log::debug!("Successfully deleted scrobble");
420 } else {
421 log::debug!("Delete failed with response: {response_text}");
422 }
423
424 Ok(success)
425 }
426
427 pub fn subscribe(&self) -> ClientEventReceiver {
428 self.broadcaster.subscribe()
429 }
430
431 pub fn latest_event(&self) -> Option<ClientEvent> {
432 self.broadcaster.latest_event()
433 }
434
435 fn broadcast_event(&self, event: ClientEvent) {
436 self.broadcaster.broadcast_event(event);
437 }
438
439 pub async fn get_recent_scrobbles(&self, page: u32) -> Result<Vec<Track>> {
440 let url = {
441 let session = self.session.lock().unwrap();
442 format!(
443 "{}/user/{}/library?page={}",
444 session.base_url, session.username, page
445 )
446 };
447
448 log::debug!("Fetching recent scrobbles page {page}");
449 let mut response = self.get(&url).await?;
450 let content = response
451 .body_string()
452 .await
453 .map_err(|e| LastFmError::Http(e.to_string()))?;
454
455 log::debug!(
456 "Recent scrobbles response: {} status, {} chars",
457 response.status(),
458 content.len()
459 );
460
461 let document = Html::parse_document(&content);
462 self.parser.parse_recent_scrobbles(&document)
463 }
464
465 pub async fn get_recent_tracks_page(&self, page: u32) -> Result<TrackPage> {
466 let url = {
467 let session = self.session.lock().unwrap();
468 format!(
469 "{}/user/{}/library?page={}",
470 session.base_url, session.username, page
471 )
472 };
473
474 log::debug!("Fetching recent tracks page {page}");
475 let mut response = self.get(&url).await?;
476 let content = response
477 .body_string()
478 .await
479 .map_err(|e| LastFmError::Http(e.to_string()))?;
480
481 log::debug!(
482 "Recent tracks response: {} status, {} chars",
483 response.status(),
484 content.len()
485 );
486
487 let document = Html::parse_document(&content);
488 let tracks = self.parser.parse_recent_scrobbles(&document)?;
489 let (has_next_page, total_pages) = self.parser.parse_pagination(&document, page)?;
490
491 Ok(TrackPage {
492 tracks,
493 page_number: page,
494 has_next_page,
495 total_pages,
496 })
497 }
498
499 pub async fn find_recent_scrobble_for_track(
500 &self,
501 track_name: &str,
502 artist_name: &str,
503 max_pages: u32,
504 ) -> Result<Option<Track>> {
505 log::debug!("Searching for recent scrobble: '{track_name}' by '{artist_name}'");
506
507 for page in 1..=max_pages {
508 let scrobbles = self.get_recent_scrobbles(page).await?;
509
510 for scrobble in scrobbles {
511 if scrobble.name == track_name && scrobble.artist == artist_name {
512 log::debug!(
513 "Found recent scrobble: '{}' with timestamp {:?}",
514 scrobble.name,
515 scrobble.timestamp
516 );
517 return Ok(Some(scrobble));
518 }
519 }
520 }
521
522 log::debug!(
523 "No recent scrobble found for '{track_name}' by '{artist_name}' in {max_pages} pages"
524 );
525 Ok(None)
526 }
527
528 pub async fn edit_scrobble(&self, edit: &ScrobbleEdit) -> Result<EditResponse> {
529 let discovered_edits = self.discover_scrobble_edit_variations(edit).await?;
530
531 if discovered_edits.is_empty() {
532 let context = match (&edit.track_name_original, &edit.album_name_original) {
533 (Some(track_name), _) => {
534 format!("track '{}' by '{}'", track_name, edit.artist_name_original)
535 }
536 (None, Some(album_name)) => {
537 format!("album '{}' by '{}'", album_name, edit.artist_name_original)
538 }
539 (None, None) => format!("artist '{}'", edit.artist_name_original),
540 };
541 return Err(LastFmError::Parse(format!(
542 "No scrobbles found for {context}. Make sure the names are correct and that you have scrobbled recently."
543 )));
544 }
545
546 let mut all_results = Vec::new();
547
548 for (index, discovered_edit) in discovered_edits.iter().enumerate() {
549 log::debug!(
550 "Processing scrobble {}/{}: '{}' from '{}'",
551 index + 1,
552 discovered_edits.len(),
553 discovered_edit.track_name_original,
554 discovered_edit.album_name_original
555 );
556
557 let mut modified_exact_edit = discovered_edit.clone();
558
559 if let Some(new_track_name) = &edit.track_name {
560 modified_exact_edit.track_name = new_track_name.clone();
561 }
562 if let Some(new_album_name) = &edit.album_name {
563 modified_exact_edit.album_name = new_album_name.clone();
564 }
565 modified_exact_edit.artist_name = edit.artist_name.clone();
566 if let Some(new_album_artist_name) = &edit.album_artist_name {
567 modified_exact_edit.album_artist_name = new_album_artist_name.clone();
568 }
569 modified_exact_edit.edit_all = edit.edit_all;
570
571 let album_info = format!(
572 "{} by {}",
573 modified_exact_edit.album_name_original,
574 modified_exact_edit.album_artist_name_original
575 );
576
577 let single_response = self.edit_scrobble_single(&modified_exact_edit, 3).await?;
578 let success = single_response.success();
579 let message = single_response.message();
580
581 all_results.push(SingleEditResponse {
582 success,
583 message,
584 album_info: Some(album_info),
585 exact_scrobble_edit: modified_exact_edit.clone(),
586 });
587
588 if index < discovered_edits.len() - 1
589 && self.config.operational_delays.edit_delay_ms > 0
590 {
591 log::info!(
592 "Operational edit delay: waiting {}ms before next edit",
593 self.config.operational_delays.edit_delay_ms
594 );
595 let delay_timestamp = std::time::SystemTime::now()
596 .duration_since(std::time::UNIX_EPOCH)
597 .unwrap_or_default()
598 .as_secs();
599 self.broadcast_event(ClientEvent::Delaying {
600 delay_ms: self.config.operational_delays.edit_delay_ms,
601 reason: DelayReason::OperationalEditDelay,
602 request: None,
603 delay_timestamp,
604 });
605 self.sleep_ms(self.config.operational_delays.edit_delay_ms)
606 .await?;
607 }
608 }
609
610 Ok(EditResponse::from_results(all_results))
611 }
612
613 pub async fn edit_scrobble_single(
614 &self,
615 exact_edit: &ExactScrobbleEdit,
616 max_retries: u32,
617 ) -> Result<EditResponse> {
618 if !self.config.retry.enabled || max_retries == 0 {
620 return match self.edit_scrobble_impl(exact_edit).await {
621 Ok(success) => Ok(EditResponse::single(
622 success,
623 None,
624 None,
625 exact_edit.clone(),
626 )),
627 Err(error) => Ok(EditResponse::single(
628 false,
629 Some(error.to_string()),
630 None,
631 exact_edit.clone(),
632 )),
633 };
634 }
635
636 let mut config = self.config.retry.clone();
637 config.max_retries = max_retries;
638
639 let edit_clone = exact_edit.clone();
640 let client = self.clone();
641
642 match retry::retry_with_backoff_cancelable(
643 config,
644 "Edit scrobble",
645 || client.edit_scrobble_impl(&edit_clone),
646 |delay, rate_limit_timestamp, operation_name| {
647 self.broadcast_event(ClientEvent::RateLimited {
648 delay_seconds: delay,
649 request: None, rate_limit_type: RateLimitType::ResponsePattern,
651 rate_limit_timestamp,
652 });
653 self.broadcast_event(ClientEvent::Delaying {
654 delay_ms: delay * 1000,
655 reason: DelayReason::RetryBackoff,
656 request: None,
657 delay_timestamp: rate_limit_timestamp,
658 });
659 log::debug!("{operation_name} rate limited, waiting {delay} seconds");
660 },
661 |total_duration, _operation_name| {
662 self.broadcast_event(ClientEvent::RateLimitEnded {
663 request: crate::types::RequestInfo::from_url_and_method(
664 &format!(
665 "edit_scrobble/{}/{}",
666 edit_clone.artist_name, edit_clone.track_name
667 ),
668 "POST",
669 ),
670 rate_limit_type: RateLimitType::ResponsePattern,
671 total_rate_limit_duration_seconds: total_duration,
672 });
673 },
674 Some(self.cancel_rx()),
675 )
676 .await
677 {
678 Ok(retry_result) => Ok(EditResponse::single(
679 retry_result.result,
680 None,
681 None,
682 exact_edit.clone(),
683 )),
684 Err(LastFmError::RateLimit { .. }) => Ok(EditResponse::single(
685 false,
686 Some(format!("Rate limit exceeded after {max_retries} retries")),
687 None,
688 exact_edit.clone(),
689 )),
690 Err(other_error) => Ok(EditResponse::single(
691 false,
692 Some(other_error.to_string()),
693 None,
694 exact_edit.clone(),
695 )),
696 }
697 }
698
699 async fn edit_scrobble_impl(&self, exact_edit: &ExactScrobbleEdit) -> Result<bool> {
700 let start_time = std::time::Instant::now();
701 let result = self.edit_scrobble_impl_internal(exact_edit).await;
702 let duration_ms = start_time.elapsed().as_millis() as u64;
703
704 match &result {
705 Ok(success) => {
706 self.broadcast_event(ClientEvent::EditAttempted {
707 edit: exact_edit.clone(),
708 success: *success,
709 error_message: None,
710 duration_ms,
711 });
712 }
713 Err(error) => {
714 self.broadcast_event(ClientEvent::EditAttempted {
715 edit: exact_edit.clone(),
716 success: false,
717 error_message: Some(error.to_string()),
718 duration_ms,
719 });
720 }
721 }
722
723 result
724 }
725
726 async fn edit_scrobble_impl_internal(&self, exact_edit: &ExactScrobbleEdit) -> Result<bool> {
727 let edit_url = {
728 let session = self.session.lock().unwrap();
729 format!(
730 "{}/user/{}/library/edit?edited-variation=library-track-scrobble",
731 session.base_url, session.username
732 )
733 };
734
735 log::debug!("Getting fresh CSRF token for edit");
736 let form_html = self.get_edit_form_html(&edit_url).await?;
737
738 let form_document = Html::parse_document(&form_html);
739 let fresh_csrf_token = self.extract_csrf_token(&form_document)?;
740
741 log::debug!("Submitting edit with fresh token");
742
743 let form_data = exact_edit.build_form_data(&fresh_csrf_token);
744
745 log::debug!(
746 "Editing scrobble: '{}' -> '{}'",
747 exact_edit.track_name_original,
748 exact_edit.track_name
749 );
750 {
751 let session = self.session.lock().unwrap();
752 log::trace!("Session cookies count: {}", session.cookies.len());
753 }
754
755 let mut request = Request::new(Method::Post, edit_url.parse::<Url>().unwrap());
756
757 let referer_url = {
758 let session = self.session.lock().unwrap();
759 headers::add_cookies(&mut request, &session.cookies);
760 format!("{}/user/{}/library", session.base_url, session.username)
761 };
762
763 headers::add_edit_headers(&mut request, &referer_url);
764
765 let form_string: String = form_data
766 .iter()
767 .map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v)))
768 .collect::<Vec<_>>()
769 .join("&");
770
771 request.set_body(form_string);
772
773 let request_info = RequestInfo::from_url_and_method(&edit_url, "POST");
774 let request_start = std::time::Instant::now();
775
776 self.broadcast_event(ClientEvent::RequestStarted {
777 request: request_info.clone(),
778 });
779
780 let mut response = self
781 .client
782 .send(request)
783 .await
784 .map_err(|e| LastFmError::Http(e.to_string()))?;
785
786 self.broadcast_event(ClientEvent::RequestCompleted {
787 request: request_info.clone(),
788 status_code: response.status().into(),
789 duration_ms: request_start.elapsed().as_millis() as u64,
790 });
791
792 log::debug!("Edit response status: {}", response.status());
793
794 let response_text = response
795 .body_string()
796 .await
797 .map_err(|e| LastFmError::Http(e.to_string()))?;
798
799 let analysis = edit_analysis::analyze_edit_response(&response_text, response.status());
800
801 Ok(analysis.success)
802 }
803
804 async fn get_edit_form_html(&self, edit_url: &str) -> Result<String> {
805 let mut form_response = self.get(edit_url).await?;
806 let form_html = form_response
807 .body_string()
808 .await
809 .map_err(|e| LastFmError::Http(e.to_string()))?;
810
811 log::debug!("Edit form response status: {}", form_response.status());
812 Ok(form_html)
813 }
814
815 pub async fn load_edit_form_values_internal(
816 &self,
817 track_name: &str,
818 artist_name: &str,
819 ) -> Result<Vec<ExactScrobbleEdit>> {
820 log::debug!("Loading edit form values for '{track_name}' by '{artist_name}'");
821
822 let noredirect_track_url_root = {
823 let session = self.session.lock().unwrap();
824 format!(
825 "{}/user/{}/library/music/+noredirect/{}/_/{}",
826 session.base_url,
827 session.username,
828 urlencoding::encode(artist_name),
829 urlencoding::encode(track_name)
830 )
831 };
832
833 let redirect_track_url_root = {
834 let session = self.session.lock().unwrap();
835 format!(
836 "{}/user/{}/library/music/{}/_/{}",
837 session.base_url,
838 session.username,
839 urlencoding::encode(artist_name),
840 urlencoding::encode(track_name)
841 )
842 };
843
844 let mut all_scrobble_edits = Vec::new();
845 let max_pages = 5;
846
847 let build_track_page_url = |root: &str, page: u32, ajax_param: Option<&str>| -> String {
848 if page <= 1 {
849 if let Some(param) = ajax_param {
850 format!("{root}?{param}")
851 } else {
852 root.to_string()
853 }
854 } else if let Some(param) = ajax_param {
855 format!("{root}?page={page}&{param}")
856 } else {
857 format!("{root}?page={page}")
858 }
859 };
860
861 let candidates = [
867 (&noredirect_track_url_root, None),
868 (&redirect_track_url_root, None),
869 (&noredirect_track_url_root, Some("ajax=true")),
870 (&redirect_track_url_root, Some("ajax=true")),
871 (&noredirect_track_url_root, Some("ajax=1")),
872 (&redirect_track_url_root, Some("ajax=1")),
873 ];
874
875 let mut base_track_url_root = None::<String>;
876 let mut base_track_url_ajax_param = None::<String>;
877 let mut document = None::<Html>;
878 let mut unique_albums = None::<std::collections::HashSet<(String, String)>>;
879 let mut page_edits = None::<Vec<ExactScrobbleEdit>>;
880 let mut last_tried_url = None::<String>;
881 let mut last_tried_html = None::<String>;
882
883 for (root, ajax_param) in candidates {
884 let url = build_track_page_url(root, 1, ajax_param);
885 log::debug!("Fetching track page: {url}");
886
887 let mut response = self.get(&url).await?;
888 let html = response
889 .body_string()
890 .await
891 .map_err(|e| crate::LastFmError::Http(e.to_string()))?;
892 last_tried_url = Some(url.clone());
893 last_tried_html = Some(html.clone());
894 let parsed = Html::parse_document(&html);
895
896 let mut attempt_unique_albums = std::collections::HashSet::new();
897 match self.extract_scrobble_edits_from_page(
898 &parsed,
899 track_name,
900 artist_name,
901 &mut attempt_unique_albums,
902 ) {
903 Ok(edits) if !edits.is_empty() => {
904 base_track_url_root = Some((*root).to_string());
905 base_track_url_ajax_param = ajax_param.map(|s| s.to_string());
906 document = Some(parsed);
907 unique_albums = Some(attempt_unique_albums);
908 page_edits = Some(edits);
909 break;
910 }
911 Ok(_) => {
912 }
915 Err(crate::LastFmError::Parse(msg))
916 if msg == "No chartlist table found on track page" =>
917 {
918 }
920 Err(e) => return Err(e),
921 }
922 }
923
924 let base_track_url_root = base_track_url_root.ok_or_else(|| {
925 if let (Some(url), Some(html)) = (&last_tried_url, &last_tried_html) {
927 let _ = std::fs::write("/tmp/lastfm-edit-track-page-no-forms.html", html);
928 log::warn!(
929 "Failed to locate scrobble edit forms for '{track_name}' by '{artist_name}'. Last URL tried: {url}. Wrote HTML to /tmp/lastfm-edit-track-page-no-forms.html"
930 );
931 }
932
933 crate::LastFmError::Parse(format!(
934 "No scrobble forms found for track '{track_name}' by '{artist_name}'"
935 ))
936 })?;
937 let document = document.ok_or_else(|| {
938 crate::LastFmError::Parse("No chartlist table found on track page".to_string())
939 })?;
940 let mut unique_albums = unique_albums.unwrap_or_default();
941
942 all_scrobble_edits.extend(page_edits.unwrap_or_default());
943
944 log::debug!(
945 "Page 1: found {} unique album variations",
946 all_scrobble_edits.len()
947 );
948
949 let pagination_selector = Selector::parse(".pagination .pagination-next").unwrap();
950 let mut has_next_page = document.select(&pagination_selector).next().is_some();
951 let mut page = 2;
952
953 while has_next_page && page <= max_pages {
954 let page_url = build_track_page_url(
955 &base_track_url_root,
956 page,
957 base_track_url_ajax_param.as_deref(),
958 );
959
960 log::debug!("Fetching page {page} for additional album variations");
961
962 let mut response = self.get(&page_url).await?;
963 let html = response
964 .body_string()
965 .await
966 .map_err(|e| crate::LastFmError::Http(e.to_string()))?;
967
968 let document = Html::parse_document(&html);
969
970 let page_edits = self.extract_scrobble_edits_from_page(
971 &document,
972 track_name,
973 artist_name,
974 &mut unique_albums,
975 )?;
976
977 let initial_count = all_scrobble_edits.len();
978 all_scrobble_edits.extend(page_edits);
979 let found_new_unique_albums = all_scrobble_edits.len() > initial_count;
980
981 has_next_page = document.select(&pagination_selector).next().is_some();
982
983 log::debug!(
984 "Page {page}: found {} total unique albums ({})",
985 all_scrobble_edits.len(),
986 if found_new_unique_albums {
987 "new albums found"
988 } else {
989 "no new unique albums"
990 }
991 );
992
993 page += 1;
994 }
995
996 if all_scrobble_edits.is_empty() {
997 return Err(crate::LastFmError::Parse(format!(
998 "No scrobble forms found for track '{track_name}' by '{artist_name}'"
999 )));
1000 }
1001
1002 log::debug!(
1003 "Final result: found {} unique album variations for '{track_name}' by '{artist_name}'",
1004 all_scrobble_edits.len(),
1005 );
1006
1007 Ok(all_scrobble_edits)
1008 }
1009
1010 fn extract_scrobble_edits_from_page(
1011 &self,
1012 document: &Html,
1013 expected_track: &str,
1014 expected_artist: &str,
1015 unique_albums: &mut std::collections::HashSet<(String, String)>,
1016 ) -> Result<Vec<ExactScrobbleEdit>> {
1017 let table_selector =
1018 Selector::parse("table.chartlist:not(.chartlist__placeholder)").unwrap();
1019 let table = document.select(&table_selector).next().ok_or_else(|| {
1020 crate::LastFmError::Parse("No chartlist table found on track page".to_string())
1021 })?;
1022
1023 let row_selector = Selector::parse("tr").unwrap();
1024 let mut scrobble_edits: Vec<ExactScrobbleEdit> = table
1025 .select(&row_selector)
1026 .filter_map(|row| {
1027 Self::extract_scrobble_edit_from_row(
1028 row,
1029 expected_track,
1030 expected_artist,
1031 unique_albums,
1032 true,
1033 )
1034 })
1035 .collect();
1036
1037 if scrobble_edits.is_empty() {
1042 log::debug!(
1043 "No exact-match scrobble forms found for '{expected_track}' by '{expected_artist}'; falling back to unfiltered forms on page"
1044 );
1045 scrobble_edits = table
1046 .select(&row_selector)
1047 .filter_map(|row| {
1048 Self::extract_scrobble_edit_from_row(
1049 row,
1050 expected_track,
1051 expected_artist,
1052 unique_albums,
1053 false,
1054 )
1055 })
1056 .collect();
1057 }
1058
1059 Ok(scrobble_edits)
1060 }
1061
1062 fn extract_scrobble_edit_from_row(
1063 row: scraper::ElementRef,
1064 expected_track: &str,
1065 expected_artist: &str,
1066 unique_albums: &mut std::collections::HashSet<(String, String)>,
1067 require_exact_match: bool,
1068 ) -> Option<ExactScrobbleEdit> {
1069 let count_bar_link_selector = Selector::parse(".chartlist-count-bar-link").unwrap();
1070 if row.select(&count_bar_link_selector).next().is_some() {
1071 log::debug!("Found count bar link, skipping aggregated row");
1072 return None;
1073 }
1074
1075 let form_selector = Selector::parse("form[data-edit-scrobble]").unwrap();
1076 let form = row.select(&form_selector).next()?;
1077
1078 let extract_form_value = |name: &str| -> Option<String> {
1079 let selector = Selector::parse(&format!("input[name='{name}']")).unwrap();
1080 form.select(&selector)
1081 .next()
1082 .and_then(|input| input.value().attr("value"))
1083 .map(|s| s.to_string())
1084 };
1085
1086 let form_track = extract_form_value("track_name").unwrap_or_default();
1087 let form_artist = extract_form_value("artist_name").unwrap_or_default();
1088
1089 if require_exact_match && (form_track != expected_track || form_artist != expected_artist) {
1090 return None;
1091 }
1092
1093 let form_album = extract_form_value("album_name").unwrap_or_default();
1094 let form_album_artist =
1095 extract_form_value("album_artist_name").unwrap_or_else(|| form_artist.clone());
1096
1097 let album_key = (form_album.clone(), form_album_artist.clone());
1098 if !unique_albums.insert(album_key) {
1099 return None;
1100 }
1101
1102 let form_timestamp = extract_form_value("timestamp").unwrap_or_default();
1103 let timestamp: u64 = match form_timestamp.parse() {
1104 Ok(ts) => ts,
1105 Err(_) => {
1106 log::warn!(
1107 "â ī¸ Skipping form without valid timestamp: '{form_album}' by '{form_album_artist}'"
1108 );
1109 return None;
1110 }
1111 };
1112
1113 Some(ExactScrobbleEdit::new(
1114 form_track.clone(),
1115 form_album.clone(),
1116 form_artist.clone(),
1117 form_album_artist.clone(),
1118 form_track,
1119 form_album,
1120 form_artist,
1121 form_album_artist,
1122 timestamp,
1123 true,
1124 ))
1125 }
1126
1127 pub async fn get_artist_tracks_page(&self, artist: &str, page: u32) -> Result<TrackPage> {
1128 let url = {
1129 let session = self.session.lock().unwrap();
1130 format!(
1131 "{}/user/{}/library/music/{}/+tracks?page={}&ajax=true",
1132 session.base_url,
1133 session.username,
1134 urlencoding::encode(artist),
1135 page
1136 )
1137 };
1138
1139 log::debug!("Fetching tracks page {page} for artist: {artist}");
1140 let mut response = self.get(&url).await?;
1141 let content = response
1142 .body_string()
1143 .await
1144 .map_err(|e| LastFmError::Http(e.to_string()))?;
1145
1146 log::debug!(
1147 "AJAX response: {} status, {} chars",
1148 response.status(),
1149 content.len()
1150 );
1151
1152 log::debug!("Parsing HTML response from AJAX endpoint");
1153 let document = Html::parse_document(&content);
1154 self.parser.parse_tracks_page(&document, page, artist, None)
1155 }
1156
1157 pub fn extract_tracks_from_document(
1158 &self,
1159 document: &Html,
1160 artist: &str,
1161 album: Option<&str>,
1162 ) -> Result<Vec<Track>> {
1163 self.parser
1164 .extract_tracks_from_document(document, artist, album)
1165 }
1166
1167 pub fn parse_tracks_page(
1168 &self,
1169 document: &Html,
1170 page_number: u32,
1171 artist: &str,
1172 album: Option<&str>,
1173 ) -> Result<TrackPage> {
1174 self.parser
1175 .parse_tracks_page(document, page_number, artist, album)
1176 }
1177
1178 fn extract_csrf_token(&self, document: &Html) -> Result<String> {
1179 let csrf_selector = Selector::parse("input[name=\"csrfmiddlewaretoken\"]").unwrap();
1180
1181 document
1182 .select(&csrf_selector)
1183 .next()
1184 .and_then(|input| input.value().attr("value"))
1185 .map(|token| token.to_string())
1186 .ok_or(LastFmError::CsrfNotFound)
1187 }
1188
1189 pub async fn get(&self, url: &str) -> Result<Response> {
1190 if self.config.retry.enabled {
1191 self.get_with_retry(url).await
1192 } else {
1193 self.get_without_retry(url).await
1194 }
1195 }
1196
1197 async fn get_without_retry(&self, url: &str) -> Result<Response> {
1198 let mut response = self.get_with_redirects(url, 0).await?;
1199
1200 let body = self.extract_response_body(url, &mut response).await?;
1201
1202 if response.status().is_success() && self.is_rate_limit_response(&body) {
1203 log::debug!("Response body contains rate limit patterns");
1204 return Err(LastFmError::RateLimit { retry_after: 60 });
1205 }
1206
1207 let mut new_response = http_types::Response::new(response.status());
1208 for (name, values) in response.iter() {
1209 for value in values {
1210 let _ = new_response.insert_header(name.clone(), value.clone());
1211 }
1212 }
1213 new_response.set_body(body);
1214
1215 Ok(new_response)
1216 }
1217
1218 async fn get_with_retry(&self, url: &str) -> Result<Response> {
1219 let config = self.config.retry.clone();
1220
1221 let url_string = url.to_string();
1222 let client = self.clone();
1223
1224 let retry_result = retry::retry_with_backoff_cancelable(
1225 config,
1226 &format!("GET {url}"),
1227 || client.get_without_retry(&url_string),
1228 |delay, rate_limit_timestamp, operation_name| {
1229 self.broadcast_event(ClientEvent::RateLimited {
1230 delay_seconds: delay,
1231 request: None, rate_limit_type: RateLimitType::ResponsePattern,
1233 rate_limit_timestamp,
1234 });
1235 self.broadcast_event(ClientEvent::Delaying {
1236 delay_ms: delay * 1000,
1237 reason: DelayReason::RetryBackoff,
1238 request: None,
1239 delay_timestamp: rate_limit_timestamp,
1240 });
1241 log::debug!("{operation_name} rate limited, waiting {delay} seconds");
1242 },
1243 |total_duration, _operation_name| {
1244 self.broadcast_event(ClientEvent::RateLimitEnded {
1245 request: crate::types::RequestInfo::from_url_and_method(&url_string, "GET"),
1246 rate_limit_type: RateLimitType::ResponsePattern,
1247 total_rate_limit_duration_seconds: total_duration,
1248 });
1249 },
1250 Some(self.cancel_rx()),
1251 )
1252 .await?;
1253
1254 Ok(retry_result.result)
1255 }
1256
1257 async fn get_with_redirects(&self, url: &str, redirect_count: u32) -> Result<Response> {
1258 if redirect_count > 5 {
1259 return Err(LastFmError::Http("Too many redirects".to_string()));
1260 }
1261
1262 let mut request = Request::new(Method::Get, url.parse::<Url>().unwrap());
1263
1264 {
1265 let session = self.session.lock().unwrap();
1266 headers::add_cookies(&mut request, &session.cookies);
1267 if session.cookies.is_empty() && url.contains("page=") {
1268 log::debug!("No cookies available for paginated request!");
1269 }
1270 }
1271
1272 let is_ajax = url.contains("ajax=true") || url.contains("ajax=1");
1273 let referer_url = if url.contains("page=") {
1274 Some(url.split('?').next().unwrap_or(url))
1275 } else {
1276 None
1277 };
1278
1279 headers::add_get_headers(&mut request, is_ajax, referer_url);
1280
1281 let request_info = RequestInfo::from_url_and_method(url, "GET");
1282 let request_start = std::time::Instant::now();
1283
1284 self.broadcast_event(ClientEvent::RequestStarted {
1285 request: request_info.clone(),
1286 });
1287
1288 if self.config.operational_delays.get_delay_ms > 0 {
1290 self.sleep_ms(self.config.operational_delays.get_delay_ms)
1291 .await?;
1292 }
1293
1294 let response = self
1295 .client
1296 .send(request)
1297 .await
1298 .map_err(|e| LastFmError::Http(e.to_string()))?;
1299
1300 self.broadcast_event(ClientEvent::RequestCompleted {
1301 request: request_info.clone(),
1302 status_code: response.status().into(),
1303 duration_ms: request_start.elapsed().as_millis() as u64,
1304 });
1305
1306 self.extract_cookies(&response);
1307
1308 if response.status() == 302 || response.status() == 301 {
1309 if let Some(location) = response.header("location") {
1310 if let Some(redirect_url) = location.get(0) {
1311 let redirect_url_str = redirect_url.as_str();
1312 if url.contains("page=") {
1313 log::debug!("Following redirect from {url} to {redirect_url_str}");
1314
1315 if redirect_url_str.contains("/login") {
1316 log::debug!("Redirect to login page - authentication failed for paginated request");
1317 return Err(LastFmError::Auth(
1318 "Session expired or invalid for paginated request".to_string(),
1319 ));
1320 }
1321 }
1322
1323 let full_redirect_url = if redirect_url_str.starts_with('/') {
1324 let base_url = self.session.lock().unwrap().base_url.clone();
1325 format!("{base_url}{redirect_url_str}")
1326 } else if redirect_url_str.starts_with("http") {
1327 redirect_url_str.to_string()
1328 } else {
1329 let base_url = url
1330 .rsplit('/')
1331 .skip(1)
1332 .collect::<Vec<_>>()
1333 .into_iter()
1334 .rev()
1335 .collect::<Vec<_>>()
1336 .join("/");
1337 format!("{base_url}/{redirect_url_str}")
1338 };
1339
1340 return Box::pin(
1341 self.get_with_redirects(&full_redirect_url, redirect_count + 1),
1342 )
1343 .await;
1344 }
1345 }
1346 }
1347
1348 if self.config.rate_limit.detect_by_status && response.status() == 429 {
1349 let retry_after = response
1350 .header("retry-after")
1351 .and_then(|h| h.get(0))
1352 .and_then(|v| v.as_str().parse::<u64>().ok())
1353 .unwrap_or(60);
1354 self.broadcast_event(ClientEvent::RateLimited {
1355 delay_seconds: retry_after,
1356 request: Some(request_info.clone()),
1357 rate_limit_type: RateLimitType::Http429,
1358 rate_limit_timestamp: std::time::SystemTime::now()
1359 .duration_since(std::time::UNIX_EPOCH)
1360 .unwrap_or_default()
1361 .as_secs(),
1362 });
1363 return Err(LastFmError::RateLimit { retry_after });
1364 }
1365
1366 if self.config.rate_limit.detect_by_status && response.status() == 403 {
1367 log::debug!("Got 403 response, checking if it's a rate limit");
1368 {
1369 let session = self.session.lock().unwrap();
1370 if !session.cookies.is_empty() {
1371 log::debug!("403 on authenticated request - likely rate limit");
1372 self.broadcast_event(ClientEvent::RateLimited {
1373 delay_seconds: 60,
1374 request: Some(request_info.clone()),
1375 rate_limit_type: RateLimitType::Http403,
1376 rate_limit_timestamp: std::time::SystemTime::now()
1377 .duration_since(std::time::UNIX_EPOCH)
1378 .unwrap_or_default()
1379 .as_secs(),
1380 });
1381 return Err(LastFmError::RateLimit { retry_after: 60 });
1382 }
1383 }
1384 }
1385
1386 Ok(response)
1387 }
1388
1389 fn is_rate_limit_response(&self, response_body: &str) -> bool {
1390 let rate_limit_config = &self.config.rate_limit;
1391
1392 if !rate_limit_config.detect_by_patterns && rate_limit_config.custom_patterns.is_empty() {
1393 return false;
1394 }
1395
1396 let body_lower = response_body.to_lowercase();
1397
1398 for pattern in &rate_limit_config.custom_patterns {
1399 if body_lower.contains(&pattern.to_lowercase()) {
1400 log::debug!("Rate limit detected (custom pattern: '{pattern}')");
1401 return true;
1402 }
1403 }
1404
1405 if rate_limit_config.detect_by_patterns {
1406 for pattern in &rate_limit_config.patterns {
1407 let pattern_lower = pattern.to_lowercase();
1408 if body_lower.contains(&pattern_lower) {
1409 log::debug!("Rate limit detected (pattern: '{pattern}')");
1410 return true;
1411 }
1412 }
1413 }
1414
1415 false
1416 }
1417
1418 fn extract_cookies(&self, response: &Response) {
1419 let mut session = self.session.lock().unwrap();
1420 extract_cookies_from_response(response, &mut session.cookies);
1421 }
1422
1423 async fn extract_response_body(&self, _url: &str, response: &mut Response) -> Result<String> {
1424 let body = response
1425 .body_string()
1426 .await
1427 .map_err(|e| LastFmError::Http(e.to_string()))?;
1428
1429 Ok(body)
1430 }
1431
1432 pub async fn get_artists_page(&self, page: u32) -> Result<crate::ArtistPage> {
1433 let url = {
1434 let session = self.session.lock().unwrap();
1435 format!(
1436 "{}/user/{}/library/artists?page={}",
1437 session.base_url, session.username, page
1438 )
1439 };
1440
1441 log::debug!("Fetching artists page {page}");
1442 let mut response = self.get(&url).await?;
1443 let content = response
1444 .body_string()
1445 .await
1446 .map_err(|e| LastFmError::Http(e.to_string()))?;
1447
1448 log::debug!(
1449 "Artist library response: {} status, {} chars",
1450 response.status(),
1451 content.len()
1452 );
1453
1454 log::debug!("Parsing HTML response from artist library endpoint");
1455 let document = Html::parse_document(&content);
1456 self.parser.parse_artists_page(&document, page)
1457 }
1458
1459 pub async fn get_artist_albums_page(&self, artist: &str, page: u32) -> Result<AlbumPage> {
1460 let url = {
1461 let session = self.session.lock().unwrap();
1462 format!(
1463 "{}/user/{}/library/music/{}/+albums?page={}&ajax=true",
1464 session.base_url,
1465 session.username,
1466 urlencoding::encode(artist),
1467 page
1468 )
1469 };
1470
1471 log::debug!("Fetching albums page {page} for artist: {artist}");
1472 let mut response = self.get(&url).await?;
1473 let content = response
1474 .body_string()
1475 .await
1476 .map_err(|e| LastFmError::Http(e.to_string()))?;
1477
1478 log::debug!(
1479 "AJAX response: {} status, {} chars",
1480 response.status(),
1481 content.len()
1482 );
1483
1484 log::debug!("Parsing HTML response from AJAX endpoint");
1485 let document = Html::parse_document(&content);
1486 self.parser.parse_albums_page(&document, page, artist)
1487 }
1488
1489 pub async fn get_album_tracks_page(
1490 &self,
1491 album_name: &str,
1492 artist_name: &str,
1493 page: u32,
1494 ) -> Result<TrackPage> {
1495 let url = {
1496 let session = self.session.lock().unwrap();
1497 format!(
1498 "{}/user/{}/library/music/{}/{}?page={}&ajax=true",
1499 session.base_url,
1500 session.username,
1501 self.lastfm_encode(artist_name),
1502 self.lastfm_encode(album_name),
1503 page
1504 )
1505 };
1506
1507 log::debug!("Fetching tracks page {page} for album '{album_name}' by '{artist_name}'");
1508 log::debug!("đ Album URL: {url}");
1509
1510 let mut response = self.get(&url).await?;
1511 let content = response
1512 .body_string()
1513 .await
1514 .map_err(|e| LastFmError::Http(e.to_string()))?;
1515
1516 log::debug!(
1517 "AJAX response: {} status, {} chars",
1518 response.status(),
1519 content.len()
1520 );
1521
1522 log::debug!("Parsing HTML response from AJAX endpoint");
1523 let document = Html::parse_document(&content);
1524 let result =
1525 self.parser
1526 .parse_tracks_page(&document, page, artist_name, Some(album_name))?;
1527
1528 if result.tracks.is_empty() {
1530 if content.contains("404") || content.contains("Not Found") {
1531 log::warn!("đ¨ 404 ERROR for album '{album_name}' by '{artist_name}': {url}");
1532 } else if content.contains("no tracks") || content.contains("no music") {
1533 log::debug!("âšī¸ Album '{album_name}' by '{artist_name}' explicitly has no tracks in user's library");
1534 } else {
1535 log::warn!(
1536 "đ¨ UNKNOWN EMPTY RESPONSE for album '{album_name}' by '{artist_name}': {url}"
1537 );
1538 log::debug!("đ Response length: {} chars", content.len());
1539 log::debug!(
1540 "đ Response preview (first 200 chars): {}",
1541 &content.chars().take(200).collect::<String>()
1542 );
1543 }
1544 } else {
1545 log::debug!(
1546 "â
SUCCESS: Album '{album_name}' by '{artist_name}' returned {} tracks",
1547 result.tracks.len()
1548 );
1549 }
1550
1551 Ok(result)
1552 }
1553
1554 pub async fn search_tracks_page(&self, query: &str, page: u32) -> Result<TrackPage> {
1555 let url = {
1556 let session = self.session.lock().unwrap();
1557 format!(
1558 "{}/user/{}/library/tracks/search?page={}&query={}&ajax=1",
1559 session.base_url,
1560 session.username,
1561 page,
1562 urlencoding::encode(query)
1563 )
1564 };
1565
1566 log::debug!("Searching tracks for query '{query}' on page {page}");
1567 let mut response = self.get(&url).await?;
1568 let content = response
1569 .body_string()
1570 .await
1571 .map_err(|e| LastFmError::Http(e.to_string()))?;
1572
1573 log::debug!(
1574 "Track search response: {} status, {} chars",
1575 response.status(),
1576 content.len()
1577 );
1578
1579 let document = Html::parse_document(&content);
1580 let tracks = self.parser.parse_track_search_results(&document)?;
1581
1582 let (has_next_page, total_pages) = self.parser.parse_pagination(&document, page)?;
1585
1586 Ok(TrackPage {
1587 tracks,
1588 page_number: page,
1589 has_next_page,
1590 total_pages,
1591 })
1592 }
1593
1594 pub async fn search_albums_page(&self, query: &str, page: u32) -> Result<AlbumPage> {
1595 let url = {
1596 let session = self.session.lock().unwrap();
1597 format!(
1598 "{}/user/{}/library/albums/search?page={}&query={}&ajax=1",
1599 session.base_url,
1600 session.username,
1601 page,
1602 urlencoding::encode(query)
1603 )
1604 };
1605
1606 log::debug!("Searching albums for query '{query}' on page {page}");
1607 let mut response = self.get(&url).await?;
1608 let content = response
1609 .body_string()
1610 .await
1611 .map_err(|e| LastFmError::Http(e.to_string()))?;
1612
1613 log::debug!(
1614 "Album search response: {} status, {} chars",
1615 response.status(),
1616 content.len()
1617 );
1618
1619 let document = Html::parse_document(&content);
1620 let albums = self.parser.parse_album_search_results(&document)?;
1621
1622 let (has_next_page, total_pages) = self.parser.parse_pagination(&document, page)?;
1624
1625 Ok(AlbumPage {
1626 albums,
1627 page_number: page,
1628 has_next_page,
1629 total_pages,
1630 })
1631 }
1632
1633 pub async fn search_artists_page(&self, query: &str, page: u32) -> Result<ArtistPage> {
1634 let url = {
1635 let session = self.session.lock().unwrap();
1636 format!(
1637 "{}/user/{}/library/artists/search?page={}&query={}&ajax=1",
1638 session.base_url,
1639 session.username,
1640 page,
1641 urlencoding::encode(query)
1642 )
1643 };
1644
1645 log::debug!("Searching artists for query '{query}' on page {page}");
1646 let mut response = self.get(&url).await?;
1647 let content = response
1648 .body_string()
1649 .await
1650 .map_err(|e| LastFmError::Http(e.to_string()))?;
1651
1652 log::debug!(
1653 "Artist search response: {} status, {} chars",
1654 response.status(),
1655 content.len()
1656 );
1657
1658 let document = Html::parse_document(&content);
1659 let artists = self.parser.parse_artist_search_results(&document)?;
1660
1661 let (has_next_page, total_pages) = self.parser.parse_pagination(&document, page)?;
1663
1664 Ok(ArtistPage {
1665 artists,
1666 page_number: page,
1667 has_next_page,
1668 total_pages,
1669 })
1670 }
1671
1672 pub fn api_client(&self) -> Option<crate::api::LastFmApiClientImpl> {
1676 self.api_key.as_ref().map(|key| {
1677 crate::api::LastFmApiClientImpl::new(
1678 Box::new(ArcHttpClient(self.client.clone())),
1679 self.username(),
1680 key.clone(),
1681 )
1682 })
1683 }
1684
1685 pub fn inner_client(&self) -> Arc<dyn HttpClient + Send + Sync> {
1687 self.client.clone()
1688 }
1689}
1690
1691#[async_trait(?Send)]
1692impl LastFmBaseClient for LastFmEditClientImpl {
1693 async fn get_artists_page(&self, page: u32) -> Result<crate::ArtistPage> {
1694 self.get_artists_page(page).await
1695 }
1696
1697 async fn get_artist_tracks_page(&self, artist: &str, page: u32) -> Result<TrackPage> {
1698 self.get_artist_tracks_page(artist, page).await
1699 }
1700
1701 async fn get_artist_albums_page(&self, artist: &str, page: u32) -> Result<AlbumPage> {
1702 self.get_artist_albums_page(artist, page).await
1703 }
1704
1705 async fn get_album_tracks_page(
1706 &self,
1707 album_name: &str,
1708 artist_name: &str,
1709 page: u32,
1710 ) -> Result<TrackPage> {
1711 self.get_album_tracks_page(album_name, artist_name, page)
1712 .await
1713 }
1714
1715 async fn get_recent_tracks_page(&self, page: u32) -> Result<TrackPage> {
1716 self.get_recent_tracks_page(page).await
1717 }
1718
1719 async fn search_tracks_page(&self, query: &str, page: u32) -> Result<crate::TrackPage> {
1720 self.search_tracks_page(query, page).await
1721 }
1722
1723 async fn search_albums_page(&self, query: &str, page: u32) -> Result<crate::AlbumPage> {
1724 self.search_albums_page(query, page).await
1725 }
1726
1727 async fn search_artists_page(&self, query: &str, page: u32) -> Result<crate::ArtistPage> {
1728 self.search_artists_page(query, page).await
1729 }
1730
1731 fn username(&self) -> String {
1732 self.username()
1733 }
1734
1735 fn get_session(&self) -> LastFmEditSession {
1736 self.get_session()
1737 }
1738
1739 fn subscribe(&self) -> ClientEventReceiver {
1740 self.subscribe()
1741 }
1742
1743 fn latest_event(&self) -> Option<ClientEvent> {
1744 self.latest_event()
1745 }
1746
1747 async fn validate_session(&self) -> bool {
1748 self.validate_session().await
1749 }
1750
1751 async fn find_recent_scrobble_for_track(
1752 &self,
1753 track_name: &str,
1754 artist_name: &str,
1755 max_pages: u32,
1756 ) -> Result<Option<Track>> {
1757 self.find_recent_scrobble_for_track(track_name, artist_name, max_pages)
1758 .await
1759 }
1760
1761 fn cancel(&self) {
1762 self.cancel.cancel();
1763 }
1764
1765 fn reset_cancel(&self) {
1766 self.cancel.reset();
1767 }
1768
1769 fn is_cancelled(&self) -> bool {
1770 self.cancel.is_cancelled()
1771 }
1772}
1773
1774#[async_trait(?Send)]
1775impl LastFmEditClient for LastFmEditClientImpl {
1776 async fn edit_scrobble(&self, edit: &ScrobbleEdit) -> Result<EditResponse> {
1777 self.edit_scrobble(edit).await
1778 }
1779
1780 async fn edit_scrobble_single(
1781 &self,
1782 exact_edit: &ExactScrobbleEdit,
1783 max_retries: u32,
1784 ) -> Result<EditResponse> {
1785 self.edit_scrobble_single(exact_edit, max_retries).await
1786 }
1787
1788 async fn delete_scrobble(
1789 &self,
1790 artist_name: &str,
1791 track_name: &str,
1792 timestamp: u64,
1793 ) -> Result<bool> {
1794 self.delete_scrobble(artist_name, track_name, timestamp)
1795 .await
1796 }
1797
1798 fn discover_scrobbles(
1799 &self,
1800 edit: ScrobbleEdit,
1801 ) -> Box<dyn crate::AsyncDiscoveryIterator<crate::ExactScrobbleEdit>> {
1802 let track_name = edit.track_name_original.clone();
1803 let album_name = edit.album_name_original.clone();
1804
1805 match (&track_name, &album_name) {
1806 (Some(track_name), Some(album_name)) => Box::new(crate::ExactMatchDiscovery::new(
1807 self.clone(),
1808 edit,
1809 track_name.clone(),
1810 album_name.clone(),
1811 )),
1812
1813 (Some(track_name), None) => Box::new(crate::TrackVariationsDiscovery::new(
1814 self.clone(),
1815 edit,
1816 track_name.clone(),
1817 )),
1818
1819 (None, Some(album_name)) => Box::new(crate::AlbumTracksDiscovery::new(
1820 self.clone(),
1821 edit,
1822 album_name.clone(),
1823 )),
1824
1825 (None, None) => Box::new(crate::ArtistTracksDiscovery::new(self.clone(), edit)),
1826 }
1827 }
1828
1829 fn artists(&self) -> Box<dyn crate::AsyncPaginatedIterator<crate::Artist>> {
1830 Box::new(crate::iterator::ArtistsIterator::new(self.clone()))
1831 }
1832
1833 fn artist_tracks(&self, artist: &str) -> Box<dyn crate::AsyncPaginatedIterator<Track>> {
1834 Box::new(crate::ArtistTracksIterator::new(
1835 self.clone(),
1836 artist.to_string(),
1837 ))
1838 }
1839
1840 fn artist_tracks_direct(&self, artist: &str) -> Box<dyn crate::AsyncPaginatedIterator<Track>> {
1841 Box::new(crate::iterator::ArtistTracksDirectIterator::new(
1842 self.clone(),
1843 artist.to_string(),
1844 ))
1845 }
1846
1847 fn artist_albums(&self, artist: &str) -> Box<dyn crate::AsyncPaginatedIterator<crate::Album>> {
1848 Box::new(crate::ArtistAlbumsIterator::new(
1849 self.clone(),
1850 artist.to_string(),
1851 ))
1852 }
1853
1854 fn album_tracks(
1855 &self,
1856 album_name: &str,
1857 artist_name: &str,
1858 ) -> Box<dyn crate::AsyncPaginatedIterator<Track>> {
1859 Box::new(crate::AlbumTracksIterator::new(
1860 self.clone(),
1861 album_name.to_string(),
1862 artist_name.to_string(),
1863 ))
1864 }
1865
1866 fn recent_tracks(&self) -> Box<dyn crate::AsyncPaginatedIterator<Track>> {
1867 Box::new(crate::RecentTracksIterator::new(self.clone()))
1868 }
1869
1870 fn recent_tracks_from_page(
1871 &self,
1872 starting_page: u32,
1873 ) -> Box<dyn crate::AsyncPaginatedIterator<Track>> {
1874 Box::new(crate::RecentTracksIterator::with_starting_page(
1875 self.clone(),
1876 starting_page,
1877 ))
1878 }
1879
1880 fn search_tracks(&self, query: &str) -> Box<dyn crate::AsyncPaginatedIterator<Track>> {
1881 Box::new(crate::SearchTracksIterator::new(
1882 self.clone(),
1883 query.to_string(),
1884 ))
1885 }
1886
1887 fn search_albums(&self, query: &str) -> Box<dyn crate::AsyncPaginatedIterator<crate::Album>> {
1888 Box::new(crate::SearchAlbumsIterator::new(
1889 self.clone(),
1890 query.to_string(),
1891 ))
1892 }
1893
1894 fn search_artists(&self, query: &str) -> Box<dyn crate::AsyncPaginatedIterator<crate::Artist>> {
1895 Box::new(crate::SearchArtistsIterator::new(
1896 self.clone(),
1897 query.to_string(),
1898 ))
1899 }
1900}
1901
1902#[async_trait(?Send)]
1903impl crate::api::LastFmApiClient for LastFmEditClientImpl {
1904 async fn api_get_recent_tracks_page(&self, page: u32) -> Result<TrackPage> {
1905 let api_key = self
1906 .api_key
1907 .as_ref()
1908 .ok_or_else(|| LastFmError::Auth("No API key configured".to_string()))?;
1909
1910 let username = self.username();
1911 let url = format!(
1912 "https://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks&user={}&api_key={}&format=json&page={}&limit=200",
1913 urlencoding::encode(&username),
1914 urlencoding::encode(api_key),
1915 page
1916 );
1917
1918 let request_info = RequestInfo::from_url_and_method(&url, "GET");
1919 let request_start = std::time::Instant::now();
1920
1921 self.broadcast_event(ClientEvent::RequestStarted {
1922 request: request_info.clone(),
1923 });
1924
1925 let request = Request::new(Method::Get, url.parse::<Url>().unwrap());
1926 let mut response = self
1927 .client
1928 .send(request)
1929 .await
1930 .map_err(|e| LastFmError::Http(e.to_string()))?;
1931
1932 self.broadcast_event(ClientEvent::RequestCompleted {
1933 request: request_info,
1934 status_code: response.status().into(),
1935 duration_ms: request_start.elapsed().as_millis() as u64,
1936 });
1937
1938 let body = response
1939 .body_string()
1940 .await
1941 .map_err(|e| LastFmError::Http(e.to_string()))?;
1942
1943 crate::api::parse_api_recent_tracks_response(&body)
1944 }
1945}
1946
1947#[derive(Debug)]
1952struct ArcHttpClient(Arc<dyn HttpClient + Send + Sync>);
1953
1954#[async_trait::async_trait]
1955impl HttpClient for ArcHttpClient {
1956 async fn send(
1957 &self,
1958 req: http_client::Request,
1959 ) -> std::result::Result<http_client::Response, http_types::Error> {
1960 self.0.send(req).await
1961 }
1962}