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 if !b[0].is_ascii_uppercase() || !b[1].is_ascii_uppercase() {
307 return false;
308 }
309 for &c in &b[2..5] {
311 if !c.is_ascii_uppercase() && !c.is_ascii_digit() {
312 return false;
313 }
314 }
315 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}