lastfm_edit/
edit_analysis.rs

1use http_types::StatusCode;
2use scraper::{Html, Selector};
3
4/// Result of analyzing an edit response from Last.fm
5#[derive(Debug, Clone)]
6pub struct EditAnalysisResult {
7    /// Whether the edit was successful based on all indicators
8    pub success: bool,
9    /// Optional detailed message about the result
10    pub message: Option<String>,
11    /// Track name found in the response (if any)
12    pub actual_track_name: Option<String>,
13    /// Album name found in the response (if any)
14    pub actual_album_name: Option<String>,
15}
16
17/// Analyze the HTML response from a Last.fm edit request to determine success/failure
18///
19/// This function parses the response HTML to look for success/error indicators
20/// and extract the actual track/album names that were processed.
21///
22/// # Arguments
23/// * `response_text` - The HTML response body from the edit request
24/// * `status_code` - The HTTP status code of the response
25///
26/// # Returns
27/// An `EditAnalysisResult` containing the analysis results
28pub fn analyze_edit_response(response_text: &str, status_code: StatusCode) -> EditAnalysisResult {
29    // Parse the HTML response to check for actual success/failure
30    let document = Html::parse_document(response_text);
31
32    // Check for success indicator
33    let success_selector = Selector::parse(".alert-success").unwrap();
34    let error_selector = Selector::parse(".alert-danger, .alert-error, .error").unwrap();
35
36    let has_success_alert = document.select(&success_selector).next().is_some();
37    let has_error_alert = document.select(&error_selector).next().is_some();
38
39    // Extract track and album names from the response
40    let (actual_track_name, actual_album_name) =
41        extract_track_album_names(&document, response_text);
42
43    log::debug!(
44        "Response analysis: success_alert={}, error_alert={}, track='{}', album='{}'",
45        has_success_alert,
46        has_error_alert,
47        actual_track_name.as_deref().unwrap_or("not found"),
48        actual_album_name.as_deref().unwrap_or("not found")
49    );
50
51    // Determine if edit was truly successful
52    let final_success = status_code.is_success() && has_success_alert && !has_error_alert;
53
54    // Create detailed message
55    let message = if has_error_alert {
56        // Extract error message
57        if let Some(error_element) = document.select(&error_selector).next() {
58            Some(format!(
59                "Edit failed: {}",
60                error_element.text().collect::<String>().trim()
61            ))
62        } else {
63            Some("Edit failed with unknown error".to_string())
64        }
65    } else if final_success {
66        Some(format!(
67            "Edit successful - Track: '{}', Album: '{}'",
68            actual_track_name.as_deref().unwrap_or("unknown"),
69            actual_album_name.as_deref().unwrap_or("unknown")
70        ))
71    } else {
72        Some(format!("Edit failed with status: {status_code}"))
73    };
74
75    EditAnalysisResult {
76        success: final_success,
77        message,
78        actual_track_name,
79        actual_album_name,
80    }
81}
82
83/// Extract track and album names from the edit response
84///
85/// This function tries multiple strategies to find the actual track and album names
86/// in the response, including direct CSS selectors and regex patterns.
87fn extract_track_album_names(
88    document: &Html,
89    response_text: &str,
90) -> (Option<String>, Option<String>) {
91    let mut actual_track_name = None;
92    let mut actual_album_name = None;
93
94    // Try direct selectors first
95    let track_name_selector = Selector::parse("td.chartlist-name a").unwrap();
96    let album_name_selector = Selector::parse("td.chartlist-album a").unwrap();
97
98    if let Some(track_element) = document.select(&track_name_selector).next() {
99        actual_track_name = Some(track_element.text().collect::<String>().trim().to_string());
100    }
101
102    if let Some(album_element) = document.select(&album_name_selector).next() {
103        actual_album_name = Some(album_element.text().collect::<String>().trim().to_string());
104    }
105
106    // If not found, try extracting from the raw response text using generic patterns
107    if actual_track_name.is_none() || actual_album_name.is_none() {
108        if actual_track_name.is_none() {
109            actual_track_name = extract_track_name_from_text(response_text);
110        }
111
112        if actual_album_name.is_none() {
113            actual_album_name = extract_album_name_from_text(response_text);
114        }
115    }
116
117    (actual_track_name, actual_album_name)
118}
119
120/// Extract track name from response text using regex patterns
121fn extract_track_name_from_text(response_text: &str) -> Option<String> {
122    // Look for track name in href="/music/{artist}/_/{track}"
123    // Use regex to find track URLs
124    let track_pattern = regex::Regex::new(r#"href="/music/[^"]+/_/([^"]+)""#).unwrap();
125    if let Some(captures) = track_pattern.captures(response_text) {
126        if let Some(track_match) = captures.get(1) {
127            let raw_track = track_match.as_str();
128            // URL decode the track name
129            let decoded_track = urlencoding::decode(raw_track)
130                .unwrap_or_else(|_| raw_track.into())
131                .replace('+', " ");
132            return Some(decoded_track);
133        }
134    }
135    None
136}
137
138/// Extract album name from response text using regex patterns
139fn extract_album_name_from_text(response_text: &str) -> Option<String> {
140    // Look for album name in href="/music/{artist}/{album}"
141    // Find album links that are not track links (don't contain /_/)
142    let album_pattern =
143        regex::Regex::new(r#"href="/music/[^"]+/([^"/_]+)"[^>]*>[^<]*</a>"#).unwrap();
144    if let Some(captures) = album_pattern.captures(response_text) {
145        if let Some(album_match) = captures.get(1) {
146            let raw_album = album_match.as_str();
147            // URL decode the album name
148            let decoded_album = urlencoding::decode(raw_album)
149                .unwrap_or_else(|_| raw_album.into())
150                .replace('+', " ");
151            return Some(decoded_album);
152        }
153    }
154    None
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    #[test]
162    fn test_analyze_success_response() {
163        let html = r#"
164            <div class="alert-success">Edit successful</div>
165            <table>
166                <tr>
167                    <td class="chartlist-name"><a href="/music/artist/_/track">Test Track</a></td>
168                    <td class="chartlist-album"><a href="/music/artist/album">Test Album</a></td>
169                </tr>
170            </table>
171        "#;
172
173        let result = analyze_edit_response(html, StatusCode::Ok);
174        assert!(result.success);
175        // The CSS selectors should extract the text content of the links
176        assert_eq!(result.actual_track_name, Some("Test Track".to_string()));
177        assert_eq!(result.actual_album_name, Some("Test Album".to_string()));
178    }
179
180    #[test]
181    fn test_analyze_error_response() {
182        let html = r#"
183            <div class="alert-danger">Edit failed: Invalid data</div>
184        "#;
185
186        let result = analyze_edit_response(html, StatusCode::Ok);
187        assert!(!result.success);
188        assert!(result
189            .message
190            .unwrap()
191            .contains("Edit failed: Invalid data"));
192    }
193
194    #[test]
195    fn test_extract_from_regex_patterns() {
196        let html = r#"
197            Some content with <a href="/music/Artist/AlbumName">album link</a>
198            and later <a href="/music/Artist/_/TrackName">track link</a>
199        "#;
200
201        let result = analyze_edit_response(html, StatusCode::Ok);
202        // Should extract from regex patterns when direct selectors fail
203        // The track pattern captures from /_/ URLs, album pattern from non-/_/ URLs
204        assert_eq!(result.actual_track_name, Some("TrackName".to_string()));
205        assert_eq!(result.actual_album_name, Some("AlbumName".to_string()));
206    }
207}