Skip to main content

lavende_core/sources/
audiomack.rs

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                &timestamp,
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, &timestamp);
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, &timestamp);
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(&param_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;