pub mod api {
use super::validators::{is_invalid_album, is_invalid_playlist};
use serde_json::{Value, json};
use std::sync::Arc;
use tokio::sync::RwLock;
use tracing::{debug, warn};
const CONFIG_URL: &str = "https://music.amazon.com/config.json";
const API_BASE: &str = "https://eu.mesk.skill.music.a2z.com/api";
pub const EP_TRACK_INFO: &str = "cosmicTrack/displayCatalogTrack";
pub const EP_ALBUM_INFO: &str = "showCatalogAlbum";
pub const EP_ARTIST_INFO: &str = "explore/v1/showCatalogArtist";
pub const EP_PLAYLIST_INFO: &str = "showCatalogPlaylist";
pub const EP_COMMUNITY_PLAYLIST_INFO: &str = "showLibraryPlaylist";
pub const EP_TRACKS_SEARCH: &str = "searchCatalogTracks";
const USER_AGENT: &str = "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Mobile Safari/537.36";
pub struct AmazonMusicClient {
http: Arc<reqwest::Client>,
cached_config: RwLock<Option<Value>>,
}
impl AmazonMusicClient {
pub fn new(http: Arc<reqwest::Client>) -> Self {
Self {
http,
cached_config: RwLock::new(None),
}
}
pub async fn site_config(&self) -> Option<Value> {
{
let guard = self.cached_config.read().await;
if guard.is_some() {
return guard.clone();
}
}
let resp = match self
.http
.get(CONFIG_URL)
.header("accept", "*/*")
.header("accept-language", "en-US,en;q=0.9")
.header("origin", "https://music.amazon.com")
.header("referer", "https://music.amazon.com/")
.header("user-agent", USER_AGENT)
.send()
.await
{
Ok(r) => r,
Err(e) => {
warn!("Amazon Music: config fetch failed: {e}");
return None;
}
};
if !resp.status().is_success() {
warn!("Amazon Music: config fetch HTTP {}", resp.status());
return None;
}
let config: Value = match resp.json().await {
Ok(v) => v,
Err(e) => {
debug!("Amazon Music: config JSON parse error: {e}");
return None;
}
};
*self.cached_config.write().await = Some(config.clone());
Some(config)
}
pub fn build_amzn_headers(config: &Value, page_url: &str) -> Value {
let access_token = config["accessToken"].as_str().unwrap_or("");
let device_id = config["deviceId"].as_str().unwrap_or("");
let session_id = config["sessionId"].as_str().unwrap_or("");
let version = config["version"].as_str().unwrap_or("");
let csrf_token = config["csrf"]["token"].as_str().unwrap_or("");
let csrf_ts = config["csrf"]["ts"]
.as_str()
.map(|s| s.to_string())
.or_else(|| config["csrf"]["ts"].as_u64().map(|n| n.to_string()))
.unwrap_or_default();
let csrf_rnd = config["csrf"]["rnd"]
.as_str()
.map(|s| s.to_string())
.or_else(|| config["csrf"]["rnd"].as_u64().map(|n| n.to_string()))
.unwrap_or_default();
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis().to_string())
.unwrap_or_default();
let req_id = gen_request_id();
json!({
"x-amzn-authentication": serde_json::to_string(&json!({
"interface": "ClientAuthenticationInterface.v1_0.ClientTokenElement",
"accessToken": access_token
})).unwrap_or_default(),
"x-amzn-device-model": "WEBPLAYER",
"x-amzn-device-width": "1920",
"x-amzn-device-family": "WebPlayer",
"x-amzn-device-id": device_id,
"x-amzn-user-agent": USER_AGENT,
"x-amzn-session-id": session_id,
"x-amzn-device-height": "1080",
"x-amzn-request-id": req_id,
"x-amzn-device-language": "en_US",
"x-amzn-currency-of-preference": "USD",
"x-amzn-os-version": "1.0",
"x-amzn-application-version": version,
"x-amzn-device-time-zone": "Asia/Calcutta",
"x-amzn-timestamp": ts,
"x-amzn-csrf": serde_json::to_string(&json!({
"interface": "CSRFInterface.v1_0.CSRFHeaderElement",
"token": csrf_token,
"timestamp": csrf_ts,
"rndNonce": csrf_rnd
})).unwrap_or_default(),
"x-amzn-music-domain": "music.amazon.com",
"x-amzn-referer": "music.amazon.com",
"x-amzn-affiliate-tags": "",
"x-amzn-ref-marker": "",
"x-amzn-page-url": page_url,
"x-amzn-weblab-id-overrides": "",
"x-amzn-video-player-token": "",
"x-amzn-feature-flags": "hd-supported,uhd-supported",
"x-amzn-has-profile-id": "",
"x-amzn-age-band": ""
})
}
pub async fn post_endpoint(
&self,
path: &str,
mut body: Value,
page_url: &str,
) -> Option<Value> {
let config = self.site_config().await?;
let amzn_headers = Self::build_amzn_headers(&config, page_url);
body["headers"] =
Value::String(serde_json::to_string(&amzn_headers).unwrap_or_default());
let url = format!("{API_BASE}/{path}");
let resp = match self
.http
.post(&url)
.header("authority", "eu.mesk.skill.music.a2z.com")
.header("accept", "*/*")
.header("accept-language", "en-US,en;q=0.9")
.header("content-type", "text/plain;charset=UTF-8")
.header("origin", "https://music.amazon.com")
.header("referer", "https://music.amazon.com/")
.header(
"sec-ch-ua",
"\"Chromium\";v=\"125\", \"Not.A/Brand\";v=\"24\"",
)
.header("sec-ch-ua-mobile", "?1")
.header("sec-ch-ua-platform", "\"Android\"")
.header("sec-fetch-dest", "empty")
.header("sec-fetch-mode", "cors")
.header("sec-fetch-site", "cross-site")
.header("user-agent", USER_AGENT)
.body(serde_json::to_string(&body).unwrap_or_default())
.send()
.await
{
Ok(r) => r,
Err(e) => {
debug!("Amazon Music: POST {path} failed: {e}");
return None;
}
};
if !resp.status().is_success() {
warn!("Amazon Music: POST {path} HTTP {}", resp.status());
*self.cached_config.write().await = None;
return None;
}
match resp.json::<Value>().await {
Ok(v) => Some(v),
Err(e) => {
debug!("Amazon Music: POST {path} JSON parse error: {e}");
None
}
}
}
fn entity_body(id: &str) -> Value {
json!({
"id": id,
"userHash": serde_json::to_string(&json!({"level": "LIBRARY_MEMBER"})).unwrap_or_default()
})
}
pub async fn fetch_track(&self, id: &str) -> Option<Value> {
let page = format!(
"https://music.amazon.com/tracks/{}",
urlencoding::encode(id)
);
self.post_endpoint(EP_TRACK_INFO, Self::entity_body(id), &page)
.await
}
pub async fn fetch_album(&self, id: &str) -> Option<Value> {
let page = format!(
"https://music.amazon.com/albums/{}",
urlencoding::encode(id)
);
self.post_endpoint(EP_ALBUM_INFO, Self::entity_body(id), &page)
.await
}
pub async fn fetch_artist(&self, id: &str) -> Option<Value> {
let page = format!(
"https://music.amazon.com/artists/{}",
urlencoding::encode(id)
);
self.post_endpoint(EP_ARTIST_INFO, Self::entity_body(id), &page)
.await
}
pub async fn fetch_playlist(&self, id: &str) -> Option<Value> {
let page = format!(
"https://music.amazon.com/playlists/{}",
urlencoding::encode(id)
);
self.post_endpoint(EP_PLAYLIST_INFO, Self::entity_body(id), &page)
.await
}
pub async fn fetch_community_playlist(&self, id: &str) -> Option<Value> {
let page = format!(
"https://music.amazon.com/user-playlists/{}",
urlencoding::encode(id)
);
self.post_endpoint(EP_COMMUNITY_PLAYLIST_INFO, Self::entity_body(id), &page)
.await
}
pub async fn search_tracks(&self, query: &str) -> Option<Value> {
let page = format!(
"https://music.amazon.com/search/{}/songs",
urlencoding::encode(query)
);
let body = json!({
"keyword": query,
"userHash": serde_json::to_string(&json!({"level": "LIBRARY_MEMBER"})).unwrap_or_default()
});
self.post_endpoint(EP_TRACKS_SEARCH, body, &page).await
}
pub async fn fetch_album_multi_region(
&self,
id: &str,
domain_hint: Option<&str>,
) -> Option<Value> {
let config = self.site_config().await?;
super::region::fetch_multi_region(
&self.http,
id,
EP_ALBUM_INFO,
"albums",
"Album",
domain_hint,
is_invalid_album,
&config,
)
.await
}
pub async fn fetch_playlist_multi_region(
&self,
id: &str,
domain_hint: Option<&str>,
) -> Option<Value> {
let config = self.site_config().await?;
super::region::fetch_multi_region(
&self.http,
id,
EP_PLAYLIST_INFO,
"playlists",
"Playlist",
domain_hint,
is_invalid_playlist,
&config,
)
.await
}
}
pub fn gen_request_id() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let seed = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.subsec_nanos())
.unwrap_or(42);
let mut x = seed ^ (seed << 13);
x ^= x >> 17;
x ^= x << 5;
(0..13)
.map(|i| {
x ^= x.wrapping_add(i).wrapping_mul(1664525);
b"0123456789abcdefghijklmnopqrstuvwxyz"[(x as usize) % 36] as char
})
.collect()
}
pub fn duration_str_to_ms(s: &str) -> u64 {
let s = s.trim();
if s.is_empty() {
return 0;
}
let parts: Vec<u64> = s.split(':').filter_map(|p| p.trim().parse().ok()).collect();
let secs = match parts.as_slice() {
[h, m, s] => h * 3600 + m * 60 + s,
[first, second] => {
if *first >= 60 {
let h = first / 60;
let m = first % 60;
h * 3600 + m * 60 + second
} else {
first * 60 + second
}
}
[n] => *n,
_ => 0,
};
secs * 1000
}
pub fn clean_image_url(url: &str) -> String {
if url.is_empty() {
return url.to_owned();
}
if url.contains("_CLa%7C") {
return url
.replace("._AA", ".")
.replace("_US354", "_US1000")
.replace("CLa%7C354,354", "CLa%7C1000,1000")
.replace("0,0,354,354", "0,0,1000,1000")
.replace("0,0,177,177", "0,0,500,500")
.replace("177,0,177,177", "500,0,500,500")
.replace("0,177,177,177", "0,500,500,500")
.replace("177,177,177,177", "500,500,500,500");
}
if let Some(i_pos) = url.find("/I/") {
let after = &url[i_pos + 3..];
if let Some(dot_pos) = after.rfind('.') {
let ext = &after[dot_pos..];
let id_end = after.find(&['.', '_', '?'][..]).unwrap_or(after.len());
let id_part = &after[..id_end];
return format!("{}/I/{}{}", &url[..i_pos], id_part, ext);
}
}
url.to_owned()
}
pub fn clean_song_title(title: &str) -> String {
if let Some(stripped) = title.trim().strip_prefix(|c: char| c.is_ascii_digit()) {
let rest = stripped.trim_start_matches(|c: char| c.is_ascii_digit());
if let Some(clean) = rest.strip_prefix(". ") {
return clean.to_owned();
}
}
title.to_owned()
}
pub fn normalize_artist(raw: &str) -> String {
let parts: Vec<&str> = raw
.split(['&', ','])
.map(|p| p.trim())
.filter(|p| !p.is_empty())
.take(3)
.collect();
if parts.is_empty() {
return raw.trim().to_owned();
}
parts.join(", ")
}
}
pub mod crypt {
use aes::Aes128;
use ctr::Ctr128BE;
use tracing::{debug, warn};
type Aes128Ctr = Ctr128BE<Aes128>;
pub struct CencDecryptor {
key: [u8; 16],
}
impl CencDecryptor {
pub fn from_hex(hex_key: &str) -> Result<Self, String> {
let trimmed = hex_key.trim();
if trimmed.len() != 32 {
return Err(format!(
"decryption key must be 32 hex chars, got {}",
trimmed.len()
));
}
let mut key = [0u8; 16];
hex::decode_to_slice(trimmed, &mut key).map_err(|e| format!("invalid hex key: {e}"))?;
Ok(Self { key })
}
pub fn decrypt_buffer(&self, buf: &mut [u8]) -> Result<(), String> {
patch_moov_headers(buf)?;
if let Ok(fragments) = locate_fragments(buf) {
for frag in fragments {
let _ = self.decrypt_fragment(buf, &frag);
}
}
Ok(())
}
fn decrypt_fragment(&self, buf: &mut [u8], frag: &FragmentInfo) -> Result<(), String> {
let mdat_payload_start = frag.mdat_offset + 8;
let mdat_payload_end = frag.mdat_offset + frag.mdat_size;
if mdat_payload_end > buf.len() {
return Err("mdat extends past buffer".into());
}
if frag.sample_ivs.is_empty() {
debug!("fragment has no sample IVs, skipping decryption");
return Ok(());
}
let mut cursor = mdat_payload_start;
for (idx, sample) in frag.samples.iter().enumerate() {
let iv_bytes = frag
.sample_ivs
.get(idx)
.ok_or_else(|| format!("missing IV for sample {idx}"))?;
let sample_end = cursor + sample.size;
if sample_end > mdat_payload_end || sample_end > buf.len() {
warn!("sample {} exceeds mdat boundary, stopping", idx);
break;
}
let mut full_iv = [0u8; 16];
let copy_len = iv_bytes.len().min(16);
full_iv[..copy_len].copy_from_slice(&iv_bytes[..copy_len]);
if !sample.subsamples.is_empty() {
let mut pos = cursor;
for sub in &sample.subsamples {
pos += sub.clear as usize;
let enc_len = sub.encrypted as usize;
if pos + enc_len > sample_end {
break;
}
self.ctr_decrypt(&full_iv, &mut buf[pos..pos + enc_len]);
pos += enc_len;
}
} else {
self.ctr_decrypt(&full_iv, &mut buf[cursor..sample_end]);
}
cursor = sample_end;
}
Ok(())
}
fn ctr_decrypt(&self, iv: &[u8; 16], data: &mut [u8]) {
self.ctr_decrypt_with_offset(iv, data, 0);
}
fn ctr_decrypt_with_offset(&self, iv: &[u8; 16], data: &mut [u8], offset: usize) {
use ctr::cipher::{KeyIvInit, StreamCipher, StreamCipherSeek};
let mut cipher = Aes128Ctr::new(&self.key.into(), iv.into());
if offset > 0 {
cipher.seek(offset as u64);
}
cipher.apply_keystream(data);
}
pub fn ctr_decrypt_external(&self, iv_bytes: &[u8], data: &mut [u8], offset: usize) {
let mut full_iv = [0u8; 16];
let copy_len = iv_bytes.len().min(16);
full_iv[..copy_len].copy_from_slice(&iv_bytes[..copy_len]);
self.ctr_decrypt_with_offset(&full_iv, data, offset);
}
}
pub fn parse_moof_external(moof: &[u8]) -> (Vec<SampleEntry>, u8) {
parse_moof(moof)
}
pub fn extract_sample_ivs_external(
moof: &[u8],
iv_size_hint: u8,
count: usize,
) -> Vec<Vec<u8>> {
extract_sample_ivs(moof, iv_size_hint, count)
}
struct FragmentInfo {
mdat_offset: usize,
mdat_size: usize,
samples: Vec<SampleEntry>,
sample_ivs: Vec<Vec<u8>>,
}
#[derive(Debug, Clone)]
pub struct SampleEntry {
pub size: usize,
pub subsamples: Vec<SubsampleEntry>,
}
#[derive(Debug, Clone)]
pub struct SubsampleEntry {
pub clear: u32,
pub encrypted: u32,
}
fn locate_fragments(buf: &[u8]) -> Result<Vec<FragmentInfo>, String> {
let mut frags = Vec::new();
let mut pos = 0;
while pos + 8 <= buf.len() {
let box_size =
u32::from_be_bytes([buf[pos], buf[pos + 1], buf[pos + 2], buf[pos + 3]]) as usize;
let box_type = &buf[pos + 4..pos + 8];
if box_size < 8 || pos + box_size > buf.len() {
break;
}
if box_type == b"moof" {
let moof_end = pos + box_size;
let mdat_offset = moof_end;
if mdat_offset + 8 > buf.len() {
pos += box_size;
continue;
}
let mdat_size_raw = u32::from_be_bytes([
buf[mdat_offset],
buf[mdat_offset + 1],
buf[mdat_offset + 2],
buf[mdat_offset + 3],
]) as usize;
let mdat_type = &buf[mdat_offset + 4..mdat_offset + 8];
if mdat_type != b"mdat" || mdat_size_raw < 8 {
pos += box_size;
continue;
}
let (samples, per_sample_iv_size) = parse_moof(&buf[pos..moof_end]);
let ivs =
extract_sample_ivs(&buf[pos..moof_end], per_sample_iv_size, samples.len());
frags.push(FragmentInfo {
mdat_offset,
mdat_size: mdat_size_raw,
samples,
sample_ivs: ivs,
});
pos = mdat_offset + mdat_size_raw;
continue;
}
pos += box_size;
}
Ok(frags)
}
fn parse_moof(moof: &[u8]) -> (Vec<SampleEntry>, u8) {
let mut samples = Vec::new();
let mut default_sample_size: u32 = 0;
let mut per_sample_iv_size: u8 = 0;
let mut pos = 8;
while pos + 8 <= moof.len() {
let sz = u32::from_be_bytes([moof[pos], moof[pos + 1], moof[pos + 2], moof[pos + 3]])
as usize;
let typ = &moof[pos + 4..pos + 8];
if sz < 8 || pos + sz > moof.len() {
break;
}
if typ == b"traf" {
let (s, dsz, iv_sz) = parse_traf(&moof[pos..pos + sz]);
if !s.is_empty() {
samples = s;
}
if dsz > 0 {
default_sample_size = dsz;
}
if iv_sz > 0 {
per_sample_iv_size = iv_sz;
}
}
pos += sz;
}
if default_sample_size > 0 {
for s in &mut samples {
if s.size == 0 {
s.size = default_sample_size as usize;
}
}
}
(samples, per_sample_iv_size)
}
fn parse_traf(traf: &[u8]) -> (Vec<SampleEntry>, u32, u8) {
let mut samples = Vec::new();
let mut default_sample_size: u32 = 0;
let mut per_sample_iv_size: u8 = 0;
let mut pos = 8;
while pos + 8 <= traf.len() {
let sz = u32::from_be_bytes([traf[pos], traf[pos + 1], traf[pos + 2], traf[pos + 3]])
as usize;
let typ = &traf[pos + 4..pos + 8];
if sz < 8 || pos + sz > traf.len() {
break;
}
match typ {
b"tfhd" => {
default_sample_size = parse_tfhd_default_size(&traf[pos..pos + sz]);
}
b"trun" => {
samples = parse_trun(&traf[pos..pos + sz]);
}
b"senc" => {
let (ivs, subsubs) = parse_senc(&traf[pos..pos + sz], per_sample_iv_size);
for (i, (iv_data, subs)) in ivs.iter().zip(subsubs.iter()).enumerate() {
if i < samples.len() && !subs.is_empty() {
samples[i].subsamples = subs.clone();
}
let _ = iv_data;
}
}
b"sbgp" | b"sgpd" | b"saiz" | b"saio" => {}
_ => {}
}
pos += sz;
}
if per_sample_iv_size == 0 {
per_sample_iv_size = detect_iv_size_from_senc(traf, samples.len());
}
(samples, default_sample_size, per_sample_iv_size)
}
fn parse_tfhd_default_size(tfhd: &[u8]) -> u32 {
if tfhd.len() < 12 {
return 0;
}
let flags = u32::from_be_bytes([0, tfhd[9], tfhd[10], tfhd[11]]);
let mut off = 12;
off += 4;
if flags & 0x01 != 0 {
off += 8;
}
if flags & 0x02 != 0 {
off += 4;
}
if flags & 0x08 != 0 {
off += 4;
}
if flags & 0x10 != 0 && off + 4 <= tfhd.len() {
return u32::from_be_bytes([tfhd[off], tfhd[off + 1], tfhd[off + 2], tfhd[off + 3]]);
}
0
}
fn parse_trun(trun: &[u8]) -> Vec<SampleEntry> {
if trun.len() < 12 {
return Vec::new();
}
let flags = u32::from_be_bytes([0, trun[9], trun[10], trun[11]]);
let sample_count = u32::from_be_bytes([trun[12], trun[13], trun[14], trun[15]]) as usize;
let mut off = 16;
if flags & 0x01 != 0 {
off += 4;
}
if flags & 0x04 != 0 {
off += 4;
}
let has_duration = flags & 0x100 != 0;
let has_size = flags & 0x200 != 0;
let has_flags = flags & 0x400 != 0;
let has_cts = flags & 0x800 != 0;
let mut samples = Vec::with_capacity(sample_count);
for _ in 0..sample_count {
if has_duration {
off += 4;
}
let size = if has_size && off + 4 <= trun.len() {
let s =
u32::from_be_bytes([trun[off], trun[off + 1], trun[off + 2], trun[off + 3]]);
off += 4;
s as usize
} else {
if has_size {
off += 4;
}
0
};
if has_flags {
off += 4;
}
if has_cts {
off += 4;
}
samples.push(SampleEntry {
size,
subsamples: Vec::new(),
});
}
samples
}
fn parse_senc(senc: &[u8], iv_size_hint: u8) -> (Vec<Vec<u8>>, Vec<Vec<SubsampleEntry>>) {
if senc.len() < 12 {
return (Vec::new(), Vec::new());
}
let flags = u32::from_be_bytes([0, senc[9], senc[10], senc[11]]);
let sample_count = u32::from_be_bytes([senc[12], senc[13], senc[14], senc[15]]) as usize;
let has_subsamples = flags & 0x02 != 0;
let iv_size = if iv_size_hint > 0 {
iv_size_hint as usize
} else {
8
};
let mut off = 16;
let mut ivs = Vec::with_capacity(sample_count);
let mut all_subs = Vec::with_capacity(sample_count);
for _ in 0..sample_count {
if off + iv_size > senc.len() {
break;
}
ivs.push(senc[off..off + iv_size].to_vec());
off += iv_size;
let mut subs = Vec::new();
if has_subsamples {
if off + 2 > senc.len() {
break;
}
let sub_count = u16::from_be_bytes([senc[off], senc[off + 1]]) as usize;
off += 2;
for _ in 0..sub_count {
if off + 6 > senc.len() {
break;
}
let clear = u16::from_be_bytes([senc[off], senc[off + 1]]) as u32;
let encrypted = u32::from_be_bytes([
senc[off + 2],
senc[off + 3],
senc[off + 4],
senc[off + 5],
]);
off += 6;
subs.push(SubsampleEntry { clear, encrypted });
}
}
all_subs.push(subs);
}
(ivs, all_subs)
}
fn detect_iv_size_from_senc(traf: &[u8], sample_count: usize) -> u8 {
if sample_count == 0 {
return 8;
}
let mut pos = 8;
while pos + 8 <= traf.len() {
let sz = u32::from_be_bytes([traf[pos], traf[pos + 1], traf[pos + 2], traf[pos + 3]])
as usize;
let typ = &traf[pos + 4..pos + 8];
if sz < 8 || pos + sz > traf.len() {
break;
}
if typ == b"senc" && sz >= 16 {
let flags = u32::from_be_bytes([0, traf[pos + 9], traf[pos + 10], traf[pos + 11]]);
let payload_after_header = sz - 16;
let has_sub = flags & 0x02 != 0;
if !has_sub && sample_count > 0 {
let candidate = payload_after_header / sample_count;
if candidate == 8 || candidate == 16 {
return candidate as u8;
}
}
}
pos += sz;
}
8
}
fn extract_sample_ivs(moof: &[u8], iv_size_hint: u8, sample_count: usize) -> Vec<Vec<u8>> {
let mut pos = 8;
while pos + 8 <= moof.len() {
let sz = u32::from_be_bytes([moof[pos], moof[pos + 1], moof[pos + 2], moof[pos + 3]])
as usize;
let typ = &moof[pos + 4..pos + 8];
if sz < 8 || pos + sz > moof.len() {
break;
}
if typ == b"traf" {
return extract_senc_ivs(&moof[pos..pos + sz], iv_size_hint, sample_count);
}
pos += sz;
}
Vec::new()
}
fn extract_senc_ivs(traf: &[u8], iv_size_hint: u8, _sample_count: usize) -> Vec<Vec<u8>> {
let mut pos = 8;
while pos + 8 <= traf.len() {
let sz = u32::from_be_bytes([traf[pos], traf[pos + 1], traf[pos + 2], traf[pos + 3]])
as usize;
let typ = &traf[pos + 4..pos + 8];
if sz < 8 || pos + sz > traf.len() {
break;
}
if typ == b"senc" {
let (ivs, _) = parse_senc(&traf[pos..pos + sz], iv_size_hint);
return ivs;
}
pos += sz;
}
Vec::new()
}
pub fn patch_moov_headers(buf: &mut [u8]) -> Result<(), String> {
let mut pos = 0;
while pos + 8 <= buf.len() {
let box_size =
u32::from_be_bytes([buf[pos], buf[pos + 1], buf[pos + 2], buf[pos + 3]]) as usize;
let box_type = &buf[pos + 4..pos + 8];
if box_size < 8 || pos + box_size > buf.len() {
break;
}
if box_type == b"moov" {
patch_moov_box(&mut buf[pos..pos + box_size]);
return Ok(());
}
pos += box_size;
}
Ok(())
}
fn patch_moov_box(moov: &mut [u8]) {
let mut pos = 8;
while pos + 8 <= moov.len() {
let sz = u32::from_be_bytes([moov[pos], moov[pos + 1], moov[pos + 2], moov[pos + 3]])
as usize;
if sz < 8 || pos + sz > moov.len() {
break;
}
let typ = [moov[pos + 4], moov[pos + 5], moov[pos + 6], moov[pos + 7]];
if &typ == b"trak" {
patch_trak(&mut moov[pos..pos + sz]);
}
pos += sz;
}
}
fn patch_trak(trak: &mut [u8]) {
let mut pos = 8;
while pos + 8 <= trak.len() {
let sz = u32::from_be_bytes([trak[pos], trak[pos + 1], trak[pos + 2], trak[pos + 3]])
as usize;
if sz < 8 || pos + sz > trak.len() {
break;
}
let typ = [trak[pos + 4], trak[pos + 5], trak[pos + 6], trak[pos + 7]];
if &typ == b"mdia" {
patch_mdia(&mut trak[pos..pos + sz]);
}
pos += sz;
}
}
fn patch_mdia(mdia: &mut [u8]) {
let mut pos = 8;
while pos + 8 <= mdia.len() {
let sz = u32::from_be_bytes([mdia[pos], mdia[pos + 1], mdia[pos + 2], mdia[pos + 3]])
as usize;
if sz < 8 || pos + sz > mdia.len() {
break;
}
let typ = [mdia[pos + 4], mdia[pos + 5], mdia[pos + 6], mdia[pos + 7]];
if &typ == b"minf" {
patch_minf(&mut mdia[pos..pos + sz]);
}
pos += sz;
}
}
fn patch_minf(minf: &mut [u8]) {
let mut pos = 8;
while pos + 8 <= minf.len() {
let sz = u32::from_be_bytes([minf[pos], minf[pos + 1], minf[pos + 2], minf[pos + 3]])
as usize;
if sz < 8 || pos + sz > minf.len() {
break;
}
let typ = [minf[pos + 4], minf[pos + 5], minf[pos + 6], minf[pos + 7]];
if &typ == b"stbl" {
patch_stbl(&mut minf[pos..pos + sz]);
}
pos += sz;
}
}
fn patch_stbl(stbl: &mut [u8]) {
let mut pos = 8;
while pos + 8 <= stbl.len() {
let sz = u32::from_be_bytes([stbl[pos], stbl[pos + 1], stbl[pos + 2], stbl[pos + 3]])
as usize;
if sz < 8 || pos + sz > stbl.len() {
break;
}
let typ = [stbl[pos + 4], stbl[pos + 5], stbl[pos + 6], stbl[pos + 7]];
if &typ == b"stsd" {
patch_stsd(&mut stbl[pos..pos + sz]);
}
pos += sz;
}
}
fn patch_stsd(stsd: &mut [u8]) {
if stsd.len() < 16 {
return;
}
stsd[12] = 0;
stsd[13] = 0;
stsd[14] = 0;
stsd[15] = 1;
let mut entry_pos = 16;
while entry_pos + 8 <= stsd.len() {
let entry_sz = u32::from_be_bytes([
stsd[entry_pos],
stsd[entry_pos + 1],
stsd[entry_pos + 2],
stsd[entry_pos + 3],
]) as usize;
if entry_sz < 8 || entry_pos + entry_sz > stsd.len() {
break;
}
let codec_tag = [
stsd[entry_pos + 4],
stsd[entry_pos + 5],
stsd[entry_pos + 6],
stsd[entry_pos + 7],
];
if &codec_tag == b"enca" {
let original = find_original_codec(&stsd[entry_pos..entry_pos + entry_sz]);
let replacement = original.unwrap_or(*b"mp4a");
debug!(
"patching stsd entry: enca -> {}",
std::str::from_utf8(&replacement).unwrap_or("????")
);
stsd[entry_pos + 4] = replacement[0];
stsd[entry_pos + 5] = replacement[1];
stsd[entry_pos + 6] = replacement[2];
stsd[entry_pos + 7] = replacement[3];
neutralize_sinf(&mut stsd[entry_pos..entry_pos + entry_sz]);
}
entry_pos += entry_sz;
}
}
fn find_original_codec(entry: &[u8]) -> Option<[u8; 4]> {
let mut pos = 36;
while pos + 8 <= entry.len() {
let sz =
u32::from_be_bytes([entry[pos], entry[pos + 1], entry[pos + 2], entry[pos + 3]])
as usize;
let typ = &entry[pos + 4..pos + 8];
if sz < 8 || pos + sz > entry.len() {
break;
}
if typ == b"sinf" {
return find_frma(&entry[pos..pos + sz]);
}
pos += sz;
}
None
}
fn find_frma(sinf: &[u8]) -> Option<[u8; 4]> {
let mut pos = 8;
while pos + 8 <= sinf.len() {
let sz = u32::from_be_bytes([sinf[pos], sinf[pos + 1], sinf[pos + 2], sinf[pos + 3]])
as usize;
let typ = &sinf[pos + 4..sinf.len().min(pos + 8)];
if sz < 8 || pos + sz > sinf.len() {
break;
}
if typ == b"frma" && sz >= 12 {
return Some([sinf[pos + 8], sinf[pos + 9], sinf[pos + 10], sinf[pos + 11]]);
}
pos += sz;
}
None
}
fn neutralize_sinf(entry: &mut [u8]) {
let mut pos = 36;
while pos + 8 <= entry.len() {
let sz =
u32::from_be_bytes([entry[pos], entry[pos + 1], entry[pos + 2], entry[pos + 3]])
as usize;
if sz < 8 || pos + sz > entry.len() {
break;
}
if &entry[pos + 4..pos + 8] == b"sinf" {
entry[pos + 4] = b'f';
entry[pos + 5] = b'r';
entry[pos + 6] = b'e';
entry[pos + 7] = b'e';
debug!("neutralized sinf box at offset {pos}");
}
pos += sz;
}
}
pub fn extract_flac_stream_header(moov: &[u8]) -> Option<Vec<u8>> {
let dfla = find_dfla_in_moov(moov)?;
if dfla.len() < 12 {
return None;
}
let metadata_blocks = &dfla[12..];
if metadata_blocks.is_empty() {
return None;
}
if metadata_blocks[0] & 0x7F != 0 {
warn!(
"Amazon FLAC: unexpected first metadata block type {}",
metadata_blocks[0] & 0x7F
);
return None;
}
let mut out = Vec::with_capacity(4 + metadata_blocks.len());
out.extend_from_slice(b"fLaC");
out.extend_from_slice(metadata_blocks);
ensure_last_metadata_block_flag(&mut out[4..]);
Some(out)
}
fn ensure_last_metadata_block_flag(metadata_blocks: &mut [u8]) {
let mut block_starts: Vec<usize> = Vec::new();
let mut pos = 0;
while pos + 4 <= metadata_blocks.len() {
let length = u32::from_be_bytes([
0,
metadata_blocks[pos + 1],
metadata_blocks[pos + 2],
metadata_blocks[pos + 3],
]) as usize;
if pos + 4 + length > metadata_blocks.len() {
break;
}
block_starts.push(pos);
pos += 4 + length;
}
if block_starts.is_empty() {
return;
}
for &start in &block_starts {
metadata_blocks[start] &= 0x7F;
}
let last = *block_starts.last().unwrap();
metadata_blocks[last] |= 0x80;
}
fn find_dfla_in_moov(moov: &[u8]) -> Option<&[u8]> {
let trak = find_child(moov, b"trak")?;
let mdia = find_child(trak, b"mdia")?;
let minf = find_child(mdia, b"minf")?;
let stbl = find_child(minf, b"stbl")?;
let stsd = find_child(stbl, b"stsd")?;
if stsd.len() < 16 {
return None;
}
let audio_entry = {
let entries = &stsd[16..];
let mut pos = 0;
loop {
if pos + 8 > entries.len() {
return None;
}
let sz = u32::from_be_bytes([
entries[pos],
entries[pos + 1],
entries[pos + 2],
entries[pos + 3],
]) as usize;
if sz < 8 || pos + sz > entries.len() {
return None;
}
let tag = &entries[pos + 4..pos + 8];
if tag == b"enca" || tag == b"fLaC" {
break &entries[pos..pos + sz];
}
pos += sz;
}
};
if audio_entry.len() < 36 {
return None;
}
find_box_in(&audio_entry[36..], b"dfLa")
}
fn find_child<'a>(parent: &'a [u8], target: &[u8; 4]) -> Option<&'a [u8]> {
if parent.len() < 8 {
return None;
}
find_box_in(&parent[8..], target)
}
fn find_box_in<'a>(data: &'a [u8], target: &[u8; 4]) -> Option<&'a [u8]> {
let mut pos = 0;
while pos + 8 <= data.len() {
let sz = u32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]])
as usize;
if sz < 8 || pos + sz > data.len() {
break;
}
if &data[pos + 4..pos + 8] == target {
return Some(&data[pos..pos + sz]);
}
pos += sz;
}
None
}
}
pub mod direct {
use super::streaming_reader::AmazonStreamingReader;
use crate::{
audio::source::create_client,
config::HttpProxyConfig,
sources::playable_track::{PlayableTrack, ResolvedTrack},
};
use async_trait::async_trait;
use std::net::IpAddr;
use tracing::debug;
pub const UA: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) \
AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36";
pub struct AmazonMusicTrack {
pub track_id: String,
pub stream_url: String,
pub decryption_key: String,
pub local_addr: Option<IpAddr>,
pub proxy: Option<HttpProxyConfig>,
}
#[async_trait]
impl PlayableTrack for AmazonMusicTrack {
fn supports_seek(&self) -> bool {
true
}
async fn resolve(&self) -> Result<ResolvedTrack, String> {
debug!(
"Amazon Music: opening streaming reader for {}",
self.track_id
);
let stream_client =
create_client(UA.to_owned(), self.local_addr, self.proxy.clone(), None)
.map_err(|e| format!("failed to create client: {e}"))?;
let content_len = probe_content_length(&stream_client, &self.stream_url)
.await
.map_err(|e| format!("failed to probe stream length: {e}"))?;
let streaming_reader = AmazonStreamingReader::new(
stream_client,
&self.stream_url,
&self.decryption_key,
content_len,
)
.map_err(|e| format!("failed to initialize streaming reader: {e}"))?;
let reader = Box::new(streaming_reader) as Box<dyn symphonia::core::io::MediaSource>;
Ok(ResolvedTrack::new(reader, None))
}
}
async fn probe_content_length(client: &reqwest::Client, url: &str) -> Result<u64, String> {
let head = client
.head(url)
.header("User-Agent", UA)
.send()
.await
.map_err(|e| format!("HEAD request failed: {e}"))?;
if let Some(len) = head.content_length() {
return Ok(len);
}
let range_resp = client
.get(url)
.header("User-Agent", UA)
.header("Range", "bytes=0-0")
.send()
.await
.map_err(|e| format!("range probe failed: {e}"))?;
range_resp
.headers()
.get(reqwest::header::CONTENT_RANGE)
.and_then(|v| v.to_str().ok())
.and_then(|s| s.split('/').next_back())
.and_then(|s| s.parse::<u64>().ok())
.ok_or_else(|| "could not determine stream length".to_string())
}
}
pub mod manager {
use super::{
api::AmazonMusicClient,
direct::AmazonMusicTrack,
parsers::{
parse_album_tracks, parse_artist_top_songs, parse_community_playlist_tracks,
parse_playlist_tracks, parse_search_tracks, parse_track,
},
validators::{
is_invalid_album, is_invalid_artist, is_invalid_community_playlist,
is_invalid_playlist, is_invalid_track,
},
};
use crate::{
config::AmazonMusicConfig,
protocol::tracks::{LoadError, LoadResult, PlaylistData, PlaylistInfo, Track},
sources::{SourcePlugin, playable_track::BoxedTrack},
};
use async_trait::async_trait;
use regex::Regex;
use serde_json::json;
use std::{net::IpAddr, sync::Arc};
use tracing::debug;
#[derive(serde::Deserialize)]
struct StreamApiResponse {
#[serde(rename = "streamUrl")]
stream_url: String,
#[serde(rename = "decryptionKey")]
decryption_key: String,
}
const TRACK_RE: &str =
r"(?i)^https?://(?:www\.)?music\.amazon\.[a-z.]+/tracks/([A-Z0-9]{10,20})";
const ALBUM_RE: &str =
r"(?i)^https?://(?:www\.)?music\.amazon\.[a-z.]+/albums/([A-Z0-9]{10,20})";
const ARTIST_RE: &str =
r"(?i)^https?://(?:www\.)?music\.amazon\.[a-z.]+/artists/([A-Z0-9]{10,20})";
const PLAYLIST_RE: &str =
r"(?i)^https?://(?:www\.)?music\.amazon\.[a-z.]+/playlists/([A-Z0-9]{10,20})";
const USER_PLAYLIST_RE: &str =
r"(?i)^https?://(?:www\.)?music\.amazon\.[a-z.]+/user-playlists/([a-zA-Z0-9]+)";
const DOMAIN_RE: &str = r"(?i)^https?://(?:www\.)?music\.amazon\.";
pub struct AmazonMusicSource {
client: Arc<AmazonMusicClient>,
http: Arc<reqwest::Client>,
search_limit: usize,
proxy: Option<crate::config::HttpProxyConfig>,
api_url: Option<String>,
local_addr: Option<IpAddr>,
track_re: Regex,
album_re: Regex,
artist_re: Regex,
playlist_re: Regex,
user_playlist_re: Regex,
domain_re: Regex,
}
impl AmazonMusicSource {
pub fn new(config: AmazonMusicConfig, http: Arc<reqwest::Client>) -> Result<Self, String> {
Ok(Self {
client: Arc::new(AmazonMusicClient::new(Arc::clone(&http))),
http,
search_limit: config.search_limit.min(5),
proxy: config.proxy,
api_url: config.api_url,
local_addr: None,
track_re: Regex::new(TRACK_RE).map_err(|e| e.to_string())?,
album_re: Regex::new(ALBUM_RE).map_err(|e| e.to_string())?,
artist_re: Regex::new(ARTIST_RE).map_err(|e| e.to_string())?,
playlist_re: Regex::new(PLAYLIST_RE).map_err(|e| e.to_string())?,
user_playlist_re: Regex::new(USER_PLAYLIST_RE).map_err(|e| e.to_string())?,
domain_re: Regex::new(DOMAIN_RE).map_err(|e| e.to_string())?,
})
}
fn capture_id(&self, re: &Regex, url: &str) -> Option<String> {
re.captures(url)
.and_then(|c| c.get(1))
.map(|m| m.as_str().to_string())
}
async fn load_track(&self, url: &str) -> LoadResult {
let id = match self.capture_id(&self.track_re, url) {
Some(id) => id,
None => return LoadResult::Empty {},
};
let resp = match self.client.fetch_track(&id).await {
Some(v) => v,
None => {
return LoadResult::Error(LoadError {
message: Some(format!("Amazon Music: failed to fetch track '{id}'")),
severity: crate::common::Severity::Suspicious,
cause: "API request failed".to_string(),
cause_stack_trace: None,
});
}
};
if is_invalid_track(&resp) {
debug!("Amazon Music: track '{id}' not found or no longer available");
return LoadResult::Empty {};
}
match parse_track(&resp, &id) {
Some(info) => LoadResult::Track(Track::new(info)),
None => {
debug!("Amazon Music: failed to parse track '{id}'");
LoadResult::Empty {}
}
}
}
async fn load_album(&self, url: &str) -> LoadResult {
let album_id = match self.capture_id(&self.album_re, url) {
Some(id) => id,
None => return LoadResult::Empty {},
};
let domain_hint = super::region::extract_domain(url);
let resp = match self
.client
.fetch_album_multi_region(&album_id, domain_hint.as_deref())
.await
{
Some(v) => v,
None => return LoadResult::Empty {},
};
if is_invalid_album(&resp) {
debug!("Amazon Music: album '{album_id}' not found");
return LoadResult::Empty {};
}
let (album_name, artist_name, track_infos) = match parse_album_tracks(&resp, &album_id)
{
Some(r) => r,
None => return LoadResult::Empty {},
};
let artwork = resp["methods"][0]["template"]["headerImage"]
.as_str()
.filter(|s| !s.is_empty())
.map(super::api::clean_image_url);
let tracks: Vec<Track> = track_infos.into_iter().map(Track::new).collect();
if tracks.is_empty() {
return LoadResult::Empty {};
}
LoadResult::Playlist(PlaylistData {
info: PlaylistInfo {
name: album_name.clone(),
selected_track: -1,
},
plugin_info: json!({
"type": "album",
"url": format!("https://music.amazon.com/albums/{album_id}"),
"artworkUrl": artwork,
"author": artist_name,
"totalTracks": tracks.len()
}),
tracks,
})
}
async fn load_artist(&self, url: &str) -> LoadResult {
let artist_id = match self.capture_id(&self.artist_re, url) {
Some(id) => id,
None => return LoadResult::Empty {},
};
let resp = match self.client.fetch_artist(&artist_id).await {
Some(v) => v,
None => return LoadResult::Empty {},
};
if is_invalid_artist(&resp) {
debug!("Amazon Music: artist '{artist_id}' not found");
return LoadResult::Empty {};
}
let unique_album_ids: Vec<String> = {
let widgets = match resp["methods"][0]["template"]["widgets"].as_array() {
Some(w) => w,
None => return LoadResult::Empty {},
};
let top_songs = match widgets.iter().find(|w| {
w["header"]
.as_str()
.map(|h| h.to_lowercase().contains("top songs"))
.unwrap_or(false)
}) {
Some(w) => w,
None => return LoadResult::Empty {},
};
let mut seen = std::collections::HashSet::new();
top_songs["items"]
.as_array()
.map(|items| {
items
.iter()
.filter_map(|item| {
let key = item["iconButton"]["observer"]["storageKey"].as_str()?;
let album_id = key.split(':').next()?.to_string();
if !album_id.is_empty() && seen.insert(album_id.clone()) {
Some(album_id)
} else {
None
}
})
.collect()
})
.unwrap_or_default()
};
let fetch_futures: Vec<_> = unique_album_ids
.iter()
.map(|album_id| self.client.fetch_album(album_id))
.collect();
let album_responses = futures::future::join_all(fetch_futures).await;
let mut duration_map: std::collections::HashMap<String, u64> =
std::collections::HashMap::new();
for (album_id, album_resp) in unique_album_ids.iter().zip(album_responses) {
let album_resp = match album_resp {
Some(v) => v,
None => continue,
};
let album_items =
match album_resp["methods"][0]["template"]["widgets"][0]["items"].as_array() {
Some(i) => i.clone(),
None => continue,
};
for track in &album_items {
let deeplink = match track["primaryTextLink"]["deeplink"].as_str() {
Some(dl) => dl,
None => continue,
};
let track_id = match deeplink.split("/tracks/").nth(1) {
Some(id) => id.split('/').next().unwrap_or("").to_string(),
None => continue,
};
if track_id.is_empty() {
continue;
}
let duration_ms = super::api::duration_str_to_ms(
track["secondaryText3"].as_str().unwrap_or(""),
);
duration_map.insert(format!("{album_id}:{track_id}"), duration_ms);
}
}
let result = match parse_artist_top_songs(&resp, &artist_id, &duration_map) {
Some(r) => r,
None => return LoadResult::Empty {},
};
let tracks: Vec<Track> = result.tracks.into_iter().map(Track::new).collect();
if tracks.is_empty() {
return LoadResult::Empty {};
}
LoadResult::Playlist(PlaylistData {
info: PlaylistInfo {
name: format!("{}'s Top Songs", result.name),
selected_track: -1,
},
plugin_info: json!({
"type": "artist",
"url": format!("https://music.amazon.com/artists/{artist_id}"),
"artworkUrl": result.artwork_url,
"author": result.name,
"totalTracks": tracks.len()
}),
tracks,
})
}
async fn load_playlist(&self, url: &str) -> LoadResult {
let playlist_id = match self.capture_id(&self.playlist_re, url) {
Some(id) => id,
None => return LoadResult::Empty {},
};
let domain_hint = super::region::extract_domain(url);
let resp = match self
.client
.fetch_playlist_multi_region(&playlist_id, domain_hint.as_deref())
.await
{
Some(v) => v,
None => return LoadResult::Empty {},
};
if is_invalid_playlist(&resp) {
debug!("Amazon Music: playlist '{playlist_id}' not found/unavailable");
return LoadResult::Empty {};
}
let result = match parse_playlist_tracks(&resp) {
Some(r) => r,
None => return LoadResult::Empty {},
};
let tracks: Vec<Track> = result.tracks.into_iter().map(Track::new).collect();
if tracks.is_empty() {
return LoadResult::Empty {};
}
LoadResult::Playlist(PlaylistData {
info: PlaylistInfo {
name: result.name.clone(),
selected_track: -1,
},
plugin_info: json!({
"type": "playlist",
"url": format!("https://music.amazon.com/playlists/{playlist_id}"),
"artworkUrl": result.artwork_url,
"author": "Amazon Music",
"totalTracks": tracks.len()
}),
tracks,
})
}
async fn load_community_playlist(&self, url: &str) -> LoadResult {
let playlist_id = match self.capture_id(&self.user_playlist_re, url) {
Some(id) => id,
None => return LoadResult::Empty {},
};
let resp = match self.client.fetch_community_playlist(&playlist_id).await {
Some(v) => v,
None => return LoadResult::Empty {},
};
if is_invalid_community_playlist(&resp) {
debug!("Amazon Music: community playlist '{playlist_id}' not found");
return LoadResult::Empty {};
}
let result = match parse_community_playlist_tracks(&resp) {
Some(r) => r,
None => return LoadResult::Empty {},
};
let tracks: Vec<Track> = result.tracks.into_iter().map(Track::new).collect();
if tracks.is_empty() {
return LoadResult::Empty {};
}
LoadResult::Playlist(PlaylistData {
info: PlaylistInfo {
name: result.name.clone(),
selected_track: -1,
},
plugin_info: json!({
"type": "playlist",
"url": format!("https://music.amazon.com/user-playlists/{playlist_id}"),
"artworkUrl": result.artwork_url,
"author": "Community User",
"totalTracks": tracks.len()
}),
tracks,
})
}
async fn load_search(&self, query: &str) -> LoadResult {
let resp = match self.client.search_tracks(query).await {
Some(v) => v,
None => return LoadResult::Empty {},
};
let items: Vec<serde_json::Value> = match resp["methods"]
.as_array()
.and_then(|m| m.first())
.and_then(|m| m["template"]["widgets"].as_array())
.and_then(|w| w.first())
.and_then(|w| w["items"].as_array())
{
Some(i) => i.iter().take(self.search_limit).cloned().collect(),
None => return LoadResult::Empty {},
};
let mut unique_albums: std::collections::HashSet<String> =
std::collections::HashSet::new();
for item in &items {
if let Some(key) = item["iconButton"]["observer"]["storageKey"].as_str()
&& let Some(album_id) = key.split(':').next()
&& !album_id.is_empty()
{
unique_albums.insert(album_id.to_string());
}
}
let album_ids: Vec<String> = unique_albums.into_iter().collect();
let mut duration_map: std::collections::HashMap<String, u64> =
std::collections::HashMap::new();
for batch in album_ids.chunks(5) {
let futures: Vec<_> = batch
.iter()
.map(|album_id| self.client.fetch_album(album_id))
.collect();
let results = futures::future::join_all(futures).await;
for (album_id, album_resp) in batch.iter().zip(results) {
let album_resp = match album_resp {
Some(v) => v,
None => continue,
};
let album_items =
match album_resp["methods"][0]["template"]["widgets"][0]["items"].as_array()
{
Some(i) => i.clone(),
None => continue,
};
for track in &album_items {
let deeplink = match track["primaryTextLink"]["deeplink"].as_str() {
Some(dl) => dl,
None => continue,
};
let track_id = match deeplink.split("/tracks/").nth(1) {
Some(id) => id.split('/').next().unwrap_or("").to_string(),
None => continue,
};
if track_id.is_empty() {
continue;
}
let duration_ms = super::api::duration_str_to_ms(
track["secondaryText3"].as_str().unwrap_or(""),
);
duration_map.insert(format!("{album_id}:{track_id}"), duration_ms);
}
}
}
let tracks: Vec<Track> = parse_search_tracks(&resp, self.search_limit, &duration_map)
.into_iter()
.map(Track::new)
.collect();
if tracks.is_empty() {
LoadResult::Empty {}
} else {
LoadResult::Search(tracks)
}
}
}
#[async_trait]
impl SourcePlugin for AmazonMusicSource {
fn name(&self) -> &str {
"amazonmusic"
}
fn can_handle(&self, identifier: &str) -> bool {
self.search_prefixes()
.iter()
.any(|p| identifier.starts_with(p))
|| self.domain_re.is_match(identifier)
}
fn search_prefixes(&self) -> Vec<&str> {
vec!["azmsearch:", "amznsearch:"]
}
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))
{
return self.load_search(&identifier[prefix.len()..]).await;
}
if self.track_re.is_match(identifier) {
return self.load_track(identifier).await;
}
if self.album_re.is_match(identifier) {
return self.load_album(identifier).await;
}
if self.artist_re.is_match(identifier) {
return self.load_artist(identifier).await;
}
if self.user_playlist_re.is_match(identifier) {
return self.load_community_playlist(identifier).await;
}
if self.playlist_re.is_match(identifier) {
return self.load_playlist(identifier).await;
}
LoadResult::Empty {}
}
async fn get_track(
&self,
identifier: &str,
routeplanner: Option<Arc<dyn crate::routeplanner::RoutePlanner>>,
) -> Option<BoxedTrack> {
let api_base = match self.api_url.as_ref() {
Some(url) => url,
None => {
tracing::debug!("AmazonMusic: api_url not set, falling back to mirror");
return None;
}
};
let track_id = self
.capture_id(&self.track_re, identifier)
.unwrap_or_else(|| identifier.to_string());
let api_endpoint = format!("{}/api/track/{}", api_base.trim_end_matches('/'), track_id);
let response = match self
.http
.get(&api_endpoint)
.header("User-Agent", super::direct::UA)
.send()
.await
{
Ok(res) => {
if !res.status().is_success() {
tracing::warn!("AmazonMusic API returned error status: {}", res.status());
return None;
}
match res.json::<StreamApiResponse>().await {
Ok(data) => data,
Err(e) => {
tracing::warn!("AmazonMusic API failed to parse JSON: {}", e);
return None;
}
}
}
Err(e) => {
tracing::warn!("AmazonMusic API request failed: {}", e);
return None;
}
};
if response.stream_url.is_empty() {
tracing::warn!("AmazonMusic API returned empty stream URL");
return None;
}
let local_addr = routeplanner
.as_ref()
.and_then(|rp| rp.get_address())
.or(self.local_addr);
tracing::info!(
"AmazonMusic: Direct playback configured successfully for {}",
track_id
);
Some(Arc::new(AmazonMusicTrack {
track_id,
stream_url: response.stream_url,
decryption_key: response.decryption_key,
local_addr,
proxy: self.proxy.clone(),
}))
}
fn get_proxy_config(&self) -> Option<crate::config::HttpProxyConfig> {
self.proxy.clone()
}
}
}
pub mod parsers {
use super::api::{clean_image_url, clean_song_title, duration_str_to_ms, normalize_artist};
use crate::protocol::tracks::TrackInfo;
use serde_json::Value;
pub fn parse_track(resp: &Value, track_id: &str) -> Option<TrackInfo> {
let methods = resp["methods"].as_array()?;
let template = &methods.first()?["template"];
if template.is_null() {
return None;
}
let widgets = template["widgets"].as_array()?;
let tracklist = widgets.iter().find(|w| {
w["header"]
.as_str()
.map(|h| h.to_lowercase().contains("album tracklist"))
.unwrap_or(false)
})?;
let items = tracklist["items"].as_array()?;
let track_item = items.iter().find(|item| {
item["primaryTextLink"]["deeplink"]
.as_str()
.map(|dl| dl.contains(&format!("/tracks/{track_id}")))
.unwrap_or(false)
})?;
let title = template["headerText"]["text"]
.as_str()
.unwrap_or("Unknown Title")
.to_string();
let artist = normalize_artist(
template["headerPrimaryText"]
.as_str()
.unwrap_or("Unknown Artist"),
);
let duration_ms = duration_str_to_ms(track_item["secondaryText3"].as_str().unwrap_or(""));
let artwork_url = template["headerImage"]
.as_str()
.filter(|s| !s.is_empty())
.map(clean_image_url);
let isrc = extract_isrc(template);
Some(TrackInfo {
identifier: track_id.to_string(),
is_seekable: true,
author: artist,
length: duration_ms,
is_stream: false,
position: 0,
title,
uri: Some(format!("https://music.amazon.com/tracks/{track_id}")),
artwork_url,
isrc,
source_name: "amazonmusic".to_string(),
})
}
pub fn parse_album_tracks(
resp: &Value,
_album_id: &str,
) -> Option<(String, String, Vec<TrackInfo>)> {
let template = &resp["methods"][0]["template"];
let album_name = template["headerText"]["text"]
.as_str()
.unwrap_or("Unknown Album")
.to_string();
let artist_name = template["headerPrimaryText"]
.as_str()
.unwrap_or("Unknown Artist")
.to_string();
let artwork = template["headerImage"].as_str().unwrap_or("").to_string();
let items = template["widgets"][0]["items"]
.as_array()
.cloned()
.unwrap_or_default();
let tracks = items
.iter()
.filter_map(|item| {
let track_id = item["primaryTextLink"]["deeplink"]
.as_str()
.and_then(|dl| dl.split("/tracks/").nth(1))?
.to_string();
let title = item["primaryText"]
.as_str()
.unwrap_or("Unknown Title")
.to_string();
let item_artist = normalize_artist(
item["secondaryText2"]
.as_str()
.filter(|s| !s.is_empty())
.unwrap_or(&artist_name),
);
let duration_ms = duration_str_to_ms(item["secondaryText3"].as_str().unwrap_or(""));
let art = if artwork.is_empty() {
None
} else {
Some(clean_image_url(&artwork))
};
Some(TrackInfo {
identifier: track_id.clone(),
is_seekable: true,
author: item_artist,
length: duration_ms,
is_stream: false,
position: 0,
title,
uri: Some(format!("https://music.amazon.com/tracks/{track_id}")),
artwork_url: art,
isrc: None,
source_name: "amazonmusic".to_string(),
})
})
.collect();
Some((album_name, artist_name, tracks))
}
pub struct ArtistResult {
pub name: String,
pub artwork_url: Option<String>,
pub tracks: Vec<TrackInfo>,
}
pub fn parse_artist_top_songs(
resp: &Value,
artist_id: &str,
duration_map: &std::collections::HashMap<String, u64>,
) -> Option<ArtistResult> {
let template = &resp["methods"][0]["template"];
let artist_name = template["headerText"]["text"]
.as_str()
.unwrap_or("Unknown Artist")
.to_string();
let artwork_url = template["backgroundImage"]
.as_str()
.filter(|s| !s.is_empty())
.map(clean_image_url);
let widgets = template["widgets"].as_array()?;
let top_songs_widget = widgets.iter().find(|w| {
w["header"]
.as_str()
.map(|h| h.to_lowercase().contains("top songs"))
.unwrap_or(false)
})?;
let items = top_songs_widget["items"].as_array()?;
let tracks = items
.iter()
.filter_map(|item| {
let storage_key = item["iconButton"]["observer"]["storageKey"].as_str()?;
let mut parts = storage_key.splitn(2, ':');
let album_id = parts.next()?.to_string();
let track_id = parts.next()?.to_string();
if track_id.is_empty() {
return None;
}
let title = clean_song_title(
item["primaryText"]["text"]
.as_str()
.unwrap_or("Unknown Title"),
);
let artist =
normalize_artist(item["secondaryText"].as_str().unwrap_or(&artist_name));
let item_artwork = item["image"]
.as_str()
.filter(|s| !s.is_empty())
.map(clean_image_url)
.or_else(|| artwork_url.clone());
let duration_ms = duration_map
.get(&format!("{album_id}:{track_id}"))
.copied()
.unwrap_or(0);
Some(TrackInfo {
identifier: track_id.clone(),
is_seekable: true,
author: artist,
length: duration_ms,
is_stream: false,
position: 0,
title,
uri: Some(format!("https://music.amazon.com/tracks/{track_id}")),
artwork_url: item_artwork,
isrc: None,
source_name: "amazonmusic".to_string(),
})
})
.collect();
let _ = artist_id;
Some(ArtistResult {
name: artist_name,
artwork_url,
tracks,
})
}
pub struct PlaylistResult {
pub name: String,
pub artwork_url: Option<String>,
pub tracks: Vec<TrackInfo>,
}
pub fn parse_playlist_tracks(resp: &Value) -> Option<PlaylistResult> {
let template = &resp["methods"][0]["template"];
let name = template["headerText"]["text"]
.as_str()
.unwrap_or("Unknown Playlist")
.to_string();
let artwork_url = template["headerImage"]
.as_str()
.filter(|s| !s.is_empty())
.map(clean_image_url);
let items = template["widgets"][0]["items"]
.as_array()
.cloned()
.unwrap_or_default();
let tracks = items
.iter()
.filter_map(|item| {
let storage_key = item["iconButton"]["observer"]["storageKey"].as_str()?;
let mut parts = storage_key.splitn(2, ':');
let _album_id = parts.next()?;
let track_id = parts.next()?.to_string();
if track_id.is_empty() {
return None;
}
let title = item["primaryText"]
.as_str()
.unwrap_or("Unknown Title")
.to_string();
let artist =
normalize_artist(item["secondaryText1"].as_str().unwrap_or("Unknown Artist"));
let duration_ms = duration_str_to_ms(item["secondaryText3"].as_str().unwrap_or(""));
let item_art = item["image"]
.as_str()
.filter(|s| !s.is_empty())
.map(clean_image_url)
.or_else(|| artwork_url.clone());
Some(TrackInfo {
identifier: track_id.clone(),
is_seekable: true,
author: artist,
length: duration_ms,
is_stream: false,
position: 0,
title,
uri: Some(format!("https://music.amazon.com/tracks/{track_id}")),
artwork_url: item_art,
isrc: None,
source_name: "amazonmusic".to_string(),
})
})
.collect();
Some(PlaylistResult {
name,
artwork_url,
tracks,
})
}
pub fn parse_community_playlist_tracks(resp: &Value) -> Option<PlaylistResult> {
let template = &resp["methods"][0]["template"];
let name = template["headerText"]["text"]
.as_str()
.unwrap_or("Unknown Playlist")
.to_string();
let artwork_url = template["headerImage"]
.as_str()
.filter(|s| !s.is_empty())
.map(clean_image_url);
let items = template["widgets"][0]["items"]
.as_array()
.cloned()
.unwrap_or_default();
let tracks = items
.iter()
.filter_map(|item| {
let track_id = item["id"].as_str()?.to_string();
if track_id.is_empty() {
return None;
}
let title = item["primaryText"]
.as_str()
.unwrap_or("Unknown Title")
.to_string();
let artist =
normalize_artist(item["secondaryText1"].as_str().unwrap_or("Unknown Artist"));
let duration_ms = duration_str_to_ms(item["secondaryText3"].as_str().unwrap_or(""));
let item_art = item["image"]
.as_str()
.filter(|s| !s.is_empty())
.map(clean_image_url)
.or_else(|| artwork_url.clone());
Some(TrackInfo {
identifier: track_id.clone(),
is_seekable: true,
author: artist,
length: duration_ms,
is_stream: false,
position: 0,
title,
uri: Some(format!("https://music.amazon.com/tracks/{track_id}")),
artwork_url: item_art,
isrc: None,
source_name: "amazonmusic".to_string(),
})
})
.collect();
Some(PlaylistResult {
name,
artwork_url,
tracks,
})
}
pub fn parse_search_tracks(
resp: &Value,
limit: usize,
duration_map: &std::collections::HashMap<String, u64>,
) -> Vec<TrackInfo> {
let items = match resp["methods"]
.as_array()
.and_then(|m| m.first())
.and_then(|m| m["template"]["widgets"].as_array())
.and_then(|w| w.first())
.and_then(|w| w["items"].as_array())
{
Some(i) => i,
None => return Vec::new(),
};
items
.iter()
.take(limit)
.filter_map(|item| {
let storage_key = item["iconButton"]["observer"]["storageKey"].as_str()?;
let mut parts = storage_key.splitn(2, ':');
let album_id = parts.next()?.to_string();
let track_id = parts.next()?.to_string();
if track_id.is_empty() {
return None;
}
let title = item["primaryText"]["text"]
.as_str()
.unwrap_or("Unknown Title")
.to_string();
let artist =
normalize_artist(item["secondaryText"].as_str().unwrap_or("Unknown Artist"));
let artwork = item["image"]
.as_str()
.filter(|s| !s.is_empty())
.map(clean_image_url);
let duration_ms = duration_map
.get(&format!("{album_id}:{track_id}"))
.copied()
.unwrap_or(0);
Some(TrackInfo {
identifier: track_id.clone(),
is_seekable: true,
author: artist,
length: duration_ms,
is_stream: false,
position: 0,
title,
uri: Some(format!("https://music.amazon.com/tracks/{track_id}")),
artwork_url: artwork,
isrc: None,
source_name: "amazonmusic".to_string(),
})
})
.collect()
}
fn extract_isrc(template: &Value) -> Option<String> {
let scripts = template["templateData"]["seoHead"]["script"].as_array()?;
for script in scripts {
let inner_html = script["innerHTML"].as_str()?;
if let Ok(parsed) = serde_json::from_str::<Value>(inner_html)
&& let Some(isrc) = parsed["isrcCode"].as_str()
{
return Some(isrc.to_string());
}
}
None
}
}
pub mod reader {
use crate::{
audio::source::{HttpSource, create_client},
common::types::AnyResult,
};
use std::io::{Read, Seek, SeekFrom};
use symphonia::core::io::MediaSource;
const UA: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36";
pub struct AmazonRemoteReader {
inner: HttpSource,
}
impl AmazonRemoteReader {
pub async fn open(
url: &str,
local_addr: Option<std::net::IpAddr>,
proxy: Option<crate::config::HttpProxyConfig>,
) -> AnyResult<Self> {
let client = create_client(UA.to_owned(), local_addr, proxy, None)?;
let inner = HttpSource::new(client, url).await?;
Ok(Self { inner })
}
}
impl Read for AmazonRemoteReader {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
self.inner.read(buf)
}
}
impl Seek for AmazonRemoteReader {
fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
self.inner.seek(pos)
}
}
impl MediaSource for AmazonRemoteReader {
fn is_seekable(&self) -> bool {
self.inner.is_seekable()
}
fn byte_len(&self) -> Option<u64> {
self.inner.byte_len()
}
}
}
pub mod region {
use super::api::gen_request_id;
use futures::{StreamExt, stream::FuturesUnordered};
use regex::Regex;
use serde_json::{Value, json};
use std::{sync::LazyLock, time::Duration};
use tracing::{debug, warn};
const USER_AGENT: &str = "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 \
(KHTML, like Gecko) Chrome/125.0.0.0 Mobile Safari/537.36";
pub struct RegionConfig {
pub skill_endpoint: &'static str,
pub language: &'static str,
pub currency: &'static str,
pub device_family: &'static str,
pub feature_flags: &'static str,
}
const EP_NA: &str = "https://na.web.skill.music.a2z.com";
const EP_EU: &str = "https://eu.web.skill.music.a2z.com";
const EP_FE: &str = "https://fe.web.skill.music.a2z.com";
static REGION_CONFIGS: &[(&str, RegionConfig)] = &[
(
"music.amazon.com",
RegionConfig {
skill_endpoint: EP_NA,
language: "en_US",
currency: "USD",
device_family: "WebPlayer",
feature_flags: "",
},
),
(
"music.amazon.com.mx",
RegionConfig {
skill_endpoint: EP_NA,
language: "es_MX",
currency: "MXN",
device_family: "WebPlayer",
feature_flags: "",
},
),
(
"music.amazon.com.br",
RegionConfig {
skill_endpoint: EP_NA,
language: "pt_BR",
currency: "BRL",
device_family: "WebPlayer",
feature_flags: "",
},
),
(
"music.amazon.ca",
RegionConfig {
skill_endpoint: EP_NA,
language: "en_CA",
currency: "CAD",
device_family: "WebPlayer",
feature_flags: "",
},
),
(
"music.amazon.co.uk",
RegionConfig {
skill_endpoint: EP_EU,
language: "en_GB",
currency: "GBP",
device_family: "WebPlayer",
feature_flags: "hd-supported,uhd-supported",
},
),
(
"music.amazon.de",
RegionConfig {
skill_endpoint: EP_EU,
language: "de_DE",
currency: "EUR",
device_family: "WebPlayer",
feature_flags: "hd-supported,uhd-supported",
},
),
(
"music.amazon.fr",
RegionConfig {
skill_endpoint: EP_EU,
language: "fr_FR",
currency: "EUR",
device_family: "WebPlayer",
feature_flags: "hd-supported,uhd-supported",
},
),
(
"music.amazon.it",
RegionConfig {
skill_endpoint: EP_EU,
language: "it_IT",
currency: "EUR",
device_family: "WebPlayer",
feature_flags: "hd-supported,uhd-supported",
},
),
(
"music.amazon.es",
RegionConfig {
skill_endpoint: EP_EU,
language: "es_ES",
currency: "EUR",
device_family: "WebPlayer",
feature_flags: "hd-supported,uhd-supported",
},
),
(
"music.amazon.in",
RegionConfig {
skill_endpoint: EP_EU,
language: "en_IN",
currency: "INR",
device_family: "WebPlayer",
feature_flags: "hd-supported,uhd-supported",
},
),
(
"music.amazon.sa",
RegionConfig {
skill_endpoint: EP_EU,
language: "ar_SA",
currency: "SAR",
device_family: "WebPlayer",
feature_flags: "",
},
),
(
"music.amazon.ae",
RegionConfig {
skill_endpoint: EP_EU,
language: "ar_AE",
currency: "AED",
device_family: "WebPlayer",
feature_flags: "",
},
),
(
"music.amazon.co.jp",
RegionConfig {
skill_endpoint: EP_FE,
language: "ja_JP",
currency: "JPY",
device_family: "WebPlayer",
feature_flags: "hd-supported,uhd-supported",
},
),
(
"music.amazon.com.au",
RegionConfig {
skill_endpoint: EP_FE,
language: "en_AU",
currency: "AUD",
device_family: "WebPlayer",
feature_flags: "hd-supported,uhd-supported",
},
),
];
static REGION_FALLBACKS: &[(&str, &str)] = &[
("NA", "music.amazon.com"),
("EU", "music.amazon.co.uk"),
("FE", "music.amazon.co.jp"),
];
fn get_region_config(domain: &str) -> Option<&'static RegionConfig> {
REGION_CONFIGS
.iter()
.find(|(d, _)| *d == domain)
.map(|(_, cfg)| cfg)
}
static DOMAIN_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)^https?://(?:www\.)?(music\.amazon\.[a-z.]+)").unwrap());
pub fn extract_domain(url: &str) -> Option<String> {
let caps = DOMAIN_RE.captures(url)?;
let domain = caps.get(1)?.as_str().to_lowercase();
if get_region_config(&domain).is_some() {
Some(domain)
} else {
None
}
}
fn build_region_headers(
config: &Value,
region: &RegionConfig,
domain: &str,
page_url: &str,
) -> Value {
let access_token = config["accessToken"].as_str().unwrap_or("");
let device_id = config["deviceId"].as_str().unwrap_or("");
let session_id = config["sessionId"].as_str().unwrap_or("");
let version = config["version"].as_str().unwrap_or("");
let csrf_token = config["csrf"]["token"].as_str().unwrap_or("");
let csrf_ts = config["csrf"]["ts"]
.as_str()
.map(|s| s.to_string())
.or_else(|| config["csrf"]["ts"].as_u64().map(|n| n.to_string()))
.unwrap_or_default();
let csrf_rnd = config["csrf"]["rnd"]
.as_str()
.map(|s| s.to_string())
.or_else(|| config["csrf"]["rnd"].as_u64().map(|n| n.to_string()))
.unwrap_or_default();
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis().to_string())
.unwrap_or_default();
json!({
"x-amzn-authentication": serde_json::to_string(&json!({
"interface": "ClientAuthenticationInterface.v1_0.ClientTokenElement",
"accessToken": access_token
})).unwrap_or_default(),
"x-amzn-device-model": "WEBPLAYER",
"x-amzn-device-width": "1920",
"x-amzn-device-family": region.device_family,
"x-amzn-device-id": device_id,
"x-amzn-user-agent": USER_AGENT,
"x-amzn-session-id": session_id,
"x-amzn-device-height": "1080",
"x-amzn-request-id": gen_request_id(),
"x-amzn-device-language": region.language,
"x-amzn-currency-of-preference": region.currency,
"x-amzn-os-version": "1.0",
"x-amzn-application-version": version,
"x-amzn-device-time-zone": "Asia/Calcutta",
"x-amzn-timestamp": ts,
"x-amzn-csrf": serde_json::to_string(&json!({
"interface": "CSRFInterface.v1_0.CSRFHeaderElement",
"token": csrf_token,
"timestamp": csrf_ts,
"rndNonce": csrf_rnd
})).unwrap_or_default(),
"x-amzn-music-domain": domain,
"x-amzn-referer": domain,
"x-amzn-affiliate-tags": "",
"x-amzn-ref-marker": "",
"x-amzn-page-url": page_url,
"x-amzn-weblab-id-overrides": "",
"x-amzn-video-player-token": "",
"x-amzn-feature-flags": region.feature_flags,
"x-amzn-has-profile-id": "",
"x-amzn-age-band": ""
})
}
async fn fetch_from_endpoint(
http: &reqwest::Client,
id: &str,
api_path: &str,
url_path_segment: &str,
region: &RegionConfig,
domain: &str,
base_config: &Value,
) -> Option<Value> {
let page_url = format!(
"https://{domain}/{url_path_segment}/{}",
urlencoding::encode(id)
);
let inner_headers = build_region_headers(base_config, region, domain, &page_url);
let body = json!({
"id": id,
"userHash": serde_json::to_string(&json!({"level": "LIBRARY_MEMBER"})).unwrap_or_default(),
"headers": serde_json::to_string(&inner_headers).unwrap_or_default()
});
let endpoint_url = format!("{}/api/{api_path}", region.skill_endpoint);
let authority = region
.skill_endpoint
.trim_start_matches("https://")
.trim_start_matches("http://");
let resp = http
.post(&endpoint_url)
.header("authority", authority)
.header("accept", "*/*")
.header("accept-language", "en-US,en;q=0.9")
.header("content-type", "text/plain;charset=UTF-8")
.header("origin", format!("https://{domain}"))
.header("referer", format!("https://{domain}/"))
.header(
"sec-ch-ua",
"\"Chromium\";v=\"125\", \"Not.A/Brand\";v=\"24\"",
)
.header("sec-ch-ua-mobile", "?1")
.header("sec-ch-ua-platform", "\"Android\"")
.header("sec-fetch-dest", "empty")
.header("sec-fetch-mode", "cors")
.header("sec-fetch-site", "cross-site")
.header("user-agent", USER_AGENT)
.timeout(Duration::from_secs(8))
.body(serde_json::to_string(&body).unwrap_or_default())
.send()
.await
.ok()?;
if !resp.status().is_success() {
return None;
}
resp.json::<Value>().await.ok()
}
#[allow(clippy::too_many_arguments)]
pub async fn fetch_multi_region(
http: &reqwest::Client,
id: &str,
api_path: &str,
url_path_segment: &str,
entity_name: &str,
domain_hint: Option<&str>,
is_error: fn(&Value) -> bool,
base_config: &Value,
) -> Option<Value> {
let mut tried_endpoint: Option<&str> = None;
if let Some(hint) = domain_hint
&& let Some(region) = get_region_config(hint)
{
debug!("Amazon Music: trying {entity_name} on hinted domain '{hint}'");
tried_endpoint = Some(region.skill_endpoint);
if let Some(data) = fetch_from_endpoint(
http,
id,
api_path,
url_path_segment,
region,
hint,
base_config,
)
.await
&& !is_error(&data)
{
debug!("Amazon Music: {entity_name} resolved via hinted domain '{hint}'");
return Some(data);
}
debug!(
"Amazon Music: hinted domain '{hint}' failed for {entity_name}, trying other regions"
);
}
let mut futs = FuturesUnordered::new();
for &(label, domain) in REGION_FALLBACKS {
let region = match get_region_config(domain) {
Some(r) => r,
None => continue,
};
if tried_endpoint == Some(region.skill_endpoint) {
continue;
}
let base_config = base_config.clone();
futs.push(async move {
let result = fetch_from_endpoint(
http,
id,
api_path,
url_path_segment,
region,
domain,
&base_config,
)
.await;
result.map(|data| (data, label))
});
}
while let Some(result) = futs.next().await {
if let Some((data, label)) = result
&& !is_error(&data)
{
debug!("Amazon Music: {entity_name} resolved via {label} region");
return Some(data);
}
}
warn!("Amazon Music: {entity_name} not found on any regional endpoint (NA, EU, FE)");
None
}
}
pub mod streaming_reader {
use super::crypt::{CencDecryptor, extract_flac_stream_header};
use parking_lot::{Condvar, Mutex};
use std::{
io::{Read, Seek, SeekFrom},
sync::Arc,
time::Duration,
};
use symphonia::core::io::MediaSource;
use tracing::{debug, warn};
struct BufferState {
data: Vec<u8>,
available: usize,
finished: bool,
error: Option<String>,
}
pub struct AmazonStreamingReader {
cursor: u64,
shared: Arc<(Mutex<BufferState>, Condvar)>,
}
impl AmazonStreamingReader {
pub fn new(
client: reqwest::Client,
url: &str,
decryption_key: &str,
total_len: u64,
) -> Result<Self, String> {
let decryptor = CencDecryptor::from_hex(decryption_key)?;
let shared = Arc::new((
Mutex::new(BufferState {
data: Vec::new(),
available: 0,
finished: false,
error: None,
}),
Condvar::new(),
));
let shared_bg = Arc::clone(&shared);
let url_owned = url.to_string();
std::thread::Builder::new()
.name("amz-flac-extract".into())
.spawn(move || {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(download_and_extract_flac(
shared_bg, client, url_owned, decryptor, total_len,
));
})
.map_err(|e| format!("failed to spawn stream thread: {e}"))?;
{
let (lock, cvar) = &*shared;
let mut state = lock.lock();
while state.available == 0 && !state.finished && state.error.is_none() {
cvar.wait_for(&mut state, Duration::from_millis(50));
}
if let Some(ref e) = state.error {
return Err(e.clone());
}
if state.available == 0 {
return Err("stream ended before FLAC header could be extracted".into());
}
}
Ok(Self { cursor: 0, shared })
}
}
impl Read for AmazonStreamingReader {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
let (lock, cvar) = &*self.shared;
let mut state = lock.lock();
loop {
if let Some(ref err) = state.error {
return Err(std::io::Error::other(err.clone()));
}
let pos = self.cursor as usize;
if pos < state.available {
let n = (state.available - pos).min(buf.len());
buf[..n].copy_from_slice(&state.data[pos..pos + n]);
self.cursor += n as u64;
return Ok(n);
}
if state.finished {
return Ok(0);
}
cvar.wait_for(&mut state, Duration::from_millis(50));
}
}
}
impl Seek for AmazonStreamingReader {
fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
let target: u64 = match pos {
SeekFrom::Start(p) => p,
SeekFrom::Current(d) => self.cursor.saturating_add_signed(d),
SeekFrom::End(d) => {
let (lock, cvar) = &*self.shared;
let mut state = lock.lock();
while !state.finished && state.error.is_none() {
cvar.wait_for(&mut state, Duration::from_millis(50));
}
if let Some(ref e) = state.error {
return Err(std::io::Error::other(e.clone()));
}
(state.available as i64).saturating_add(d).max(0) as u64
}
};
{
let (lock, cvar) = &*self.shared;
let mut state = lock.lock();
while (state.available as u64) < target && !state.finished && state.error.is_none()
{
cvar.wait_for(&mut state, Duration::from_millis(50));
}
if let Some(ref e) = state.error {
return Err(std::io::Error::other(e.clone()));
}
self.cursor = target.min(state.available as u64);
}
Ok(self.cursor)
}
}
impl MediaSource for AmazonStreamingReader {
fn is_seekable(&self) -> bool {
true
}
fn byte_len(&self) -> Option<u64> {
let (lock, _) = &*self.shared;
let state = lock.lock();
if state.finished {
Some(state.available as u64)
} else {
None
}
}
}
impl Drop for AmazonStreamingReader {
fn drop(&mut self) {
let (lock, _) = &*self.shared;
lock.lock().finished = true;
}
}
const USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) \
AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36";
async fn download_and_extract_flac(
shared: Arc<(Mutex<BufferState>, Condvar)>,
client: reqwest::Client,
url: String,
decryptor: CencDecryptor,
_total_len: u64,
) {
let response = match client
.get(&url)
.header("User-Agent", USER_AGENT)
.header("Accept", "*/*")
.send()
.await
{
Ok(r) if r.status().is_success() => r,
Ok(r) => {
signal_error(&shared, format!("HTTP {}", r.status()));
return;
}
Err(e) => {
signal_error(&shared, format!("request failed: {e}"));
return;
}
};
let mut staging: Vec<u8> = Vec::with_capacity(256 * 1024);
let mut total_drained: usize = 0;
let mut remainder: Vec<u8> = Vec::new();
let mut stream = response;
let mut header_written = false;
loop {
match stream.chunk().await {
Ok(Some(chunk)) => {
staging.extend_from_slice(&chunk);
}
Ok(None) => break,
Err(e) => {
signal_error(&shared, format!("download error: {e}"));
return;
}
}
total_drained += process_staging_lowmem(
&shared,
&decryptor,
&mut staging,
&mut remainder,
&mut header_written,
);
if total_drained > 512 * 1024 {
staging.shrink_to_fit();
total_drained = 0;
}
}
process_staging_lowmem(
&shared,
&decryptor,
&mut staging,
&mut remainder,
&mut header_written,
);
drop(staging);
if !remainder.is_empty() {
append_to_buffer(&shared, &remainder);
remainder.clear();
remainder.shrink_to_fit();
}
let (lock, cvar) = &*shared;
let mut state = lock.lock();
state.finished = true;
cvar.notify_all();
debug!(
"Amazon streaming: download complete ({} bytes)",
state.available
);
}
fn process_staging_lowmem(
shared: &Arc<(Mutex<BufferState>, Condvar)>,
decryptor: &CencDecryptor,
staging: &mut Vec<u8>,
remainder: &mut Vec<u8>,
header_written: &mut bool,
) -> usize {
let mut total_drained = 0;
loop {
if staging.len() < 8 {
break;
}
let box_size =
u32::from_be_bytes([staging[0], staging[1], staging[2], staging[3]]) as usize;
if box_size < 8 {
break;
}
let box_type = [staging[4], staging[5], staging[6], staging[7]];
if &box_type == b"moof" {
if staging.len() < box_size + 8 {
break;
}
let mdat_size = u32::from_be_bytes([
staging[box_size],
staging[box_size + 1],
staging[box_size + 2],
staging[box_size + 3],
]) as usize;
let mdat_type = [
staging[box_size + 4],
staging[box_size + 5],
staging[box_size + 6],
staging[box_size + 7],
];
if &mdat_type != b"mdat" || mdat_size < 8 {
staging.drain(..box_size);
total_drained += box_size;
continue;
}
let fragment_total = box_size + mdat_size;
if staging.len() < fragment_total {
break;
}
let _ = decryptor.decrypt_buffer(&mut staging[..fragment_total]);
let payload_start = box_size + 8;
let payload_end = box_size + mdat_size;
if *header_written && payload_end > payload_start {
let payload_len = payload_end - payload_start;
let mut payload_copy = Vec::with_capacity(payload_len);
payload_copy.extend_from_slice(&staging[payload_start..payload_end]);
flush_complete_flac_frames(shared, remainder, &payload_copy);
}
staging.drain(..fragment_total);
total_drained += fragment_total;
continue;
}
if staging.len() < box_size {
break;
}
if &box_type == b"moov" && !*header_written {
match extract_flac_stream_header(&staging[..box_size]) {
Some(hdr) => {
debug!("Amazon FLAC: header extracted ({} bytes)", hdr.len());
append_to_buffer(shared, &hdr);
*header_written = true;
}
None => warn!("Amazon FLAC: failed to extract FLAC header from moov"),
}
}
staging.drain(..box_size);
total_drained += box_size;
}
total_drained
}
fn flush_complete_flac_frames(
shared: &Arc<(Mutex<BufferState>, Condvar)>,
remainder: &mut Vec<u8>,
payload: &[u8],
) {
remainder.extend_from_slice(payload);
let mut pos = 0;
loop {
if pos >= remainder.len() {
break;
}
match next_flac_frame_end(&remainder[pos..]) {
Some(frame_len) => {
append_to_buffer(shared, &remainder[pos..pos + frame_len]);
pos += frame_len;
}
None => {
break;
}
}
}
remainder.drain(..pos);
}
fn next_flac_frame_end(data: &[u8]) -> Option<usize> {
if data.len() < 6 {
return None;
}
if (data[0] != 0xFF) || (data[1] & 0xFE) != 0xF8 {
if let Some(next) = find_sync(&data[1..]) {
return next_flac_frame_end(&data[1 + next..]);
}
return None;
}
let search_from = 6;
if data.len() <= search_from {
return None;
}
find_sync(&data[search_from..]).map(|offset| search_from + offset)
}
fn find_sync(data: &[u8]) -> Option<usize> {
data.windows(2)
.position(|w| w[0] == 0xFF && (w[1] & 0xFE) == 0xF8)
}
fn append_to_buffer(shared: &Arc<(Mutex<BufferState>, Condvar)>, data: &[u8]) {
let (lock, cvar) = &**shared;
let mut state = lock.lock();
state.data.extend_from_slice(data);
state.available = state.data.len();
cvar.notify_all();
}
fn signal_error(shared: &Arc<(Mutex<BufferState>, Condvar)>, msg: String) {
warn!("Amazon FLAC streaming error: {}", msg);
let (lock, cvar) = &**shared;
let mut state = lock.lock();
state.error = Some(msg);
state.finished = true;
cvar.notify_all();
}
}
pub mod track {
use crate::protocol::tracks::TrackInfo;
pub struct AmazonTrackData {
pub track_id: String,
pub title: String,
pub artist: String,
pub duration_ms: u64,
pub artwork_url: Option<String>,
pub isrc: Option<String>,
}
impl AmazonTrackData {
pub fn into_track_info(self) -> TrackInfo {
let uri = format!("https://music.amazon.com/tracks/{}", self.track_id);
TrackInfo {
identifier: self.track_id,
is_seekable: true,
author: self.artist,
length: self.duration_ms,
is_stream: false,
position: 0,
title: self.title,
uri: Some(uri),
artwork_url: self.artwork_url,
isrc: self.isrc,
source_name: "amazonmusic".to_string(),
}
}
}
}
pub mod validators {
use serde_json::Value;
pub fn is_invalid_track(resp: &Value) -> bool {
let methods = match resp["methods"].as_array() {
Some(m) if !m.is_empty() => m,
_ => return true,
};
let has_error_note = methods.iter().any(|m| {
m["interface"]
.as_str()
.map(|i| i.contains("ShowNotificationMethod"))
.unwrap_or(false)
&& m["notification"]["message"]["text"]
.as_str()
.map(|t| t.contains("no longer available"))
.unwrap_or(false)
});
let is_homepage = methods[0]["template"]["interface"]
.as_str()
.map(|i| i.contains("GalleryTemplate"))
.unwrap_or(false)
&& methods[0]["template"]["widgets"]
.as_array()
.map(|w| w.is_empty())
.unwrap_or(false);
has_error_note || is_homepage
}
pub fn is_invalid_album(resp: &Value) -> bool {
let methods = match resp["methods"].as_array() {
Some(m) if !m.is_empty() => m,
_ => return true,
};
let template = &methods[0]["template"];
template["interface"]
.as_str()
.map(|i| i.contains("DialogTemplate"))
.unwrap_or(false)
&& template["header"]
.as_str()
.map(|h| h == "Service error")
.unwrap_or(false)
}
pub fn is_invalid_artist(resp: &Value) -> bool {
let methods = match resp["methods"].as_array() {
Some(m) if !m.is_empty() => m,
_ => return true,
};
let template = &methods[0]["template"];
template["interface"]
.as_str()
.map(|i| i.contains("MessageTemplate"))
.unwrap_or(false)
&& template["header"]
.as_str()
.map(|h| h == "We're Sorry")
.unwrap_or(false)
&& template["message"]
.as_str()
.map(|m| m.contains("unable to complete your action"))
.unwrap_or(false)
}
pub fn is_invalid_playlist(resp: &Value) -> bool {
let methods = match resp["methods"].as_array() {
Some(m) if !m.is_empty() => m,
_ => return true,
};
let first = &methods[0];
if first["template"]["widgets"]
.as_array()
.map(|w| w.is_empty())
.unwrap_or(false)
{
return true;
}
if let Some(second) = methods.get(1) {
let msg = second["notification"]["message"]["text"]
.as_str()
.or_else(|| second["notification"]["message"]["innerHTML"].as_str())
.unwrap_or("");
if msg
.to_lowercase()
.contains("playlist is no longer available")
{
return true;
}
}
first["template"]["templateData"]["deeplink"]
.as_str()
.map(|d| d == "/")
.unwrap_or(false)
}
pub fn is_invalid_community_playlist(resp: &Value) -> bool {
let template = match resp["methods"].as_array().and_then(|m| m.first()) {
Some(m) => &m["template"],
None => return false,
};
let is_dialog = template["interface"]
.as_str()
.map(|i| {
i == "Web.TemplatesInterface.v1_0.Touch.DialogTemplateInterface.DialogTemplate"
})
.unwrap_or(false);
let is_service_error = template["header"]
.as_str()
.map(|h| h.trim().to_lowercase() == "service error")
.unwrap_or(false);
let has_error_body = template["body"]["text"]
.as_str()
.map(|t| t.to_lowercase().contains("sorry something went wrong"))
.unwrap_or(false);
is_dialog && is_service_error && has_error_body
}
}
pub use manager::AmazonMusicSource;