1pub mod manager {
2 use super::{track::AudiomackTrack, utils::build_auth_header};
3 use crate::{
4 protocol::tracks::{LoadResult, PlaylistData, PlaylistInfo, Track, TrackInfo},
5 sources::{SourcePlugin, playable_track::BoxedTrack},
6 };
7 use async_trait::async_trait;
8 use rand::{Rng, distributions::Alphanumeric, thread_rng};
9 use regex::Regex;
10 use serde_json::Value;
11 use std::{
12 collections::BTreeMap,
13 sync::{Arc, OnceLock},
14 };
15 use tracing::{error, warn};
16 const API_BASE: &str = "https://api.audiomack.com/v1";
17 static SONG_REGEX: OnceLock<Regex> = OnceLock::new();
18 static ALBUM_REGEX: OnceLock<Regex> = OnceLock::new();
19 static PLAYLIST_REGEX: OnceLock<Regex> = OnceLock::new();
20 static ARTIST_REGEX: OnceLock<Regex> = OnceLock::new();
21 static LIKES_REGEX: OnceLock<Regex> = OnceLock::new();
22 pub struct AudiomackSource {
23 client: Arc<reqwest::Client>,
24 search_limit: usize,
25 }
26 impl AudiomackSource {
27 pub fn new(
28 config: Option<crate::config::AudiomackConfig>,
29 client: Arc<reqwest::Client>,
30 ) -> Result<Self, String> {
31 let search_limit = config.map(|c| c.search_limit).unwrap_or(20);
32 Ok(Self {
33 client,
34 search_limit,
35 })
36 }
37 fn generate_nonce(&self) -> String {
38 thread_rng()
39 .sample_iter(&Alphanumeric)
40 .take(32)
41 .map(char::from)
42 .collect()
43 }
44 async fn make_request(
45 &self,
46 method: reqwest::Method,
47 endpoint: &str,
48 query_params: Option<BTreeMap<String, String>>,
49 ) -> Option<Value> {
50 let url = format!("{API_BASE}{endpoint}");
51 tracing::debug!("Audiomack request: {method} {url} params: {query_params:?}");
52 let mut request_builder = self.base_request(self.client.request(method.clone(), &url));
53 let nonce = self.generate_nonce();
54 let timestamp = std::time::SystemTime::now()
55 .duration_since(std::time::UNIX_EPOCH)
56 .unwrap_or_default()
57 .as_secs()
58 .to_string();
59 let auth_header = build_auth_header(
60 method.as_str(),
61 &url,
62 query_params.as_ref().unwrap_or(&BTreeMap::new()),
63 &nonce,
64 ×tamp,
65 );
66 request_builder = request_builder.header("Authorization", auth_header);
67 if let Some(qp) = query_params {
68 request_builder = request_builder.query(&qp);
69 }
70 let resp = match request_builder.send().await {
71 Ok(r) => r,
72 Err(e) => {
73 error!("Audiomack request failed: {e}");
74 return None;
75 }
76 };
77 let status = resp.status();
78 let text = match resp.text().await {
79 Ok(t) => t,
80 Err(e) => {
81 error!("Failed to read Audiomack response text: {e}");
82 return None;
83 }
84 };
85 if !status.is_success() {
86 warn!("Audiomack API error status: {status} for endpoint: {endpoint}");
87 return None;
88 }
89 match serde_json::from_str(&text) {
90 Ok(v) => Some(v),
91 Err(e) => {
92 error!("Failed to parse Audiomack JSON: {e} body: {text}");
93 None
94 }
95 }
96 }
97 fn base_request(&self, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
98 builder
99 .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36")
100 .header("Accept", "application/json")
101 .header("Accept-Language", "en-US,en;q=0.9")
102 .header("Origin", "https://audiomack.com")
103 .header("Referer", "https://audiomack.com/")
104 .header("Sec-Fetch-Site", "same-site")
105 .header("Sec-Fetch-Mode", "cors")
106 .header("Sec-Fetch-Dest", "empty")
107 .header("Priority", "u=1, i")
108 .header("DNT", "1")
109 .header("sec-ch-ua-platform", "\"Windows\"")
110 }
111 fn parse_track(&self, json: &Value) -> Option<Track> {
112 let id_val = json.get("id").or_else(|| json.get("song_id"));
113 let id = match id_val {
114 Some(Value::String(s)) => s.clone(),
115 Some(Value::Number(n)) => n.to_string(),
116 _ => {
117 tracing::debug!("Audiomack track missing id: {json:?}");
118 return None;
119 }
120 };
121 let title = json.get("title")?.as_str()?.to_owned();
122 let author = json.get("artist")?.as_str()?.to_owned();
123 let duration_sec = json
124 .get("duration")
125 .and_then(|v| {
126 v.as_u64()
127 .or_else(|| v.as_i64().map(|i| i as u64))
128 .or_else(|| v.as_str().and_then(|s| s.parse().ok()))
129 })
130 .unwrap_or_default();
131 let uploader_slug = json
132 .pointer("/uploader/url_slug")
133 .and_then(|v| v.as_str())
134 .or_else(|| json.get("uploader_url_slug").and_then(|v| v.as_str()))
135 .unwrap_or("unknown");
136 let url_slug = json.get("url_slug")?.as_str()?;
137 let uri = Some(format!(
138 "https://audiomack.com/{uploader_slug}/song/{url_slug}"
139 ));
140 let artwork_url = json
141 .get("image")
142 .and_then(|v| v.as_str())
143 .filter(|s| !s.is_empty())
144 .map(|s| s.to_owned());
145 let isrc = json
146 .get("isrc")
147 .and_then(|v| v.as_str())
148 .filter(|s| !s.is_empty())
149 .map(|s| s.to_owned());
150 Some(Track::new(TrackInfo {
151 identifier: id,
152 is_seekable: true,
153 author,
154 length: duration_sec * 1000,
155 is_stream: false,
156 position: 0,
157 title,
158 uri,
159 artwork_url,
160 isrc,
161 source_name: "audiomack".to_owned(),
162 }))
163 }
164 async fn search(&self, query: &str) -> LoadResult {
165 let mut params = BTreeMap::new();
166 params.insert("q".to_owned(), query.to_owned());
167 params.insert("limit".to_owned(), self.search_limit.to_string());
168 params.insert("show".to_owned(), "songs".to_owned());
169 params.insert("sort".to_owned(), "popular".to_owned());
170 let json = match self
171 .make_request(reqwest::Method::GET, "/search", Some(params))
172 .await
173 {
174 Some(j) => j,
175 None => return LoadResult::Empty {},
176 };
177 let results = match json.get("results").and_then(|v| v.as_array()) {
178 Some(r) => r,
179 None => return LoadResult::Empty {},
180 };
181 let tracks: Vec<_> = results
182 .iter()
183 .filter_map(|item| self.parse_track(item))
184 .collect();
185 if tracks.is_empty() {
186 LoadResult::Empty {}
187 } else {
188 LoadResult::Search(tracks)
189 }
190 }
191 async fn get_song(&self, artist: &str, slug: &str) -> LoadResult {
192 let endpoint = format!("/music/song/{artist}/{slug}");
193 let json = match self
194 .make_request(reqwest::Method::GET, &endpoint, None)
195 .await
196 {
197 Some(j) => j,
198 None => return LoadResult::Empty {},
199 };
200 if let Some(track) = json.get("results").and_then(|v| self.parse_track(v)) {
201 LoadResult::Track(track)
202 } else {
203 LoadResult::Empty {}
204 }
205 }
206 async fn get_playlist_items(&self, type_: &str, artist: &str, slug: &str) -> LoadResult {
207 let endpoint = if type_ == "playlist" {
208 format!("/playlist/{artist}/{slug}")
209 } else {
210 format!("/music/album/{artist}/{slug}")
211 };
212 let json = match self
213 .make_request(reqwest::Method::GET, &endpoint, None)
214 .await
215 {
216 Some(j) => j,
217 None => return LoadResult::Empty {},
218 };
219 let results = match json.get("results") {
220 Some(r) => r,
221 None => return LoadResult::Empty {},
222 };
223 let name = results
224 .get("title")
225 .and_then(|v| v.as_str())
226 .unwrap_or("Unknown")
227 .to_owned();
228 let tracks: Vec<_> = results
229 .get("tracks")
230 .and_then(|v| v.as_array())
231 .map(|arr| {
232 arr.iter()
233 .filter_map(|item| self.parse_track(item))
234 .collect()
235 })
236 .unwrap_or_default();
237 if tracks.is_empty() {
238 return LoadResult::Empty {};
239 }
240 let url = results
241 .get("url")
242 .and_then(|v| v.as_str())
243 .map(|s| format!("https://audiomack.com{s}"))
244 .unwrap_or_else(|| format!("https://audiomack.com/{artist}/{type_}/{slug}"));
245 LoadResult::Playlist(PlaylistData {
246 info: PlaylistInfo {
247 name,
248 selected_track: -1,
249 },
250 plugin_info: serde_json::json!({
251 "type": type_,
252 "url": url,
253 "artworkUrl": results.get("image").and_then(|v| v.as_str()),
254 "author": results.get("artist").and_then(|v| v.as_str()),
255 "totalTracks": tracks.len()
256 }),
257 tracks,
258 })
259 }
260 async fn get_artist(&self, artist_slug: &str) -> LoadResult {
261 let json = match self
262 .make_request(
263 reqwest::Method::GET,
264 &format!("/artist/{artist_slug}"),
265 None,
266 )
267 .await
268 {
269 Some(j) => j,
270 None => return LoadResult::Empty {},
271 };
272 let results = match json.get("results") {
273 Some(r) => r,
274 None => return LoadResult::Empty {},
275 };
276 let artist_id = results
277 .get("id")
278 .and_then(|v| {
279 v.as_str()
280 .map(|s| s.to_owned())
281 .or_else(|| v.as_i64().map(|i| i.to_string()))
282 })
283 .unwrap_or_default();
284 if artist_id.is_empty() {
285 return LoadResult::Empty {};
286 }
287 let name = results
288 .get("name")
289 .and_then(|v| v.as_str())
290 .unwrap_or("Artist")
291 .to_owned();
292 let mut params = BTreeMap::new();
293 params.insert("artist_id".to_owned(), artist_id);
294 params.insert("limit".to_owned(), "100".to_owned());
295 params.insert("sort".to_owned(), "rank".to_owned());
296 params.insert("type".to_owned(), "songs".to_owned());
297 let tracks_json = match self
298 .make_request(reqwest::Method::GET, "/search_artist_content", Some(params))
299 .await
300 {
301 Some(j) => j,
302 None => return LoadResult::Empty {},
303 };
304 let tracks: Vec<_> = tracks_json
305 .get("results")
306 .and_then(|v| v.as_array())
307 .map(|arr| {
308 arr.iter()
309 .filter_map(|item| self.parse_track(item))
310 .collect()
311 })
312 .unwrap_or_default();
313 if tracks.is_empty() {
314 return LoadResult::Empty {};
315 }
316 let url = results
317 .get("url")
318 .and_then(|v| v.as_str())
319 .map(|s| format!("https://audiomack.com{s}"))
320 .unwrap_or_else(|| format!("https://audiomack.com/{artist_slug}"));
321 LoadResult::Playlist(PlaylistData {
322 info: PlaylistInfo {
323 name: format!("{name}'s Top Tracks"),
324 selected_track: -1,
325 },
326 plugin_info: serde_json::json!({
327 "type": "artist",
328 "url": url,
329 "artworkUrl": results.get("image").and_then(|v| v.as_str()),
330 "author": name,
331 "totalTracks": tracks.len()
332 }),
333 tracks,
334 })
335 }
336 async fn get_artist_likes(&self, artist_slug: &str) -> LoadResult {
337 let json = match self
338 .make_request(
339 reqwest::Method::GET,
340 &format!("/artist/{artist_slug}"),
341 None,
342 )
343 .await
344 {
345 Some(j) => j,
346 None => return LoadResult::Empty {},
347 };
348 let results = match json.get("results") {
349 Some(r) => r,
350 None => return LoadResult::Empty {},
351 };
352 let name = results
353 .get("name")
354 .and_then(|v| v.as_str())
355 .unwrap_or("Artist")
356 .to_owned();
357 let likes_json = match self
358 .make_request(
359 reqwest::Method::GET,
360 &format!("/artist/{artist_slug}/favorites"),
361 None,
362 )
363 .await
364 {
365 Some(j) => j,
366 None => return LoadResult::Empty {},
367 };
368 let tracks: Vec<_> = likes_json
369 .get("results")
370 .and_then(|v| v.as_array())
371 .map(|arr| {
372 arr.iter()
373 .filter_map(|item| self.parse_track(item))
374 .collect()
375 })
376 .unwrap_or_default();
377 if tracks.is_empty() {
378 return LoadResult::Empty {};
379 }
380 LoadResult::Playlist(PlaylistData {
381 info: PlaylistInfo {
382 name: format!("{name}'s Liked Tracks"),
383 selected_track: -1,
384 },
385 plugin_info: serde_json::json!({}),
386 tracks,
387 })
388 }
389 }
390 #[async_trait]
391 impl SourcePlugin for AudiomackSource {
392 fn name(&self) -> &str {
393 "audiomack"
394 }
395 fn can_handle(&self, identifier: &str) -> bool {
396 self.search_prefixes()
397 .iter()
398 .any(|p| identifier.starts_with(p))
399 || SONG_REGEX
400 .get_or_init(|| Regex::new(r"https?://(?:www\.)?audiomack\.com/(?P<artist>[^/]+)/song/(?P<slug>[^/?#]+)").unwrap())
401 .is_match(identifier)
402 || ALBUM_REGEX
403 .get_or_init(|| Regex::new(r"https?://(?:www\.)?audiomack\.com/(?P<artist>[^/]+)/album/(?P<slug>[^/?#]+)").unwrap())
404 .is_match(identifier)
405 || PLAYLIST_REGEX
406 .get_or_init(|| Regex::new(r"https?://(?:www\.)?audiomack\.com/(?P<artist>[^/]+)/playlist/(?P<slug>[^/?#]+)").unwrap())
407 .is_match(identifier)
408 || ARTIST_REGEX
409 .get_or_init(|| Regex::new(r"https?://(?:www\.)?audiomack\.com/(?P<artist>[^/?#]+)(?:/songs)?/?$").unwrap())
410 .is_match(identifier)
411 || LIKES_REGEX
412 .get_or_init(|| Regex::new(r"https?://(?:www\.)?audiomack\.com/(?P<artist>[^/]+)/likes").unwrap())
413 .is_match(identifier)
414 }
415 fn search_prefixes(&self) -> Vec<&str> {
416 vec!["amksearch:"]
417 }
418 async fn load(
419 &self,
420 identifier: &str,
421 _routeplanner: Option<Arc<dyn crate::routeplanner::RoutePlanner>>,
422 ) -> LoadResult {
423 if let Some(prefix) = self
424 .search_prefixes()
425 .into_iter()
426 .find(|p| identifier.starts_with(p))
427 {
428 let query = identifier.strip_prefix(prefix).unwrap();
429 return self.search(query).await;
430 }
431 if let Some(caps) = SONG_REGEX.get().and_then(|r| r.captures(identifier)) {
432 let artist = caps.name("artist").map(|m| m.as_str()).unwrap_or("");
433 let slug = caps.name("slug").map(|m| m.as_str()).unwrap_or("");
434 return self.get_song(artist, slug).await;
435 }
436 if let Some(caps) = ALBUM_REGEX.get().and_then(|r| r.captures(identifier)) {
437 let artist = caps.name("artist").map(|m| m.as_str()).unwrap_or("");
438 let slug = caps.name("slug").map(|m| m.as_str()).unwrap_or("");
439 return self.get_playlist_items("album", artist, slug).await;
440 }
441 if let Some(caps) = PLAYLIST_REGEX.get().and_then(|r| r.captures(identifier)) {
442 let artist = caps.name("artist").map(|m| m.as_str()).unwrap_or("");
443 let slug = caps.name("slug").map(|m| m.as_str()).unwrap_or("");
444 return self.get_playlist_items("playlist", artist, slug).await;
445 }
446 if let Some(caps) = LIKES_REGEX.get().and_then(|r| r.captures(identifier)) {
447 let artist = caps.name("artist").map(|m| m.as_str()).unwrap_or("");
448 return self.get_artist_likes(artist).await;
449 }
450 if let Some(caps) = ARTIST_REGEX.get().and_then(|r| r.captures(identifier)) {
451 let artist = caps.name("artist").map(|m| m.as_str()).unwrap_or("");
452 return self.get_artist(artist).await;
453 }
454 LoadResult::Empty {}
455 }
456 async fn get_track(
457 &self,
458 identifier: &str,
459 routeplanner: Option<Arc<dyn crate::routeplanner::RoutePlanner>>,
460 ) -> Option<BoxedTrack> {
461 let mut track_id = identifier.to_owned();
462 if SONG_REGEX
463 .get()
464 .map(|r| r.is_match(identifier))
465 .unwrap_or(false)
466 {
467 if let LoadResult::Track(track) = self.load(identifier, None).await {
468 track_id = track.info.identifier;
469 } else {
470 return None;
471 }
472 }
473 let local_addr = routeplanner.and_then(|rp| rp.get_address());
474 let stream_url = super::track::fetch_stream_url(&self.client, &track_id).await;
475 if stream_url.is_none() {
476 warn!(
477 "Audiomack: no stream URL for track {}, falling back to mirrors",
478 track_id
479 );
480 return None;
481 }
482 Some(Arc::new(AudiomackTrack {
483 stream_url: stream_url.unwrap(),
484 local_addr,
485 }))
486 }
487 fn get_proxy_config(&self) -> Option<crate::config::HttpProxyConfig> {
488 None
489 }
490 }
491}
492pub mod track {
493 use crate::sources::{
494 audiomack::utils::build_auth_header,
495 http::HttpTrack,
496 playable_track::{PlayableTrack, ResolvedTrack},
497 };
498 use async_trait::async_trait;
499 use rand::{Rng, distributions::Alphanumeric, thread_rng};
500 use std::{collections::BTreeMap, net::IpAddr, sync::Arc};
501 use tracing::debug;
502 pub struct AudiomackTrack {
503 pub stream_url: String,
504 pub local_addr: Option<IpAddr>,
505 }
506 #[async_trait]
507 impl PlayableTrack for AudiomackTrack {
508 async fn resolve(&self) -> Result<ResolvedTrack, String> {
509 let url = self.stream_url.clone();
510 debug!("Reddit playback URL: {url}");
511 HttpTrack {
512 url,
513 local_addr: self.local_addr,
514 proxy: None,
515 }
516 .resolve()
517 .await
518 }
519 }
520 pub async fn fetch_stream_url(
521 client: &Arc<reqwest::Client>,
522 identifier: &str,
523 ) -> Option<String> {
524 let nonce = generate_nonce();
525 let timestamp = std::time::SystemTime::now()
526 .duration_since(std::time::UNIX_EPOCH)
527 .unwrap_or_default()
528 .as_secs()
529 .to_string();
530 let post_url = format!("https://api.audiomack.com/v1/music/{identifier}/play");
531 let mut body = BTreeMap::new();
532 body.insert("environment".to_owned(), "desktop-web".to_owned());
533 body.insert("session".to_owned(), "backend-session".to_owned());
534 body.insert("hq".to_owned(), "true".to_owned());
535 let auth_post = build_auth_header("POST", &post_url, &body, &nonce, ×tamp);
536 if let Ok(resp) = client
537 .post(&post_url)
538 .header("Authorization", auth_post)
539 .form(&body)
540 .send()
541 .await
542 && let Some(url) = parse_response(resp).await
543 {
544 return Some(url);
545 }
546 let get_url = format!("https://api.audiomack.com/v1/music/play/{identifier}");
547 let mut query = BTreeMap::new();
548 query.insert("environment".to_owned(), "desktop-web".to_owned());
549 query.insert("hq".to_owned(), "true".to_owned());
550 let auth_get = build_auth_header("GET", &get_url, &query, &nonce, ×tamp);
551 if let Ok(resp) = client
552 .get(&get_url)
553 .header("Authorization", auth_get)
554 .query(&query)
555 .send()
556 .await
557 && let Some(url) = parse_response(resp).await
558 {
559 return Some(url);
560 }
561 None
562 }
563 async fn parse_response(resp: reqwest::Response) -> Option<String> {
564 if !resp.status().is_success() {
565 return None;
566 }
567 let text = resp.text().await.ok()?;
568 let is_stream = |url: &str| {
569 url.contains("music.audiomack.com")
570 || url.ends_with(".m4a")
571 || url.ends_with(".mp3")
572 || url.contains(".m4a?")
573 || url.contains(".mp3?")
574 };
575 if text.starts_with("http") && is_stream(&text) {
576 return Some(text);
577 }
578 let json: serde_json::Value = serde_json::from_str(&text).ok()?;
579 if let Some(s) = json.as_str()
580 && is_stream(s)
581 {
582 return Some(s.to_owned());
583 }
584 let results = json.get("results").unwrap_or(&json);
585 let potential_url = results
586 .get("signedUrl")
587 .or_else(|| results.get("signed_url"))
588 .or_else(|| results.get("streamUrl"))
589 .or_else(|| results.get("stream_url"))
590 .or_else(|| results.get("url"))
591 .and_then(|v| v.as_str());
592 if let Some(url) = potential_url
593 && is_stream(url)
594 {
595 return Some(url.to_owned());
596 }
597 None
598 }
599 fn generate_nonce() -> String {
600 thread_rng()
601 .sample_iter(&Alphanumeric)
602 .take(32)
603 .map(char::from)
604 .collect()
605 }
606}
607pub mod utils {
608 use base64::{Engine as _, engine::general_purpose::STANDARD};
609 use hmac::{Hmac, Mac};
610 use sha1::Sha1;
611 use std::collections::BTreeMap;
612 const CONSUMER_KEY: &str = "audiomack-web";
613 const CONSUMER_SECRET: &str = "bd8a07e9f23fbe9d808646b730f89b8e";
614 type HmacSha1 = Hmac<Sha1>;
615 pub fn percent_encode(s: &str) -> String {
616 let mut out = String::with_capacity(s.len());
617 for b in s.as_bytes() {
618 match b {
619 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
620 out.push(*b as char)
621 }
622 _ => out.push_str(&format!("%{:02X}", b)),
623 }
624 }
625 out
626 }
627 pub fn build_auth_header(
628 method: &str,
629 url: &str,
630 params: &BTreeMap<String, String>,
631 nonce: &str,
632 timestamp: &str,
633 ) -> String {
634 let mut oauth_params = BTreeMap::new();
635 oauth_params.insert("oauth_consumer_key".to_owned(), CONSUMER_KEY.to_owned());
636 oauth_params.insert("oauth_nonce".to_owned(), nonce.to_owned());
637 oauth_params.insert("oauth_signature_method".to_owned(), "HMAC-SHA1".to_owned());
638 oauth_params.insert("oauth_timestamp".to_owned(), timestamp.to_owned());
639 oauth_params.insert("oauth_version".to_owned(), "1.0".to_owned());
640 let mut all_params = oauth_params.clone();
641 for (k, v) in params {
642 all_params.insert(percent_encode(k), percent_encode(v));
643 }
644 let param_string = all_params
645 .iter()
646 .map(|(k, v)| format!("{k}={v}"))
647 .collect::<Vec<_>>()
648 .join("&");
649 let base_string = format!(
650 "{}&{}&{}",
651 percent_encode(&method.to_uppercase()),
652 percent_encode(url),
653 percent_encode(¶m_string)
654 );
655 let signing_key = format!("{}&", percent_encode(CONSUMER_SECRET));
656 let mut mac =
657 HmacSha1::new_from_slice(signing_key.as_bytes()).expect("HMAC can take any key size");
658 mac.update(base_string.as_bytes());
659 let signature = STANDARD.encode(mac.finalize().into_bytes());
660 oauth_params.insert("oauth_signature".to_owned(), signature);
661 let header_parts: Vec<_> = oauth_params
662 .iter()
663 .map(|(k, v)| format!("{}=\"{}\"", percent_encode(k), percent_encode(v)))
664 .collect();
665 format!("OAuth {}", header_parts.join(", "))
666 }
667}
668pub use manager::AudiomackSource;