Skip to main content

lavende_core/sources/
shazam.rs

1use crate::{
2    protocol::tracks::{LoadResult, Track, TrackInfo},
3    sources::{SourcePlugin, playable_track::BoxedTrack},
4};
5use async_trait::async_trait;
6use regex::Regex;
7use reqwest::header::USER_AGENT;
8use serde_json::Value;
9use std::sync::{Arc, OnceLock};
10use tracing::error;
11const SEARCH_URL: &str = "https://www.shazam.com/services/amapi/v1/catalog/US/search";
12fn url_regex() -> &'static Regex {
13    static REGEX: OnceLock<Regex> = OnceLock::new();
14    REGEX.get_or_init(|| {
15        Regex::new(r"https?://(?:www\.)?shazam\.com/song/\d+(?:/[^/?#]+)?")
16            .expect("shazam URL regex is a valid literal")
17    })
18}
19fn og_title_regex() -> &'static Regex {
20    static REGEX: OnceLock<Regex> = OnceLock::new();
21    REGEX.get_or_init(|| {
22        Regex::new(r"^(.+?) - (.+?):").expect("shazam og:title regex is a valid literal")
23    })
24}
25pub struct ShazamSource {
26    client: Arc<reqwest::Client>,
27    search_limit: usize,
28}
29impl ShazamSource {
30    pub fn new(
31        config: &crate::config::AppConfig,
32        client: Arc<reqwest::Client>,
33    ) -> Result<Self, String> {
34        Ok(Self {
35            client,
36            search_limit: config
37                .sources
38                .shazam
39                .as_ref()
40                .map(|c| c.search_limit)
41                .unwrap_or(10),
42        })
43    }
44    async fn search(&self, query: &str) -> LoadResult {
45        let url = format!(
46            "{SEARCH_URL}?types=songs&term={query}&limit={limit}",
47            query = urlencoding::encode(query),
48            limit = self.search_limit
49        );
50        let resp = match self.base_request(self.client.get(&url)).send().await {
51            Ok(r) => r,
52            Err(e) => {
53                error!("Shazam search request failed: {e}");
54                return LoadResult::Empty {};
55            }
56        };
57        if !resp.status().is_success() {
58            return LoadResult::Empty {};
59        }
60        let data: Value = match resp.json().await {
61            Ok(v) => v,
62            Err(e) => {
63                error!("Failed to parse Shazam search JSON: {e}");
64                return LoadResult::Empty {};
65            }
66        };
67        let songs = data
68            .pointer("/results/songs/data")
69            .and_then(|v| v.as_array());
70        let Some(songs) = songs else {
71            return LoadResult::Empty {};
72        };
73        let mut tracks = Vec::new();
74        for item in songs {
75            if let Some(track) = self.build_track(item) {
76                tracks.push(track);
77            }
78        }
79        if tracks.is_empty() {
80            LoadResult::Empty {}
81        } else {
82            LoadResult::Search(tracks)
83        }
84    }
85    fn build_track(&self, item: &Value) -> Option<Track> {
86        let attributes = item.get("attributes")?;
87        let id = item.get("id")?.as_str()?.to_owned();
88        let title = attributes
89            .get("name")
90            .and_then(|v| v.as_str())
91            .unwrap_or("Unknown")
92            .to_owned();
93        let author = attributes
94            .get("artistName")
95            .and_then(|v| v.as_str())
96            .unwrap_or("Unknown")
97            .to_owned();
98        let length = attributes
99            .get("durationInMillis")
100            .and_then(|v| v.as_u64())
101            .unwrap_or(0);
102        let isrc = attributes
103            .get("isrc")
104            .and_then(|v| v.as_str())
105            .map(|s| s.to_owned());
106        let artwork_url = attributes
107            .get("artwork")
108            .and_then(|a| a.get("url"))
109            .and_then(|v| v.as_str())
110            .map(|s| s.replace("{w}", "1000").replace("{h}", "1000"));
111        let uri = attributes
112            .get("url")
113            .and_then(|v| v.as_str())
114            .map(|s| s.to_owned());
115        let mut track = Track::new(TrackInfo {
116            identifier: id,
117            is_seekable: true,
118            author,
119            length,
120            is_stream: false,
121            position: 0,
122            title,
123            uri: uri.clone(),
124            artwork_url,
125            isrc,
126            source_name: "shazam".to_owned(),
127        });
128        track.plugin_info = serde_json::json!({
129            "albumName": null,
130            "albumUrl": null,
131            "artistUrl": null,
132            "artistArtworkUrl": null,
133            "previewUrl": null,
134            "isPreview": false
135        });
136        Some(track)
137    }
138    async fn resolve_url(&self, url: &str) -> LoadResult {
139        let resp = match self.base_request(self.client.get(url)).send().await {
140            Ok(r) => r,
141            Err(e) => {
142                error!("Shazam resolve request failed: {e}");
143                return LoadResult::Empty {};
144            }
145        };
146        if !resp.status().is_success() {
147            return LoadResult::Empty {};
148        }
149        let html = match resp.text().await {
150            Ok(t) => t,
151            Err(e) => {
152                error!("Failed to read Shazam HTML: {e}");
153                return LoadResult::Empty {};
154            }
155        };
156        let title = self.extract_text_after_class(&html, "NewTrackPageHeader_trackTitle__");
157        let artist = self.extract_text_after_class(&html, "TrackPageArtistLink_artistNameText__");
158        let artwork_url = self.extract_artwork(&html);
159        let isrc = self.extract_isrc(&html);
160        let duration_ms = self.extract_duration(&html);
161        let apple_music_url =
162            self.extract_href_starting_with(&html, "https://www.shazam.com/applemusic/song/");
163        let mut final_title = title.unwrap_or_else(|| "Unknown".to_owned());
164        let mut final_artist = artist.unwrap_or_else(|| "Unknown".to_owned());
165        if final_title == "Unknown"
166            && let Some(og_title) = self.extract_meta_content(&html, "og:title")
167        {
168            if let Some(caps) = og_title_regex().captures(&og_title) {
169                final_title = caps
170                    .get(1)
171                    .map(|m| m.as_str().to_owned())
172                    .unwrap_or_else(|| og_title.clone());
173                final_artist = caps
174                    .get(2)
175                    .map(|m| m.as_str().to_owned())
176                    .unwrap_or_else(|| "Unknown".to_owned());
177            } else {
178                final_title = og_title;
179            }
180        }
181        if final_title == "Unknown" && apple_music_url.is_none() {
182            return LoadResult::Empty {};
183        }
184        let clean_url = url.strip_suffix('/').unwrap_or(url);
185        let identifier = clean_url
186            .split('/')
187            .next_back()
188            .unwrap_or("unknown")
189            .to_owned();
190        let mut track = Track::new(TrackInfo {
191            identifier,
192            is_seekable: true,
193            author: final_artist,
194            length: duration_ms,
195            is_stream: false,
196            position: 0,
197            title: final_title,
198            uri: Some(url.to_owned()),
199            artwork_url: artwork_url.or_else(|| self.extract_meta_content(&html, "og:image")),
200            isrc,
201            source_name: "shazam".to_owned(),
202        });
203        track.plugin_info = serde_json::json!({
204            "albumName": null,
205            "albumUrl": null,
206            "artistUrl": null,
207            "artistArtworkUrl": null,
208            "previewUrl": null,
209            "isPreview": false
210        });
211        LoadResult::Track(track)
212    }
213    fn extract_text_after_class(&self, html: &str, class_part: &str) -> Option<String> {
214        let mut from = 0;
215        while let Some(c) = html[from..].find("class=\"") {
216            let c = from + c;
217            let q = html[c + 7..].find('"').map(|i| c + 7 + i)?;
218            let cls = &html[c + 7..q];
219            if cls.contains(class_part) {
220                let gt = html[q..].find('>').map(|i| q + i)?;
221                let lt = html[gt + 1..].find('<').map(|i| gt + 1 + i)?;
222                let text = html[gt + 1..lt].trim().to_owned();
223                if !text.is_empty() {
224                    return Some(text);
225                }
226            }
227            from = q + 1;
228        }
229        None
230    }
231    fn extract_href_starting_with(&self, html: &str, prefix: &str) -> Option<String> {
232        let pattern = format!("href=\"{prefix}\"");
233        if let Some(i) = html.find(&pattern) {
234            let start = i + 6;
235            if let Some(end) = html[start..].find('"') {
236                return Some(html[start..start + end].to_owned());
237            }
238        }
239        None
240    }
241    fn extract_artwork(&self, html: &str) -> Option<String> {
242        if let Some(og) = self.extract_meta_content(html, "og:image") {
243            return Some(og);
244        }
245        let mut alt_idx = html.find("alt=\"album cover\"");
246        if alt_idx.is_none() {
247            alt_idx = html.find("alt=\"song thumbnail\"");
248        }
249        let alt_idx = alt_idx?;
250        let img_start = html[..alt_idx].rfind("<img")?;
251        let img_end = html[alt_idx..].find('>')? + alt_idx;
252        let tag = &html[img_start..img_end + 1];
253        if let Some(s) = tag.find("srcset=\"") {
254            let val_start = s + 8;
255            if let Some(val_end) = tag[val_start..].find('"') {
256                let srcset = &tag[val_start..val_start + val_end];
257                let space = srcset.find(' ').unwrap_or(srcset.len());
258                return Some(srcset[..space].to_owned());
259            }
260        }
261        None
262    }
263    fn extract_isrc(&self, html: &str) -> Option<String> {
264        let tokens = ["\"isrc\"", "\\\"isrc\\\""];
265        for token in tokens {
266            let mut from = 0;
267            while let Some(at) = html[from..].find(token) {
268                let at = from + at;
269                from = at + token.len();
270                let mut i = html[from..].find(':')? + from + 1;
271                while i < html.len() {
272                    let bytes = html.as_bytes();
273                    if bytes[i] != b' '
274                        && bytes[i] != b'\t'
275                        && bytes[i] != b'\n'
276                        && bytes[i] != b'\r'
277                    {
278                        break;
279                    }
280                    i += 1;
281                }
282                while i < html.len() && html.as_bytes()[i] == b'\\' {
283                    i += 1;
284                }
285                if i >= html.len() || html.as_bytes()[i] != b'"' {
286                    continue;
287                }
288                i += 1;
289                if i + 12 > html.len() {
290                    continue;
291                }
292                let isrc_cand = &html[i..i + 12];
293                if self.is_valid_isrc(isrc_cand) {
294                    return Some(isrc_cand.to_owned());
295                }
296            }
297        }
298        None
299    }
300    fn is_valid_isrc(&self, s: &str) -> bool {
301        if s.len() != 12 {
302            return false;
303        }
304        let b = s.as_bytes();
305        // 2 upper
306        if !b[0].is_ascii_uppercase() || !b[1].is_ascii_uppercase() {
307            return false;
308        }
309        // 3 upper or digit
310        for &c in &b[2..5] {
311            if !c.is_ascii_uppercase() && !c.is_ascii_digit() {
312                return false;
313            }
314        }
315        // 7 digit
316        for &c in &b[5..12] {
317            if !c.is_ascii_digit() {
318                return false;
319            }
320        }
321        true
322    }
323    fn extract_duration(&self, html: &str) -> u64 {
324        let needles = [
325            "\"duration\":\"PT",
326            "\"duration\": \"PT",
327            "\\\"duration\\\":\\\"PT",
328        ];
329        for n in needles {
330            if let Some(at) = html.find(n) {
331                let start = at + n.len() - 2;
332                let bracket = n.starts_with('\\');
333                let end_char = if bracket { '\\' } else { '"' };
334                if let Some(end) = html[start..].find(end_char) {
335                    return self.parse_iso_duration(&html[start..start + end]);
336                }
337            }
338        }
339        0
340    }
341    fn parse_iso_duration(&self, iso: &str) -> u64 {
342        if !iso.contains('T') {
343            return 0;
344        }
345        let t_idx = iso.find('T').unwrap() + 1;
346        let mut ms = 0.0;
347        let mut current_num = String::new();
348        for c in iso[t_idx..].chars() {
349            if c.is_ascii_digit() || c == '.' {
350                current_num.push(c);
351            } else {
352                let val = current_num.parse::<f64>().unwrap_or(0.0);
353                match c {
354                    'H' => ms += val * 3600000.0,
355                    'M' => ms += val * 60000.0,
356                    'S' => ms += val * 1000.0,
357                    _ => break,
358                }
359                current_num.clear();
360            }
361        }
362        ms as u64
363    }
364    fn extract_meta_content(&self, html: &str, prop: &str) -> Option<String> {
365        let pattern = format!("<meta property=\"{prop}\" content=\"");
366        if let Some(i) = html.find(&pattern) {
367            let start = i + pattern.len();
368            if let Some(end) = html[start..].find('"') {
369                return Some(html[start..start + end].to_owned());
370            }
371        }
372        None
373    }
374    pub fn base_request(&self, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
375        builder.header(USER_AGENT, "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36")
376    }
377}
378#[async_trait]
379impl SourcePlugin for ShazamSource {
380    fn name(&self) -> &str {
381        "shazam"
382    }
383    fn can_handle(&self, identifier: &str) -> bool {
384        self.search_prefixes()
385            .iter()
386            .any(|p| identifier.starts_with(p))
387            || url_regex().is_match(identifier)
388    }
389    fn search_prefixes(&self) -> Vec<&str> {
390        vec!["shsearch:", "szsearch:"]
391    }
392    fn is_mirror(&self) -> bool {
393        true
394    }
395    async fn load(
396        &self,
397        identifier: &str,
398        _routeplanner: Option<Arc<dyn crate::routeplanner::RoutePlanner>>,
399    ) -> LoadResult {
400        if let Some(prefix) = self
401            .search_prefixes()
402            .into_iter()
403            .find(|p| identifier.starts_with(p))
404        {
405            let query = &identifier[prefix.len()..];
406            return self.search(query).await;
407        }
408        if url_regex().is_match(identifier) {
409            return self.resolve_url(identifier).await;
410        }
411        LoadResult::Empty {}
412    }
413    async fn get_track(
414        &self,
415        _identifier: &str,
416        _routeplanner: Option<Arc<dyn crate::routeplanner::RoutePlanner>>,
417    ) -> Option<BoxedTrack> {
418        None
419    }
420}