pub mod manager {
use crate::{
config::AppConfig,
protocol::tracks::{LoadResult, PlaylistData, PlaylistInfo, Track, TrackInfo},
sources::{SourcePlugin, playable_track::BoxedTrack},
};
use async_trait::async_trait;
use regex::Regex;
use serde_json::{Value, json};
use std::{
sync::Arc,
time::{SystemTime, UNIX_EPOCH},
};
const BASE_URL: &str = "https://api.anghami.com/gateway.php";
pub struct AnghamiSource {
client: Arc<reqwest::Client>,
udid: String,
search_limit: usize,
url_regex: Regex,
}
impl AnghamiSource {
pub fn new(config: &AppConfig, client: Arc<reqwest::Client>) -> Result<Self, String> {
let ag_config = config.sources.anghami.clone().unwrap_or_default();
let udid = uuid::Uuid::new_v4().simple().to_string();
Ok(Self {
client,
udid,
search_limit: ag_config.search_limit,
url_regex: Regex::new(
r"^https?://(?:play\.|www\.)?anghami\.com/(?P<type>song|album|playlist|artist)/(?P<id>[0-9]+)",
)
.unwrap(),
})
}
fn unix_ts(&self) -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
async fn api_request(&self, params: Vec<(&str, &str)>) -> Option<Value> {
let mut url = reqwest::Url::parse(BASE_URL).ok()?;
{
let mut q = url.query_pairs_mut();
for (k, v) in params {
q.append_pair(k, v);
}
}
let resp = self
.base_request(self.client.get(url))
.header("X-ANGH-UDID", &self.udid)
.header("X-ANGH-TS", self.unix_ts().to_string())
.send()
.await
.ok()?;
if !resp.status().is_success() {
return None;
}
resp.json::<Value>().await.ok()
}
fn build_artwork_url(json: &Value) -> Option<String> {
let art_id = json["coverArt"]
.as_str()
.or_else(|| json["AlbumArt"].as_str())
.or_else(|| json["cover"].as_str())
.filter(|s| !s.is_empty())?;
Some(format!(
"https://artwork.anghcdn.co/?id={}&size=640",
art_id
))
}
fn parse_track(&self, json: &Value) -> Option<Track> {
let id = json["id"]
.as_str()
.map(|s| s.to_owned())
.or_else(|| json["id"].as_i64().map(|n| n.to_string()))
.filter(|s| !s.is_empty())?;
let title = json["title"]
.as_str()
.or_else(|| json["name"].as_str())
.filter(|s| !s.is_empty())?
.to_owned();
let author = json["artist"]
.as_str()
.or_else(|| json["artistName"].as_str())
.unwrap_or("Unknown Artist")
.to_owned();
let duration_secs = json["duration"]
.as_f64()
.or_else(|| {
json["duration"]
.as_str()
.and_then(|s| s.parse::<f64>().ok())
})
.unwrap_or(0.0);
let length = (duration_secs * 1000.0).round() as u64;
let artwork_url = Self::build_artwork_url(json);
let uri = format!("https://play.anghami.com/song/{}", id);
Some(Track::new(TrackInfo {
identifier: id,
is_seekable: true,
author,
length,
is_stream: false,
position: 0,
title,
uri: Some(uri),
artwork_url,
isrc: None,
source_name: "anghami".to_owned(),
}))
}
fn extract_tracks(&self, body: &Value) -> Vec<Track> {
if let Some(songbuffers) = body["songbuffers"].as_array() {
let mut song_map = std::collections::HashMap::new();
for buffer_base64 in songbuffers {
if let Some(s) = buffer_base64.as_str()
&& let Ok(decoded) =
base64::Engine::decode(&base64::prelude::BASE64_STANDARD, s.as_bytes())
{
let songs = super::reader::decode_song_batch(&decoded);
for (id, track_info) in songs {
song_map.insert(id, Track::new(track_info));
}
}
}
if !song_map.is_empty() {
if let Some(order) = self.get_song_order(body) {
let tracks: Vec<Track> = order
.split(',')
.filter_map(|id| song_map.remove(id.trim()))
.collect();
if !tracks.is_empty() {
return tracks;
}
}
return song_map.into_values().collect();
}
}
if let Some(sections) = body["sections"].as_array() {
for section in sections {
let type_ = section["type"].as_str().unwrap_or("");
let group = section["group"].as_str().unwrap_or("");
if (type_ == "song" || group == "songs" || group == "album_songs")
&& let Some(data) = section["data"].as_array()
{
let tracks: Vec<Track> =
data.iter().filter_map(|s| self.parse_track(s)).collect();
if !tracks.is_empty() {
return tracks;
}
}
}
}
for path in &["songs", "playlist/songs", "album/songs"] {
let parts: Vec<&str> = path.split('/').collect();
let mut current = body;
for part in parts {
current = ¤t[part];
}
if current.is_null() {
continue;
}
let mut song_map = std::collections::HashMap::new();
if let Some(obj) = current.as_object() {
for v in obj.values() {
let s = if !v["_attributes"].is_null() {
&v["_attributes"]
} else {
v
};
if let Some(track) = self.parse_track(s) {
song_map.insert(track.info.identifier.clone(), track);
}
}
}
if song_map.is_empty() {
continue;
}
let tracks: Vec<Track> = if let Some(order) = self.get_song_order(body) {
order
.split(',')
.filter_map(|id| song_map.remove(id.trim()))
.collect()
} else {
song_map.into_values().collect()
};
if !tracks.is_empty() {
return tracks;
}
}
body["data"]
.as_array()
.map(|data| data.iter().filter_map(|s| self.parse_track(s)).collect())
.unwrap_or_default()
}
fn get_song_order<'a>(&self, body: &'a Value) -> Option<&'a str> {
[
&body["songorder"],
&body["_attributes"]["songorder"],
&body["playlist"]["songorder"],
&body["album"]["songorder"],
]
.iter()
.find_map(|v| v.as_str())
}
fn collection_title(body: &Value, type_hint: &str, default: &str) -> String {
let mut candidates = vec![
&body["title"],
&body["name"],
&body["playlist_name"],
&body["album_name"],
&body["albumTitle"],
&body["playlistTitle"],
&body["album_info"]["title"],
&body["playlist_info"]["title"],
];
for t in &["album", "playlist", type_hint] {
candidates.push(&body[*t]["title"]);
candidates.push(&body[*t]["name"]);
candidates.push(&body[*t]["album_name"]);
candidates.push(&body[*t]["playlist_name"]);
candidates.push(&body[*t]["albumTitle"]);
candidates.push(&body[*t]["playlistTitle"]);
candidates.push(&body[*t]["_attributes"]["title"]);
candidates.push(&body[*t]["_attributes"]["name"]);
}
candidates.push(&body["_attributes"]["title"]);
candidates.push(&body["_attributes"]["name"]);
if let Some(title) = candidates
.into_iter()
.find_map(|v| v.as_str().filter(|s| !s.is_empty()))
{
return title.to_owned();
}
if let Some(sections) = body["sections"].as_array() {
for sec in sections {
if let Some(t) = sec["title"]
.as_str()
.or_else(|| sec["name"].as_str())
.filter(|s| !s.is_empty())
{
return t.to_owned();
}
}
}
default.to_owned()
}
async fn get_search(&self, query: &str) -> LoadResult {
if query.is_empty() {
return LoadResult::Empty {};
}
let body = match self
.api_request(vec![
("type", "GETtabsearch"),
("query", query),
("web2", "true"),
("language", "en"),
("output", "json"),
])
.await
{
Some(b) => b,
None => return LoadResult::Empty {},
};
let tracks: Vec<Track> = body["sections"]
.as_array()
.and_then(|secs| {
secs.iter().find(|s| {
s["type"].as_str() == Some("genericitem")
&& s["group"].as_str() == Some("songs")
})
})
.and_then(|s| s["data"].as_array())
.map(|data| {
data.iter()
.take(self.search_limit)
.filter_map(|item| self.parse_track(item))
.collect()
})
.unwrap_or_default();
if tracks.is_empty() {
LoadResult::Empty {}
} else {
LoadResult::Search(tracks)
}
}
async fn get_song(&self, id: &str) -> LoadResult {
if let Some(body) = self
.api_request(vec![
("type", "GETsongdata"),
("songId", id),
("output", "jsonhp"),
])
.await
&& body["status"].as_str() == Some("ok")
&& let Some(track) = self.parse_track(&body)
{
return LoadResult::Track(track);
}
if let Some(body) = self
.api_request(vec![
("type", "GETtabsearch"),
("query", id),
("web2", "true"),
("language", "en"),
("output", "json"),
])
.await
&& let Some(sections) = body["sections"].as_array()
{
for section in sections {
if let Some(data) = section["data"].as_array() {
let song = data.iter().find(|s| {
s["id"].as_str() == Some(id)
|| s["id"].as_i64().map(|n| n.to_string()).as_deref() == Some(id)
});
if let Some(s) = song
&& let Some(track) = self.parse_track(s)
{
return LoadResult::Track(track);
}
}
}
}
LoadResult::Empty {}
}
async fn get_album(&self, id: &str) -> LoadResult {
for buffered in &[false, true] {
let mut params = vec![
("type", "GETalbumdata"),
("albumId", id),
("web2", "true"),
("language", "en"),
("output", "json"),
];
if *buffered {
params.push(("buffered", "1"));
}
let body = match self.api_request(params).await {
Some(b) if b["error"].is_null() => b,
_ => continue,
};
let tracks = self.extract_tracks(&body);
if tracks.is_empty() {
continue;
}
let mut name = Self::collection_title(&body, "album", "Unknown Album");
if name == "Unknown Album" {
if let Some(first_album) = body["data"]
.as_array()
.and_then(|a| a.first())
.and_then(|t| t["album"].as_str().or_else(|| t["albumName"].as_str()))
{
name = first_album.to_owned();
} else if let Some(sections) = body["sections"].as_array() {
for sec in sections {
if let Some(first_album) = sec["data"]
.as_array()
.and_then(|a| a.first())
.and_then(|t| {
t["album"].as_str().or_else(|| t["albumName"].as_str())
})
{
name = first_album.to_owned();
break;
}
}
}
}
let artwork_url = tracks.first().and_then(|t| t.info.artwork_url.clone());
let author = tracks.first().map(|t| t.info.author.clone());
return LoadResult::Playlist(PlaylistData {
info: PlaylistInfo {
name,
selected_track: -1,
},
plugin_info: json!({
"type": "album",
"url": format!("https://play.anghami.com/album/{}", id),
"artworkUrl": artwork_url,
"author": author,
"totalTracks": tracks.len()
}),
tracks,
});
}
LoadResult::Empty {}
}
async fn get_playlist(&self, id: &str) -> LoadResult {
for buffered in &[false, true] {
let mut params = vec![
("type", "GETplaylistdata"),
("playlistId", id),
("web2", "true"),
("language", "en"),
("output", "json"),
];
if *buffered {
params.push(("buffered", "1"));
}
let body = match self.api_request(params).await {
Some(b) if b["error"].is_null() => b,
_ => continue,
};
let tracks = self.extract_tracks(&body);
if tracks.is_empty() {
continue;
}
let mut name = Self::collection_title(&body, "playlist", "Unknown Playlist");
if name == "Unknown Playlist"
&& let Some(alt_name) = body["playlist"]["name"]
.as_str()
.or_else(|| body["playlist"]["title"].as_str())
{
name = alt_name.to_owned();
}
let artwork_url = tracks.first().and_then(|t| t.info.artwork_url.clone());
return LoadResult::Playlist(PlaylistData {
info: PlaylistInfo {
name,
selected_track: -1,
},
plugin_info: json!({
"type": "playlist",
"url": format!("https://play.anghami.com/playlist/{}", id),
"artworkUrl": artwork_url,
"totalTracks": tracks.len()
}),
tracks,
});
}
LoadResult::Empty {}
}
async fn get_artist(&self, id: &str) -> LoadResult {
let body = match self
.api_request(vec![
("type", "GETartistprofile"),
("artistId", id),
("web2", "true"),
("language", "en"),
("output", "json"),
])
.await
{
Some(b) => b,
None => return LoadResult::Empty {},
};
let tracks: Vec<Track> = if let Some(sections) = body["sections"].as_array() {
sections
.iter()
.find(|s| {
s["group"].as_str() == Some("songs") || s["type"].as_str() == Some("song")
})
.and_then(|s| s["data"].as_array())
.map(|data| data.iter().filter_map(|s| self.parse_track(s)).collect())
.unwrap_or_default()
} else {
body["data"]
.as_array()
.map(|data| data.iter().filter_map(|s| self.parse_track(s)).collect())
.unwrap_or_default()
};
if tracks.is_empty() {
return LoadResult::Empty {};
}
let name = body["name"]
.as_str()
.or_else(|| body["title"].as_str())
.unwrap_or("Unknown Artist")
.to_owned();
let artwork_url = tracks.first().and_then(|t| t.info.artwork_url.clone());
LoadResult::Playlist(PlaylistData {
info: PlaylistInfo {
name: format!("{}'s Top Tracks", name),
selected_track: -1,
},
plugin_info: json!({
"type": "artist",
"url": format!("https://play.anghami.com/artist/{}", id),
"artworkUrl": artwork_url,
"author": name,
"totalTracks": tracks.len()
}),
tracks,
})
}
pub fn base_request(&self, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
builder
.header(reqwest::header::USER_AGENT, "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
.header("Referer", "https://play.anghami.com/")
.header("Origin", "https://play.anghami.com")
}
}
#[async_trait]
impl SourcePlugin for AnghamiSource {
fn name(&self) -> &str {
"anghami"
}
fn can_handle(&self, identifier: &str) -> bool {
self.search_prefixes()
.into_iter()
.any(|p| identifier.starts_with(p))
|| self.url_regex.is_match(identifier)
}
fn search_prefixes(&self) -> Vec<&str> {
vec!["agsearch:"]
}
fn is_mirror(&self) -> bool {
true
}
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.get_search(&identifier[prefix.len()..]).await;
}
if let Some(caps) = self.url_regex.captures(identifier) {
let type_ = caps.name("type").map(|m| m.as_str()).unwrap_or("");
let id = caps.name("id").map(|m| m.as_str()).unwrap_or("");
return match type_ {
"song" => self.get_song(id).await,
"album" => self.get_album(id).await,
"playlist" => self.get_playlist(id).await,
"artist" => self.get_artist(id).await,
_ => LoadResult::Empty {},
};
}
LoadResult::Empty {}
}
async fn get_track(
&self,
_identifier: &str,
_routeplanner: Option<Arc<dyn crate::routeplanner::RoutePlanner>>,
) -> Option<BoxedTrack> {
None
}
}
}
pub mod reader {
use crate::protocol::tracks::TrackInfo;
pub fn decode_song_batch(buf: &[u8]) -> Vec<(String, TrackInfo)> {
let mut songs = Vec::new();
let mut reader = ProtoReader::new(buf);
while reader.has_more() {
let tag = match reader.read_uint32() {
Some(t) => t,
None => break,
};
let field_no = tag >> 3;
let wire_type = tag & 7;
if field_no == 2 {
if let Some(len) = reader.read_uint32() {
let end = reader.pos + len as usize;
let mut key = String::new();
let mut song = None;
while reader.pos < end {
let map_tag = match reader.read_uint32() {
Some(t) => t,
None => break,
};
match map_tag >> 3 {
1 => key = reader.read_string().unwrap_or_default(),
2 => {
if let Some(song_len) = reader.read_uint32() {
song = decode_song(reader.read_slice(song_len as usize));
}
}
_ => reader.skip_type(map_tag & 7),
}
}
if !key.is_empty()
&& let Some(s) = song
{
songs.push((key, s));
}
}
} else {
reader.skip_type(wire_type);
}
}
songs
}
fn decode_song(buf: &[u8]) -> Option<TrackInfo> {
let mut reader = ProtoReader::new(buf);
let mut id = String::new();
let mut title = String::new();
let mut artist = String::new();
let mut duration = 0.0f32;
let mut cover_art = String::new();
while reader.has_more() {
let tag = match reader.read_uint32() {
Some(t) => t,
None => break,
};
match tag >> 3 {
1 => id = reader.read_string().unwrap_or_default(),
2 => title = reader.read_string().unwrap_or_default(),
5 => artist = reader.read_string().unwrap_or_default(),
9 => duration = reader.read_float().unwrap_or(0.0),
10 => cover_art = reader.read_string().unwrap_or_default(),
_ => reader.skip_type(tag & 7),
}
}
if id.is_empty() || title.is_empty() {
return None;
}
let artwork_url = (!cover_art.is_empty())
.then(|| format!("https://artwork.anghcdn.co/?id={}&size=640", cover_art));
Some(TrackInfo {
identifier: id.clone(),
is_seekable: true,
author: if artist.is_empty() {
"Unknown Artist".to_owned()
} else {
artist
},
length: (duration * 1000.0).round() as u64,
is_stream: false,
position: 0,
title,
uri: Some(format!("https://play.anghami.com/song/{}", id)),
artwork_url,
isrc: None,
source_name: "anghami".to_owned(),
})
}
struct ProtoReader<'a> {
buf: &'a [u8],
pos: usize,
}
impl<'a> ProtoReader<'a> {
fn new(buf: &'a [u8]) -> Self {
Self { buf, pos: 0 }
}
fn has_more(&self) -> bool {
self.pos < self.buf.len()
}
fn read_uint32(&mut self) -> Option<u32> {
let mut value = 0u32;
let mut shift = 0;
while self.pos < self.buf.len() {
let b = self.buf[self.pos];
self.pos += 1;
value |= ((b & 0x7F) as u32) << shift;
if b < 0x80 {
return Some(value);
}
shift += 7;
if shift >= 35 {
break;
}
}
None
}
fn read_string(&mut self) -> Option<String> {
let len = self.read_uint32()? as usize;
if self.pos + len > self.buf.len() {
return None;
}
let s = String::from_utf8_lossy(&self.buf[self.pos..self.pos + len]).into_owned();
self.pos += len;
Some(s)
}
fn read_float(&mut self) -> Option<f32> {
if self.pos + 4 > self.buf.len() {
return None;
}
let mut b = [0u8; 4];
b.copy_from_slice(&self.buf[self.pos..self.pos + 4]);
self.pos += 4;
Some(f32::from_le_bytes(b))
}
fn read_slice(&mut self, len: usize) -> &[u8] {
let end = (self.pos + len).min(self.buf.len());
let slice = &self.buf[self.pos..end];
self.pos = end;
slice
}
fn skip_type(&mut self, wire_type: u32) {
match wire_type {
0 => {
let _ = self.read_uint32();
}
1 => self.pos += 8,
2 => {
if let Some(len) = self.read_uint32() {
self.pos += len as usize;
}
}
5 => self.pos += 4,
_ => {}
}
}
}
}
pub use manager::AnghamiSource;