Skip to main content

lastfm_edit/
parsing.rs

1//! HTML parsing utilities for Last.fm pages.
2//!
3//! This module contains all the HTML parsing logic for extracting track, album,
4//! and other data from Last.fm web pages. These functions are primarily pure
5//! functions that take HTML documents and return structured data.
6
7use crate::{Album, AlbumPage, Artist, ArtistPage, LastFmError, Result, Track, TrackPage};
8use scraper::{Html, Selector};
9
10/// Parser struct containing parsing methods for Last.fm HTML pages.
11///
12/// This struct holds the parsing logic that was previously embedded in the client.
13/// It's designed to be stateless and focused purely on HTML parsing.
14#[derive(Debug, Clone)]
15pub struct LastFmParser;
16
17impl LastFmParser {
18    /// Create a new parser instance.
19    pub fn new() -> Self {
20        Self
21    }
22
23    /// Parse recent scrobbles from the user's library page
24    /// This extracts real scrobble data with timestamps for editing
25    pub fn parse_recent_scrobbles(&self, document: &Html) -> Result<Vec<Track>> {
26        let mut tracks = Vec::new();
27
28        // Recent scrobbles are typically in chartlist tables - there can be multiple
29        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    /// Parse a single row from the recent scrobbles table
52    fn parse_recent_scrobble_row(&self, row: &scraper::ElementRef) -> Result<Track> {
53        // Extract track name
54        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        // Extract artist name
65        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        // Extract timestamp from data attributes or hidden inputs
76        let timestamp = self.extract_scrobble_timestamp(row);
77
78        // Extract album from hidden inputs in edit form
79        let album = self.extract_scrobble_album(row);
80
81        // Extract album artist from hidden inputs in edit form
82        let album_artist = self.extract_scrobble_album_artist(row);
83
84        // For recent scrobbles, playcount is typically 1 since they're individual scrobbles
85        let playcount = 1;
86
87        Ok(Track {
88            name,
89            artist,
90            playcount,
91            timestamp,
92            album,
93            album_artist,
94        })
95    }
96
97    /// Extract timestamp from scrobble row elements
98    fn extract_scrobble_timestamp(&self, row: &scraper::ElementRef) -> Option<u64> {
99        // Look for timestamp in various places:
100
101        // 1. Check for data-timestamp attribute
102        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        // 2. Look for hidden timestamp input
109        let timestamp_input_selector = Selector::parse("input[name='timestamp']").unwrap();
110        if let Some(input) = row.select(&timestamp_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        // 3. Look for edit form with timestamp
119        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        // Removed time element parsing - testing if needed
130
131        None
132    }
133
134    /// Extract album name from scrobble row elements
135    fn extract_scrobble_album(&self, row: &scraper::ElementRef) -> Option<String> {
136        // Look for album_name in hidden inputs within edit forms
137        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    /// Extract album artist name from scrobble row elements
152    fn extract_scrobble_album_artist(&self, row: &scraper::ElementRef) -> Option<String> {
153        // Look for album_artist_name in hidden inputs within edit forms
154        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    /// Parse a tracks page into a `TrackPage` structure
169    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        // Check for pagination
179        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    /// Extract tracks from HTML document
190    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        // JSON parsing removed - was not implemented and always failed
202
203        // Parse track data from data-track-name attributes (AJAX response)
204        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, // Not available in aggregate track listings
232                    };
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        // Always try fallback parsing from chartlist tables to catch tracks without data-track-name
243        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                // Try to parse as track row
252                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                    // Only add if we don't already have this track
261                    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    // Removed parse_tracks_from_rows - no longer needed
274
275    /// Parse a single track row from chartlist table
276    pub fn parse_track_row(&self, row: &scraper::ElementRef) -> Result<Track> {
277        // Extract track name using shared method
278        let name = self.extract_name_from_row(row, "track")?;
279
280        // Parse play count using shared method
281        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,    // Not available in table parsing mode
295            album: None,        // Not available in table parsing mode
296            album_artist: None, // Not available in table parsing mode
297        })
298    }
299
300    /// Parse albums page into `AlbumPage` structure
301    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        // Try parsing album data from data attributes (AJAX response)
310        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            // Use a set to track unique albums
320            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            // Fall back to parsing album rows from chartlist tables
341            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    /// Parse albums from chartlist table rows
355    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    /// Parse a single album row from chartlist table
372    pub fn parse_album_row(&self, row: &scraper::ElementRef) -> Result<Album> {
373        // Extract album name using shared method
374        let name = self.extract_name_from_row(row, "album")?;
375
376        // Parse play count using shared method
377        let playcount = self.extract_playcount_from_row(row);
378
379        let artist = "".to_string(); // Will be filled in by caller
380
381        Ok(Album {
382            name,
383            artist,
384            playcount,
385            timestamp: None, // Not available in table parsing
386        })
387    }
388
389    // === SEARCH RESULTS PARSING ===
390
391    /// Parse track search results from AJAX response
392    ///
393    /// This parses the HTML returned by `/user/{username}/library/tracks/search?ajax=1&query={query}`
394    /// which contains chartlist tables with track results.
395    pub fn parse_track_search_results(&self, document: &Html) -> Result<Vec<Track>> {
396        let mut tracks = Vec::new();
397
398        // Search results use the same chartlist structure as library pages
399        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    /// Parse album search results from AJAX response
418    ///
419    /// This parses the HTML returned by `/user/{username}/library/albums/search?ajax=1&query={query}`
420    /// which contains chartlist tables with album results.
421    pub fn parse_album_search_results(&self, document: &Html) -> Result<Vec<Album>> {
422        let mut albums = Vec::new();
423
424        // Search results use the same chartlist structure as library pages
425        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    /// Parse artist search results from AJAX response
447    ///
448    /// This parses the HTML returned by `/user/{username}/library/artists/search?ajax=1&query={query}`
449    /// which contains chartlist tables with artist results.
450    pub fn parse_artist_search_results(&self, document: &Html) -> Result<Vec<Artist>> {
451        let mut artists = Vec::new();
452
453        // Search results use the same chartlist structure as library pages
454        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    /// Parse a single artist row from search results
476    fn parse_search_artist_row(&self, row: &scraper::ElementRef) -> Result<Artist> {
477        // Extract artist name from the name column
478        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        // Extract playcount from the count bar
491        let playcount = self.extract_playcount_from_row(row);
492
493        Ok(Artist {
494            name,
495            playcount,
496            timestamp: None, // Search results don't have timestamps
497        })
498    }
499
500    /// Parse a single track row from search results
501    fn parse_search_track_row(&self, row: &scraper::ElementRef) -> Result<Track> {
502        // Extract track name using the standard chartlist structure
503        let name = self.extract_name_from_row(row, "track")?;
504
505        // Extract artist name from chartlist-artist column
506        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        // Extract playcount from the bar value
516        let playcount = self.extract_playcount_from_row(row);
517
518        // Search results typically don't have timestamps since they're aggregated
519        let timestamp = None;
520
521        // Try to extract album information if available in the search results
522        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    /// Parse a single album row from search results
536    fn parse_search_album_row(&self, row: &scraper::ElementRef) -> Result<Album> {
537        // Extract album name using the standard chartlist structure
538        let name = self.extract_name_from_row(row, "album")?;
539
540        // Extract artist name from chartlist-artist column
541        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        // Extract playcount from the bar value
551        let playcount = self.extract_playcount_from_row(row);
552
553        Ok(Album {
554            name,
555            artist,
556            playcount,
557            timestamp: None, // Search results don't have timestamps
558        })
559    }
560
561    /// Extract album information from search track row
562    fn extract_album_from_search_row(&self, row: &scraper::ElementRef) -> Option<String> {
563        // Look for album information in hidden form inputs (similar to recent scrobbles)
564        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    /// Extract album artist information from search track row
577    fn extract_album_artist_from_search_row(&self, row: &scraper::ElementRef) -> Option<String> {
578        // Look for album artist information in hidden form inputs
579        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    // === SHARED PARSING UTILITIES ===
592
593    /// Extract name from chartlist row (works for both tracks and albums)
594    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    /// Extract playcount from chartlist row (works for both tracks and albums)
605    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; // default fallback
608
609        if let Some(element) = row.select(&playcount_selector).next() {
610            let text = element.text().collect::<String>().trim().to_string();
611            // Extract just the number part (before "scrobbles" if present)
612            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    /// Parse pagination information from document
622    pub fn parse_pagination(
623        &self,
624        document: &Html,
625        _current_page: u32,
626    ) -> Result<(bool, Option<u32>)> {
627        // Different parts of Last.fm use slightly different pagination wrappers.
628        // Prefer the more specific `.pagination-list` when present, otherwise fall back to `.pagination`.
629        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            // Try multiple possible selectors for next page link
638            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            // Try to extract total pages from pagination text
658            let total_pages = self.extract_total_pages_from_pagination(&pagination);
659
660            Ok((has_next, total_pages))
661        } else {
662            // No pagination found - single page
663            Ok((false, Some(1)))
664        }
665    }
666
667    /// Helper functions for pagination parsing
668    fn extract_total_pages_from_pagination(&self, pagination: &scraper::ElementRef) -> Option<u32> {
669        // Look for patterns like "Page 1 of 42"
670        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        // Fall back to extracting the maximum `page=` value found in pagination links.
693        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    // === JSON PARSING METHODS ===
714    // Removed unused JSON parsing method
715
716    // === FIND HELPER METHODS ===
717
718    pub fn find_timestamp_for_track(&self, _document: &Html, _track_name: &str) -> Option<u64> {
719        // Implementation would search for timestamp data
720        None
721    }
722
723    pub fn find_playcount_for_track(&self, document: &Html, track_name: &str) -> Result<u32> {
724        // Look for chartlist-count-bar-value elements near the track
725        let count_selector = Selector::parse(".chartlist-count-bar-value").unwrap();
726        let link_selector = Selector::parse("a[href*=\"/music/\"]").unwrap();
727
728        // Find all track links that match our track name
729        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        // Implementation would search for timestamp data
751        None
752    }
753
754    pub fn find_playcount_for_album(&self, document: &Html, album_name: &str) -> Result<u32> {
755        // Look for chartlist-count-bar-value elements near the album
756        let count_selector = Selector::parse(".chartlist-count-bar-value").unwrap();
757        let link_selector = Selector::parse("a[href*=\"/music/\"]").unwrap();
758
759        // Find all album links that match our album name
760        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    /// Parse artists page from user's library
799    pub fn parse_artists_page(&self, document: &Html, page_number: u32) -> Result<ArtistPage> {
800        let mut artists = Vec::new();
801
802        // Parse artists from chartlist table rows
803        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    /// Parse a single artist row from the artist library table
830    fn parse_artist_row(&self, row: &scraper::ElementRef) -> Result<Artist> {
831        // Extract artist name from the name column
832        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        // Extract playcount from the count bar
843        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        // Artists in library listings typically don't have individual timestamps
853        let timestamp = None;
854
855        Ok(Artist {
856            name,
857            playcount,
858            timestamp,
859        })
860    }
861
862    /// Extract numeric value from count text like "3,395 scrobbles"
863    fn extract_number_from_count_text(&self, text: &str) -> Option<u32> {
864        // Remove commas and extract the first numeric part
865        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}