1use crate::{Album, AlbumPage, Artist, ArtistPage, LastFmError, Result, Track, TrackPage};
8use scraper::{Html, Selector};
9
10#[derive(Debug, Clone)]
15pub struct LastFmParser;
16
17impl LastFmParser {
18 pub fn new() -> Self {
20 Self
21 }
22
23 pub fn parse_recent_scrobbles(&self, document: &Html) -> Result<Vec<Track>> {
26 let mut tracks = Vec::new();
27
28 let table_selector = Selector::parse("table.chartlist").unwrap();
30 let row_selector = Selector::parse("tbody tr").unwrap();
31
32 let tables: Vec<_> = document.select(&table_selector).collect();
33 log::debug!("Found {} chartlist tables", tables.len());
34
35 for table in tables {
36 for row in table.select(&row_selector) {
37 if let Ok(track) = self.parse_recent_scrobble_row(&row) {
38 tracks.push(track);
39 }
40 }
41 }
42
43 if tracks.is_empty() {
44 log::debug!("No tracks found in recent scrobbles");
45 }
46
47 log::debug!("Parsed {} recent scrobbles", tracks.len());
48 Ok(tracks)
49 }
50
51 fn parse_recent_scrobble_row(&self, row: &scraper::ElementRef) -> Result<Track> {
53 let name_selector = Selector::parse(".chartlist-name a").unwrap();
55 let name = row
56 .select(&name_selector)
57 .next()
58 .ok_or(LastFmError::Parse("Missing track name".to_string()))?
59 .text()
60 .collect::<String>()
61 .trim()
62 .to_string();
63
64 let artist_selector = Selector::parse(".chartlist-artist a").unwrap();
66 let artist = row
67 .select(&artist_selector)
68 .next()
69 .ok_or(LastFmError::Parse("Missing artist name".to_string()))?
70 .text()
71 .collect::<String>()
72 .trim()
73 .to_string();
74
75 let timestamp = self.extract_scrobble_timestamp(row);
77
78 let album = self.extract_scrobble_album(row);
80
81 let album_artist = self.extract_scrobble_album_artist(row);
83
84 let playcount = 1;
86
87 Ok(Track {
88 name,
89 artist,
90 playcount,
91 timestamp,
92 album,
93 album_artist,
94 })
95 }
96
97 fn extract_scrobble_timestamp(&self, row: &scraper::ElementRef) -> Option<u64> {
99 if let Some(timestamp_str) = row.value().attr("data-timestamp") {
103 if let Ok(timestamp) = timestamp_str.parse::<u64>() {
104 return Some(timestamp);
105 }
106 }
107
108 let timestamp_input_selector = Selector::parse("input[name='timestamp']").unwrap();
110 if let Some(input) = row.select(×tamp_input_selector).next() {
111 if let Some(value) = input.value().attr("value") {
112 if let Ok(timestamp) = value.parse::<u64>() {
113 return Some(timestamp);
114 }
115 }
116 }
117
118 let edit_form_selector =
120 Selector::parse("form[data-edit-scrobble] input[name='timestamp']").unwrap();
121 if let Some(timestamp_input) = row.select(&edit_form_selector).next() {
122 if let Some(value) = timestamp_input.value().attr("value") {
123 if let Ok(timestamp) = value.parse::<u64>() {
124 return Some(timestamp);
125 }
126 }
127 }
128
129 None
132 }
133
134 fn extract_scrobble_album(&self, row: &scraper::ElementRef) -> Option<String> {
136 let album_input_selector =
138 Selector::parse("form[data-edit-scrobble] input[name='album_name']").unwrap();
139
140 if let Some(album_input) = row.select(&album_input_selector).next() {
141 if let Some(album_name) = album_input.value().attr("value") {
142 if !album_name.is_empty() {
143 return Some(album_name.to_string());
144 }
145 }
146 }
147
148 None
149 }
150
151 fn extract_scrobble_album_artist(&self, row: &scraper::ElementRef) -> Option<String> {
153 let album_artist_input_selector =
155 Selector::parse("form[data-edit-scrobble] input[name='album_artist_name']").unwrap();
156
157 if let Some(album_artist_input) = row.select(&album_artist_input_selector).next() {
158 if let Some(album_artist_name) = album_artist_input.value().attr("value") {
159 if !album_artist_name.is_empty() {
160 return Some(album_artist_name.to_string());
161 }
162 }
163 }
164
165 None
166 }
167
168 pub fn parse_tracks_page(
170 &self,
171 document: &Html,
172 page_number: u32,
173 artist: &str,
174 album: Option<&str>,
175 ) -> Result<TrackPage> {
176 let tracks = self.extract_tracks_from_document(document, artist, album)?;
177
178 let (has_next_page, total_pages) = self.parse_pagination(document, page_number)?;
180
181 Ok(TrackPage {
182 tracks,
183 page_number,
184 has_next_page,
185 total_pages,
186 })
187 }
188
189 pub fn extract_tracks_from_document(
191 &self,
192 document: &Html,
193 artist: &str,
194 album: Option<&str>,
195 ) -> Result<Vec<Track>> {
196 let mut tracks = Vec::new();
197 let mut seen_tracks = std::collections::HashSet::new();
198
199 log::debug!("Starting track extraction for artist: {artist}, album: {album:?}");
200
201 let track_selector = Selector::parse("[data-track-name]").unwrap();
205 let track_elements: Vec<_> = document.select(&track_selector).collect();
206 log::debug!(
207 "Found {} elements with data-track-name",
208 track_elements.len()
209 );
210
211 for element in track_elements {
212 let track_name = element.value().attr("data-track-name").unwrap_or("");
213 if track_name.is_empty() {
214 continue;
215 }
216 if seen_tracks.contains(track_name) {
217 continue;
218 }
219 seen_tracks.insert(track_name.to_string());
220
221 match self.find_playcount_for_track(document, track_name) {
222 Ok(playcount) => {
223 let timestamp = self.find_timestamp_for_track(document, track_name);
224 let track_artist = element.value().attr("data-artist-name").unwrap_or(artist);
225 let track = Track {
226 name: track_name.to_string(),
227 artist: track_artist.to_string(),
228 playcount,
229 timestamp,
230 album: album.map(|a| a.to_string()),
231 album_artist: None, };
233 tracks.push(track);
234 log::debug!("Added track '{track_name}' with {playcount} plays");
235 }
236 Err(e) => {
237 log::debug!("FAILED to find playcount for track '{track_name}': {e}");
238 }
239 }
240 }
241
242 let table_selector = Selector::parse("table.chartlist").unwrap();
244 let tables: Vec<_> = document.select(&table_selector).collect();
245
246 for table in tables {
247 let row_selector = Selector::parse("tbody tr").unwrap();
248 let rows: Vec<_> = table.select(&row_selector).collect();
249
250 for row in rows.iter() {
251 if let Ok(mut track) = self.parse_track_row(row) {
253 if track.artist.is_empty() {
254 track.artist = artist.to_string();
255 }
256 if let Some(album_name) = album {
257 track.album = Some(album_name.to_string());
258 }
259
260 if !seen_tracks.contains(&track.name) {
262 seen_tracks.insert(track.name.clone());
263 tracks.push(track);
264 }
265 }
266 }
267 }
268
269 log::debug!("Successfully extracted {} unique tracks", tracks.len());
270 Ok(tracks)
271 }
272
273 pub fn parse_track_row(&self, row: &scraper::ElementRef) -> Result<Track> {
277 let name = self.extract_name_from_row(row, "track")?;
279
280 let playcount = self.extract_playcount_from_row(row);
282
283 let artist_selector = Selector::parse(".chartlist-artist a").unwrap();
284 let artist = row
285 .select(&artist_selector)
286 .next()
287 .map(|el| el.text().collect::<String>().trim().to_string())
288 .unwrap_or_default();
289
290 Ok(Track {
291 name,
292 artist,
293 playcount,
294 timestamp: None, album: None, album_artist: None, })
298 }
299
300 pub fn parse_albums_page(
302 &self,
303 document: &Html,
304 page_number: u32,
305 artist: &str,
306 ) -> Result<AlbumPage> {
307 let mut albums = Vec::new();
308
309 let album_selector = Selector::parse("[data-album-name]").unwrap();
311 let album_elements: Vec<_> = document.select(&album_selector).collect();
312
313 if !album_elements.is_empty() {
314 log::debug!(
315 "Found {} album elements with data-album-name",
316 album_elements.len()
317 );
318
319 let mut seen_albums = std::collections::HashSet::new();
321
322 for element in album_elements {
323 let album_name = element.value().attr("data-album-name").unwrap_or("");
324 if !album_name.is_empty() && !seen_albums.contains(album_name) {
325 seen_albums.insert(album_name.to_string());
326
327 if let Ok(playcount) = self.find_playcount_for_album(document, album_name) {
328 let timestamp = self.find_timestamp_for_album(document, album_name);
329 let album = Album {
330 name: album_name.to_string(),
331 artist: artist.to_string(),
332 playcount,
333 timestamp,
334 };
335 albums.push(album);
336 }
337 }
338 }
339 } else {
340 albums = self.parse_albums_from_rows(document, artist)?;
342 }
343
344 let (has_next_page, total_pages) = self.parse_pagination(document, page_number)?;
345
346 Ok(AlbumPage {
347 albums,
348 page_number,
349 has_next_page,
350 total_pages,
351 })
352 }
353
354 fn parse_albums_from_rows(&self, document: &Html, artist: &str) -> Result<Vec<Album>> {
356 let mut albums = Vec::new();
357 let table_selector = Selector::parse("table.chartlist").unwrap();
358 let row_selector = Selector::parse("tbody tr").unwrap();
359
360 for table in document.select(&table_selector) {
361 for row in table.select(&row_selector) {
362 if let Ok(mut album) = self.parse_album_row(&row) {
363 album.artist = artist.to_string();
364 albums.push(album);
365 }
366 }
367 }
368 Ok(albums)
369 }
370
371 pub fn parse_album_row(&self, row: &scraper::ElementRef) -> Result<Album> {
373 let name = self.extract_name_from_row(row, "album")?;
375
376 let playcount = self.extract_playcount_from_row(row);
378
379 let artist = "".to_string(); Ok(Album {
382 name,
383 artist,
384 playcount,
385 timestamp: None, })
387 }
388
389 pub fn parse_track_search_results(&self, document: &Html) -> Result<Vec<Track>> {
396 let mut tracks = Vec::new();
397
398 let table_selector = Selector::parse("table.chartlist").unwrap();
400 let row_selector = Selector::parse("tbody tr").unwrap();
401
402 let tables: Vec<_> = document.select(&table_selector).collect();
403 log::debug!("Found {} chartlist tables in search results", tables.len());
404
405 for table in tables {
406 for row in table.select(&row_selector) {
407 if let Ok(track) = self.parse_search_track_row(&row) {
408 tracks.push(track);
409 }
410 }
411 }
412
413 log::debug!("Parsed {} tracks from search results", tracks.len());
414 Ok(tracks)
415 }
416
417 pub fn parse_album_search_results(&self, document: &Html) -> Result<Vec<Album>> {
422 let mut albums = Vec::new();
423
424 let table_selector = Selector::parse("table.chartlist").unwrap();
426 let row_selector = Selector::parse("tbody tr").unwrap();
427
428 let tables: Vec<_> = document.select(&table_selector).collect();
429 log::debug!(
430 "Found {} chartlist tables in album search results",
431 tables.len()
432 );
433
434 for table in tables {
435 for row in table.select(&row_selector) {
436 if let Ok(album) = self.parse_search_album_row(&row) {
437 albums.push(album);
438 }
439 }
440 }
441
442 log::debug!("Parsed {} albums from search results", albums.len());
443 Ok(albums)
444 }
445
446 pub fn parse_artist_search_results(&self, document: &Html) -> Result<Vec<Artist>> {
451 let mut artists = Vec::new();
452
453 let table_selector = Selector::parse("table.chartlist").unwrap();
455 let row_selector = Selector::parse("tbody tr").unwrap();
456
457 let tables: Vec<_> = document.select(&table_selector).collect();
458 log::debug!(
459 "Found {} chartlist tables in artist search results",
460 tables.len()
461 );
462
463 for table in tables {
464 for row in table.select(&row_selector) {
465 if let Ok(artist) = self.parse_search_artist_row(&row) {
466 artists.push(artist);
467 }
468 }
469 }
470
471 log::debug!("Parsed {} artists from search results", artists.len());
472 Ok(artists)
473 }
474
475 fn parse_search_artist_row(&self, row: &scraper::ElementRef) -> Result<Artist> {
477 let name_selector = Selector::parse("td.chartlist-name a").unwrap();
479 let name = row
480 .select(&name_selector)
481 .next()
482 .ok_or(LastFmError::Parse(
483 "Missing artist name in search results".to_string(),
484 ))?
485 .text()
486 .collect::<String>()
487 .trim()
488 .to_string();
489
490 let playcount = self.extract_playcount_from_row(row);
492
493 Ok(Artist {
494 name,
495 playcount,
496 timestamp: None, })
498 }
499
500 fn parse_search_track_row(&self, row: &scraper::ElementRef) -> Result<Track> {
502 let name = self.extract_name_from_row(row, "track")?;
504
505 let artist_selector = Selector::parse(".chartlist-artist a").unwrap();
507 let artist = row
508 .select(&artist_selector)
509 .next()
510 .map(|el| el.text().collect::<String>().trim().to_string())
511 .ok_or_else(|| {
512 LastFmError::Parse("Missing artist name in search results".to_string())
513 })?;
514
515 let playcount = self.extract_playcount_from_row(row);
517
518 let timestamp = None;
520
521 let album = self.extract_album_from_search_row(row);
523 let album_artist = self.extract_album_artist_from_search_row(row);
524
525 Ok(Track {
526 name,
527 artist,
528 playcount,
529 timestamp,
530 album,
531 album_artist,
532 })
533 }
534
535 fn parse_search_album_row(&self, row: &scraper::ElementRef) -> Result<Album> {
537 let name = self.extract_name_from_row(row, "album")?;
539
540 let artist_selector = Selector::parse(".chartlist-artist a").unwrap();
542 let artist = row
543 .select(&artist_selector)
544 .next()
545 .map(|el| el.text().collect::<String>().trim().to_string())
546 .ok_or_else(|| {
547 LastFmError::Parse("Missing artist name in album search results".to_string())
548 })?;
549
550 let playcount = self.extract_playcount_from_row(row);
552
553 Ok(Album {
554 name,
555 artist,
556 playcount,
557 timestamp: None, })
559 }
560
561 fn extract_album_from_search_row(&self, row: &scraper::ElementRef) -> Option<String> {
563 let album_input_selector = Selector::parse("input[name='album']").unwrap();
565 if let Some(input) = row.select(&album_input_selector).next() {
566 if let Some(value) = input.value().attr("value") {
567 let album = value.trim().to_string();
568 if !album.is_empty() {
569 return Some(album);
570 }
571 }
572 }
573 None
574 }
575
576 fn extract_album_artist_from_search_row(&self, row: &scraper::ElementRef) -> Option<String> {
578 let album_artist_input_selector = Selector::parse("input[name='album_artist']").unwrap();
580 if let Some(input) = row.select(&album_artist_input_selector).next() {
581 if let Some(value) = input.value().attr("value") {
582 let album_artist = value.trim().to_string();
583 if !album_artist.is_empty() {
584 return Some(album_artist);
585 }
586 }
587 }
588 None
589 }
590
591 fn extract_name_from_row(&self, row: &scraper::ElementRef, item_type: &str) -> Result<String> {
595 let name_selector = Selector::parse(".chartlist-name a").unwrap();
596 let name = row
597 .select(&name_selector)
598 .next()
599 .map(|el| el.text().collect::<String>().trim().to_string())
600 .ok_or_else(|| LastFmError::Parse(format!("Missing {item_type} name")))?;
601 Ok(name)
602 }
603
604 fn extract_playcount_from_row(&self, row: &scraper::ElementRef) -> u32 {
606 let playcount_selector = Selector::parse(".chartlist-count-bar-value").unwrap();
607 let mut playcount = 1; if let Some(element) = row.select(&playcount_selector).next() {
610 let text = element.text().collect::<String>().trim().to_string();
611 if let Some(number_part) = text.split_whitespace().next() {
613 if let Ok(count) = number_part.parse::<u32>() {
614 playcount = count;
615 }
616 }
617 }
618 playcount
619 }
620
621 pub fn parse_pagination(
623 &self,
624 document: &Html,
625 _current_page: u32,
626 ) -> Result<(bool, Option<u32>)> {
627 let pagination = [
630 Selector::parse(".pagination-list").unwrap(),
631 Selector::parse(".pagination").unwrap(),
632 ]
633 .into_iter()
634 .find_map(|sel| document.select(&sel).next());
635
636 if let Some(pagination) = pagination {
637 let next_selectors = [
639 "a[aria-label=\"Next\"]",
640 "a[aria-label=\"Next page\"]",
641 "a[rel=\"next\"]",
642 ".pagination-next a",
643 "a:contains(\"Next\")",
644 ".next a",
645 ];
646
647 let mut has_next = false;
648 for selector_str in &next_selectors {
649 if let Ok(selector) = Selector::parse(selector_str) {
650 if pagination.select(&selector).next().is_some() {
651 has_next = true;
652 break;
653 }
654 }
655 }
656
657 let total_pages = self.extract_total_pages_from_pagination(&pagination);
659
660 Ok((has_next, total_pages))
661 } else {
662 Ok((false, Some(1)))
664 }
665 }
666
667 fn extract_total_pages_from_pagination(&self, pagination: &scraper::ElementRef) -> Option<u32> {
669 let text = pagination.text().collect::<String>();
671 if let Some(of_pos) = text.find(" of ") {
672 let after_of = &text[of_pos + 4..];
673 if let Some(number_end) = after_of.find(|c: char| !c.is_ascii_digit()) {
674 if let Ok(total) = after_of[..number_end].parse::<u32>() {
675 return Some(total);
676 }
677 } else if let Ok(total) = after_of.trim().parse::<u32>() {
678 return Some(total);
679 }
680 }
681
682 let extract_page_param = |href: &str| -> Option<u32> {
683 let idx = href.find("page=")?;
684 let after = &href[idx + "page=".len()..];
685 let digits: String = after.chars().take_while(|c| c.is_ascii_digit()).collect();
686 if digits.is_empty() {
687 return None;
688 }
689 digits.parse::<u32>().ok()
690 };
691
692 let link_selector = Selector::parse("a[href*=\"page=\"]").unwrap();
694 let mut max_page = None::<u32>;
695 for a in pagination.select(&link_selector) {
696 if let Some(href) = a.value().attr("href") {
697 if let Some(p) = extract_page_param(href) {
698 max_page = Some(max_page.map_or(p, |m| m.max(p)));
699 }
700 }
701
702 let label = a.text().collect::<String>().trim().to_string();
703 if !label.is_empty() && label.chars().all(|c| c.is_ascii_digit()) {
704 if let Ok(p) = label.parse::<u32>() {
705 max_page = Some(max_page.map_or(p, |m| m.max(p)));
706 }
707 }
708 }
709
710 max_page
711 }
712
713 pub fn find_timestamp_for_track(&self, _document: &Html, _track_name: &str) -> Option<u64> {
719 None
721 }
722
723 pub fn find_playcount_for_track(&self, document: &Html, track_name: &str) -> Result<u32> {
724 let count_selector = Selector::parse(".chartlist-count-bar-value").unwrap();
726 let link_selector = Selector::parse("a[href*=\"/music/\"]").unwrap();
727
728 for link in document.select(&link_selector) {
730 let link_text = link.text().collect::<String>().trim().to_string();
731 if link_text == track_name {
732 if let Some(row) = self.find_ancestor_row(link) {
733 if let Some(count_element) = row.select(&count_selector).next() {
734 let text = count_element.text().collect::<String>().trim().to_string();
735 if let Some(number_part) = text.split_whitespace().next() {
736 if let Ok(count) = number_part.parse::<u32>() {
737 return Ok(count);
738 }
739 }
740 }
741 }
742 }
743 }
744 Err(LastFmError::Parse(format!(
745 "Could not find playcount for track: {track_name}"
746 )))
747 }
748
749 pub fn find_timestamp_for_album(&self, _document: &Html, _album_name: &str) -> Option<u64> {
750 None
752 }
753
754 pub fn find_playcount_for_album(&self, document: &Html, album_name: &str) -> Result<u32> {
755 let count_selector = Selector::parse(".chartlist-count-bar-value").unwrap();
757 let link_selector = Selector::parse("a[href*=\"/music/\"]").unwrap();
758
759 for link in document.select(&link_selector) {
761 let link_text = link.text().collect::<String>().trim().to_string();
762 if link_text == album_name {
763 if let Some(row) = self.find_ancestor_row(link) {
764 if let Some(count_element) = row.select(&count_selector).next() {
765 let text = count_element.text().collect::<String>().trim().to_string();
766 if let Some(number_part) = text.split_whitespace().next() {
767 if let Ok(count) = number_part.parse::<u32>() {
768 return Ok(count);
769 }
770 }
771 }
772 }
773 }
774 }
775 Err(LastFmError::Parse(format!(
776 "Could not find playcount for album: {album_name}"
777 )))
778 }
779
780 pub fn find_ancestor_row<'a>(
781 &self,
782 element: scraper::ElementRef<'a>,
783 ) -> Option<scraper::ElementRef<'a>> {
784 let mut current = element;
785 while let Some(parent) = current.parent() {
786 if let Some(parent_elem) = scraper::ElementRef::wrap(parent) {
787 if parent_elem.value().name() == "tr" {
788 return Some(parent_elem);
789 }
790 current = parent_elem;
791 } else {
792 break;
793 }
794 }
795 None
796 }
797
798 pub fn parse_artists_page(&self, document: &Html, page_number: u32) -> Result<ArtistPage> {
800 let mut artists = Vec::new();
801
802 let table_selector = Selector::parse("table.chartlist").unwrap();
804 let row_selector = Selector::parse("tr.js-link-block").unwrap();
805
806 let tables: Vec<_> = document.select(&table_selector).collect();
807 log::debug!("Found {} chartlist tables for artists", tables.len());
808
809 for table in tables {
810 for row in table.select(&row_selector) {
811 if let Ok(artist) = self.parse_artist_row(&row) {
812 artists.push(artist);
813 }
814 }
815 }
816
817 log::debug!("Parsed {} artists from page {}", artists.len(), page_number);
818
819 let (has_next_page, total_pages) = self.parse_pagination(document, page_number)?;
820
821 Ok(ArtistPage {
822 artists,
823 page_number,
824 has_next_page,
825 total_pages,
826 })
827 }
828
829 fn parse_artist_row(&self, row: &scraper::ElementRef) -> Result<Artist> {
831 let name_selector = Selector::parse("td.chartlist-name a").unwrap();
833 let name = row
834 .select(&name_selector)
835 .next()
836 .ok_or(LastFmError::Parse("Missing artist name".to_string()))?
837 .text()
838 .collect::<String>()
839 .trim()
840 .to_string();
841
842 let count_selector = Selector::parse(".chartlist-count-bar").unwrap();
844 let playcount = if let Some(count_element) = row.select(&count_selector).next() {
845 let count_text = count_element.text().collect::<String>();
846 self.extract_number_from_count_text(&count_text)
847 .unwrap_or(0)
848 } else {
849 0
850 };
851
852 let timestamp = None;
854
855 Ok(Artist {
856 name,
857 playcount,
858 timestamp,
859 })
860 }
861
862 fn extract_number_from_count_text(&self, text: &str) -> Option<u32> {
864 let cleaned = text.replace(',', "");
866 cleaned.split_whitespace().next()?.parse::<u32>().ok()
867 }
868}
869
870impl Default for LastFmParser {
871 fn default() -> Self {
872 Self::new()
873 }
874}