pub mod manager {
use super::{track::AudiomackTrack, utils::build_auth_header};
use crate::{
protocol::tracks::{LoadResult, PlaylistData, PlaylistInfo, Track, TrackInfo},
sources::{SourcePlugin, playable_track::BoxedTrack},
};
use async_trait::async_trait;
use rand::{Rng, distributions::Alphanumeric, thread_rng};
use regex::Regex;
use serde_json::Value;
use std::{
collections::BTreeMap,
sync::{Arc, OnceLock},
};
use tracing::{error, warn};
const API_BASE: &str = "https://api.audiomack.com/v1";
static SONG_REGEX: OnceLock<Regex> = OnceLock::new();
static ALBUM_REGEX: OnceLock<Regex> = OnceLock::new();
static PLAYLIST_REGEX: OnceLock<Regex> = OnceLock::new();
static ARTIST_REGEX: OnceLock<Regex> = OnceLock::new();
static LIKES_REGEX: OnceLock<Regex> = OnceLock::new();
pub struct AudiomackSource {
client: Arc<reqwest::Client>,
search_limit: usize,
}
impl AudiomackSource {
pub fn new(
config: Option<crate::config::AudiomackConfig>,
client: Arc<reqwest::Client>,
) -> Result<Self, String> {
let search_limit = config.map(|c| c.search_limit).unwrap_or(20);
Ok(Self {
client,
search_limit,
})
}
fn generate_nonce(&self) -> String {
thread_rng()
.sample_iter(&Alphanumeric)
.take(32)
.map(char::from)
.collect()
}
async fn make_request(
&self,
method: reqwest::Method,
endpoint: &str,
query_params: Option<BTreeMap<String, String>>,
) -> Option<Value> {
let url = format!("{API_BASE}{endpoint}");
tracing::debug!("Audiomack request: {method} {url} params: {query_params:?}");
let mut request_builder = self.base_request(self.client.request(method.clone(), &url));
let nonce = self.generate_nonce();
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
.to_string();
let auth_header = build_auth_header(
method.as_str(),
&url,
query_params.as_ref().unwrap_or(&BTreeMap::new()),
&nonce,
×tamp,
);
request_builder = request_builder.header("Authorization", auth_header);
if let Some(qp) = query_params {
request_builder = request_builder.query(&qp);
}
let resp = match request_builder.send().await {
Ok(r) => r,
Err(e) => {
error!("Audiomack request failed: {e}");
return None;
}
};
let status = resp.status();
let text = match resp.text().await {
Ok(t) => t,
Err(e) => {
error!("Failed to read Audiomack response text: {e}");
return None;
}
};
if !status.is_success() {
warn!("Audiomack API error status: {status} for endpoint: {endpoint}");
return None;
}
match serde_json::from_str(&text) {
Ok(v) => Some(v),
Err(e) => {
error!("Failed to parse Audiomack JSON: {e} body: {text}");
None
}
}
}
fn base_request(&self, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
builder
.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")
.header("Accept", "application/json")
.header("Accept-Language", "en-US,en;q=0.9")
.header("Origin", "https://audiomack.com")
.header("Referer", "https://audiomack.com/")
.header("Sec-Fetch-Site", "same-site")
.header("Sec-Fetch-Mode", "cors")
.header("Sec-Fetch-Dest", "empty")
.header("Priority", "u=1, i")
.header("DNT", "1")
.header("sec-ch-ua-platform", "\"Windows\"")
}
fn parse_track(&self, json: &Value) -> Option<Track> {
let id_val = json.get("id").or_else(|| json.get("song_id"));
let id = match id_val {
Some(Value::String(s)) => s.clone(),
Some(Value::Number(n)) => n.to_string(),
_ => {
tracing::debug!("Audiomack track missing id: {json:?}");
return None;
}
};
let title = json.get("title")?.as_str()?.to_owned();
let author = json.get("artist")?.as_str()?.to_owned();
let duration_sec = json
.get("duration")
.and_then(|v| {
v.as_u64()
.or_else(|| v.as_i64().map(|i| i as u64))
.or_else(|| v.as_str().and_then(|s| s.parse().ok()))
})
.unwrap_or_default();
let uploader_slug = json
.pointer("/uploader/url_slug")
.and_then(|v| v.as_str())
.or_else(|| json.get("uploader_url_slug").and_then(|v| v.as_str()))
.unwrap_or("unknown");
let url_slug = json.get("url_slug")?.as_str()?;
let uri = Some(format!(
"https://audiomack.com/{uploader_slug}/song/{url_slug}"
));
let artwork_url = json
.get("image")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(|s| s.to_owned());
let isrc = json
.get("isrc")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(|s| s.to_owned());
Some(Track::new(TrackInfo {
identifier: id,
is_seekable: true,
author,
length: duration_sec * 1000,
is_stream: false,
position: 0,
title,
uri,
artwork_url,
isrc,
source_name: "audiomack".to_owned(),
}))
}
async fn search(&self, query: &str) -> LoadResult {
let mut params = BTreeMap::new();
params.insert("q".to_owned(), query.to_owned());
params.insert("limit".to_owned(), self.search_limit.to_string());
params.insert("show".to_owned(), "songs".to_owned());
params.insert("sort".to_owned(), "popular".to_owned());
let json = match self
.make_request(reqwest::Method::GET, "/search", Some(params))
.await
{
Some(j) => j,
None => return LoadResult::Empty {},
};
let results = match json.get("results").and_then(|v| v.as_array()) {
Some(r) => r,
None => return LoadResult::Empty {},
};
let tracks: Vec<_> = results
.iter()
.filter_map(|item| self.parse_track(item))
.collect();
if tracks.is_empty() {
LoadResult::Empty {}
} else {
LoadResult::Search(tracks)
}
}
async fn get_song(&self, artist: &str, slug: &str) -> LoadResult {
let endpoint = format!("/music/song/{artist}/{slug}");
let json = match self
.make_request(reqwest::Method::GET, &endpoint, None)
.await
{
Some(j) => j,
None => return LoadResult::Empty {},
};
if let Some(track) = json.get("results").and_then(|v| self.parse_track(v)) {
LoadResult::Track(track)
} else {
LoadResult::Empty {}
}
}
async fn get_playlist_items(&self, type_: &str, artist: &str, slug: &str) -> LoadResult {
let endpoint = if type_ == "playlist" {
format!("/playlist/{artist}/{slug}")
} else {
format!("/music/album/{artist}/{slug}")
};
let json = match self
.make_request(reqwest::Method::GET, &endpoint, None)
.await
{
Some(j) => j,
None => return LoadResult::Empty {},
};
let results = match json.get("results") {
Some(r) => r,
None => return LoadResult::Empty {},
};
let name = results
.get("title")
.and_then(|v| v.as_str())
.unwrap_or("Unknown")
.to_owned();
let tracks: Vec<_> = results
.get("tracks")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|item| self.parse_track(item))
.collect()
})
.unwrap_or_default();
if tracks.is_empty() {
return LoadResult::Empty {};
}
let url = results
.get("url")
.and_then(|v| v.as_str())
.map(|s| format!("https://audiomack.com{s}"))
.unwrap_or_else(|| format!("https://audiomack.com/{artist}/{type_}/{slug}"));
LoadResult::Playlist(PlaylistData {
info: PlaylistInfo {
name,
selected_track: -1,
},
plugin_info: serde_json::json!({
"type": type_,
"url": url,
"artworkUrl": results.get("image").and_then(|v| v.as_str()),
"author": results.get("artist").and_then(|v| v.as_str()),
"totalTracks": tracks.len()
}),
tracks,
})
}
async fn get_artist(&self, artist_slug: &str) -> LoadResult {
let json = match self
.make_request(
reqwest::Method::GET,
&format!("/artist/{artist_slug}"),
None,
)
.await
{
Some(j) => j,
None => return LoadResult::Empty {},
};
let results = match json.get("results") {
Some(r) => r,
None => return LoadResult::Empty {},
};
let artist_id = results
.get("id")
.and_then(|v| {
v.as_str()
.map(|s| s.to_owned())
.or_else(|| v.as_i64().map(|i| i.to_string()))
})
.unwrap_or_default();
if artist_id.is_empty() {
return LoadResult::Empty {};
}
let name = results
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("Artist")
.to_owned();
let mut params = BTreeMap::new();
params.insert("artist_id".to_owned(), artist_id);
params.insert("limit".to_owned(), "100".to_owned());
params.insert("sort".to_owned(), "rank".to_owned());
params.insert("type".to_owned(), "songs".to_owned());
let tracks_json = match self
.make_request(reqwest::Method::GET, "/search_artist_content", Some(params))
.await
{
Some(j) => j,
None => return LoadResult::Empty {},
};
let tracks: Vec<_> = tracks_json
.get("results")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|item| self.parse_track(item))
.collect()
})
.unwrap_or_default();
if tracks.is_empty() {
return LoadResult::Empty {};
}
let url = results
.get("url")
.and_then(|v| v.as_str())
.map(|s| format!("https://audiomack.com{s}"))
.unwrap_or_else(|| format!("https://audiomack.com/{artist_slug}"));
LoadResult::Playlist(PlaylistData {
info: PlaylistInfo {
name: format!("{name}'s Top Tracks"),
selected_track: -1,
},
plugin_info: serde_json::json!({
"type": "artist",
"url": url,
"artworkUrl": results.get("image").and_then(|v| v.as_str()),
"author": name,
"totalTracks": tracks.len()
}),
tracks,
})
}
async fn get_artist_likes(&self, artist_slug: &str) -> LoadResult {
let json = match self
.make_request(
reqwest::Method::GET,
&format!("/artist/{artist_slug}"),
None,
)
.await
{
Some(j) => j,
None => return LoadResult::Empty {},
};
let results = match json.get("results") {
Some(r) => r,
None => return LoadResult::Empty {},
};
let name = results
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("Artist")
.to_owned();
let likes_json = match self
.make_request(
reqwest::Method::GET,
&format!("/artist/{artist_slug}/favorites"),
None,
)
.await
{
Some(j) => j,
None => return LoadResult::Empty {},
};
let tracks: Vec<_> = likes_json
.get("results")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|item| self.parse_track(item))
.collect()
})
.unwrap_or_default();
if tracks.is_empty() {
return LoadResult::Empty {};
}
LoadResult::Playlist(PlaylistData {
info: PlaylistInfo {
name: format!("{name}'s Liked Tracks"),
selected_track: -1,
},
plugin_info: serde_json::json!({}),
tracks,
})
}
}
#[async_trait]
impl SourcePlugin for AudiomackSource {
fn name(&self) -> &str {
"audiomack"
}
fn can_handle(&self, identifier: &str) -> bool {
self.search_prefixes()
.iter()
.any(|p| identifier.starts_with(p))
|| SONG_REGEX
.get_or_init(|| Regex::new(r"https?://(?:www\.)?audiomack\.com/(?P<artist>[^/]+)/song/(?P<slug>[^/?#]+)").unwrap())
.is_match(identifier)
|| ALBUM_REGEX
.get_or_init(|| Regex::new(r"https?://(?:www\.)?audiomack\.com/(?P<artist>[^/]+)/album/(?P<slug>[^/?#]+)").unwrap())
.is_match(identifier)
|| PLAYLIST_REGEX
.get_or_init(|| Regex::new(r"https?://(?:www\.)?audiomack\.com/(?P<artist>[^/]+)/playlist/(?P<slug>[^/?#]+)").unwrap())
.is_match(identifier)
|| ARTIST_REGEX
.get_or_init(|| Regex::new(r"https?://(?:www\.)?audiomack\.com/(?P<artist>[^/?#]+)(?:/songs)?/?$").unwrap())
.is_match(identifier)
|| LIKES_REGEX
.get_or_init(|| Regex::new(r"https?://(?:www\.)?audiomack\.com/(?P<artist>[^/]+)/likes").unwrap())
.is_match(identifier)
}
fn search_prefixes(&self) -> Vec<&str> {
vec!["amksearch:"]
}
async fn load(
&self,
identifier: &str,
_routeplanner: Option<Arc<dyn crate::routeplanner::RoutePlanner>>,
) -> LoadResult {
if let Some(prefix) = self
.search_prefixes()
.into_iter()
.find(|p| identifier.starts_with(p))
{
let query = identifier.strip_prefix(prefix).unwrap();
return self.search(query).await;
}
if let Some(caps) = SONG_REGEX.get().and_then(|r| r.captures(identifier)) {
let artist = caps.name("artist").map(|m| m.as_str()).unwrap_or("");
let slug = caps.name("slug").map(|m| m.as_str()).unwrap_or("");
return self.get_song(artist, slug).await;
}
if let Some(caps) = ALBUM_REGEX.get().and_then(|r| r.captures(identifier)) {
let artist = caps.name("artist").map(|m| m.as_str()).unwrap_or("");
let slug = caps.name("slug").map(|m| m.as_str()).unwrap_or("");
return self.get_playlist_items("album", artist, slug).await;
}
if let Some(caps) = PLAYLIST_REGEX.get().and_then(|r| r.captures(identifier)) {
let artist = caps.name("artist").map(|m| m.as_str()).unwrap_or("");
let slug = caps.name("slug").map(|m| m.as_str()).unwrap_or("");
return self.get_playlist_items("playlist", artist, slug).await;
}
if let Some(caps) = LIKES_REGEX.get().and_then(|r| r.captures(identifier)) {
let artist = caps.name("artist").map(|m| m.as_str()).unwrap_or("");
return self.get_artist_likes(artist).await;
}
if let Some(caps) = ARTIST_REGEX.get().and_then(|r| r.captures(identifier)) {
let artist = caps.name("artist").map(|m| m.as_str()).unwrap_or("");
return self.get_artist(artist).await;
}
LoadResult::Empty {}
}
async fn get_track(
&self,
identifier: &str,
routeplanner: Option<Arc<dyn crate::routeplanner::RoutePlanner>>,
) -> Option<BoxedTrack> {
let mut track_id = identifier.to_owned();
if SONG_REGEX
.get()
.map(|r| r.is_match(identifier))
.unwrap_or(false)
{
if let LoadResult::Track(track) = self.load(identifier, None).await {
track_id = track.info.identifier;
} else {
return None;
}
}
let local_addr = routeplanner.and_then(|rp| rp.get_address());
let stream_url = super::track::fetch_stream_url(&self.client, &track_id).await;
if stream_url.is_none() {
warn!(
"Audiomack: no stream URL for track {}, falling back to mirrors",
track_id
);
return None;
}
Some(Arc::new(AudiomackTrack {
stream_url: stream_url.unwrap(),
local_addr,
}))
}
fn get_proxy_config(&self) -> Option<crate::config::HttpProxyConfig> {
None
}
}
}
pub mod track {
use crate::sources::{
audiomack::utils::build_auth_header,
http::HttpTrack,
playable_track::{PlayableTrack, ResolvedTrack},
};
use async_trait::async_trait;
use rand::{Rng, distributions::Alphanumeric, thread_rng};
use std::{collections::BTreeMap, net::IpAddr, sync::Arc};
use tracing::debug;
pub struct AudiomackTrack {
pub stream_url: String,
pub local_addr: Option<IpAddr>,
}
#[async_trait]
impl PlayableTrack for AudiomackTrack {
async fn resolve(&self) -> Result<ResolvedTrack, String> {
let url = self.stream_url.clone();
debug!("Reddit playback URL: {url}");
HttpTrack {
url,
local_addr: self.local_addr,
proxy: None,
}
.resolve()
.await
}
}
pub async fn fetch_stream_url(
client: &Arc<reqwest::Client>,
identifier: &str,
) -> Option<String> {
let nonce = generate_nonce();
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
.to_string();
let post_url = format!("https://api.audiomack.com/v1/music/{identifier}/play");
let mut body = BTreeMap::new();
body.insert("environment".to_owned(), "desktop-web".to_owned());
body.insert("session".to_owned(), "backend-session".to_owned());
body.insert("hq".to_owned(), "true".to_owned());
let auth_post = build_auth_header("POST", &post_url, &body, &nonce, ×tamp);
if let Ok(resp) = client
.post(&post_url)
.header("Authorization", auth_post)
.form(&body)
.send()
.await
&& let Some(url) = parse_response(resp).await
{
return Some(url);
}
let get_url = format!("https://api.audiomack.com/v1/music/play/{identifier}");
let mut query = BTreeMap::new();
query.insert("environment".to_owned(), "desktop-web".to_owned());
query.insert("hq".to_owned(), "true".to_owned());
let auth_get = build_auth_header("GET", &get_url, &query, &nonce, ×tamp);
if let Ok(resp) = client
.get(&get_url)
.header("Authorization", auth_get)
.query(&query)
.send()
.await
&& let Some(url) = parse_response(resp).await
{
return Some(url);
}
None
}
async fn parse_response(resp: reqwest::Response) -> Option<String> {
if !resp.status().is_success() {
return None;
}
let text = resp.text().await.ok()?;
let is_stream = |url: &str| {
url.contains("music.audiomack.com")
|| url.ends_with(".m4a")
|| url.ends_with(".mp3")
|| url.contains(".m4a?")
|| url.contains(".mp3?")
};
if text.starts_with("http") && is_stream(&text) {
return Some(text);
}
let json: serde_json::Value = serde_json::from_str(&text).ok()?;
if let Some(s) = json.as_str()
&& is_stream(s)
{
return Some(s.to_owned());
}
let results = json.get("results").unwrap_or(&json);
let potential_url = results
.get("signedUrl")
.or_else(|| results.get("signed_url"))
.or_else(|| results.get("streamUrl"))
.or_else(|| results.get("stream_url"))
.or_else(|| results.get("url"))
.and_then(|v| v.as_str());
if let Some(url) = potential_url
&& is_stream(url)
{
return Some(url.to_owned());
}
None
}
fn generate_nonce() -> String {
thread_rng()
.sample_iter(&Alphanumeric)
.take(32)
.map(char::from)
.collect()
}
}
pub mod utils {
use base64::{Engine as _, engine::general_purpose::STANDARD};
use hmac::{Hmac, Mac};
use sha1::Sha1;
use std::collections::BTreeMap;
const CONSUMER_KEY: &str = "audiomack-web";
const CONSUMER_SECRET: &str = "bd8a07e9f23fbe9d808646b730f89b8e";
type HmacSha1 = Hmac<Sha1>;
pub fn percent_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.as_bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
out.push(*b as char)
}
_ => out.push_str(&format!("%{:02X}", b)),
}
}
out
}
pub fn build_auth_header(
method: &str,
url: &str,
params: &BTreeMap<String, String>,
nonce: &str,
timestamp: &str,
) -> String {
let mut oauth_params = BTreeMap::new();
oauth_params.insert("oauth_consumer_key".to_owned(), CONSUMER_KEY.to_owned());
oauth_params.insert("oauth_nonce".to_owned(), nonce.to_owned());
oauth_params.insert("oauth_signature_method".to_owned(), "HMAC-SHA1".to_owned());
oauth_params.insert("oauth_timestamp".to_owned(), timestamp.to_owned());
oauth_params.insert("oauth_version".to_owned(), "1.0".to_owned());
let mut all_params = oauth_params.clone();
for (k, v) in params {
all_params.insert(percent_encode(k), percent_encode(v));
}
let param_string = all_params
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect::<Vec<_>>()
.join("&");
let base_string = format!(
"{}&{}&{}",
percent_encode(&method.to_uppercase()),
percent_encode(url),
percent_encode(¶m_string)
);
let signing_key = format!("{}&", percent_encode(CONSUMER_SECRET));
let mut mac =
HmacSha1::new_from_slice(signing_key.as_bytes()).expect("HMAC can take any key size");
mac.update(base_string.as_bytes());
let signature = STANDARD.encode(mac.finalize().into_bytes());
oauth_params.insert("oauth_signature".to_owned(), signature);
let header_parts: Vec<_> = oauth_params
.iter()
.map(|(k, v)| format!("{}=\"{}\"", percent_encode(k), percent_encode(v)))
.collect();
format!("OAuth {}", header_parts.join(", "))
}
}
pub use manager::AudiomackSource;