1pub mod manager {
2 use crate::{
3 config::AppConfig,
4 protocol::tracks::{LoadResult, PlaylistData, PlaylistInfo, Track, TrackInfo},
5 sources::{SourcePlugin, playable_track::BoxedTrack},
6 };
7 use async_trait::async_trait;
8 use regex::Regex;
9 use serde_json::{Value, json};
10 use std::{
11 sync::Arc,
12 time::{SystemTime, UNIX_EPOCH},
13 };
14 const BASE_URL: &str = "https://api.anghami.com/gateway.php";
15 pub struct AnghamiSource {
16 client: Arc<reqwest::Client>,
17 udid: String,
18 search_limit: usize,
19 url_regex: Regex,
20 }
21 impl AnghamiSource {
22 pub fn new(config: &AppConfig, client: Arc<reqwest::Client>) -> Result<Self, String> {
23 let ag_config = config.sources.anghami.clone().unwrap_or_default();
24 let udid = uuid::Uuid::new_v4().simple().to_string();
25 Ok(Self {
26 client,
27 udid,
28 search_limit: ag_config.search_limit,
29 url_regex: Regex::new(
30 r"^https?://(?:play\.|www\.)?anghami\.com/(?P<type>song|album|playlist|artist)/(?P<id>[0-9]+)",
31 )
32 .unwrap(),
33 })
34 }
35 fn unix_ts(&self) -> u64 {
36 SystemTime::now()
37 .duration_since(UNIX_EPOCH)
38 .unwrap_or_default()
39 .as_secs()
40 }
41 async fn api_request(&self, params: Vec<(&str, &str)>) -> Option<Value> {
42 let mut url = reqwest::Url::parse(BASE_URL).ok()?;
43 {
44 let mut q = url.query_pairs_mut();
45 for (k, v) in params {
46 q.append_pair(k, v);
47 }
48 }
49 let resp = self
50 .base_request(self.client.get(url))
51 .header("X-ANGH-UDID", &self.udid)
52 .header("X-ANGH-TS", self.unix_ts().to_string())
53 .send()
54 .await
55 .ok()?;
56 if !resp.status().is_success() {
57 return None;
58 }
59 resp.json::<Value>().await.ok()
60 }
61 fn build_artwork_url(json: &Value) -> Option<String> {
62 let art_id = json["coverArt"]
63 .as_str()
64 .or_else(|| json["AlbumArt"].as_str())
65 .or_else(|| json["cover"].as_str())
66 .filter(|s| !s.is_empty())?;
67 Some(format!(
68 "https://artwork.anghcdn.co/?id={}&size=640",
69 art_id
70 ))
71 }
72 fn parse_track(&self, json: &Value) -> Option<Track> {
73 let id = json["id"]
74 .as_str()
75 .map(|s| s.to_owned())
76 .or_else(|| json["id"].as_i64().map(|n| n.to_string()))
77 .filter(|s| !s.is_empty())?;
78 let title = json["title"]
79 .as_str()
80 .or_else(|| json["name"].as_str())
81 .filter(|s| !s.is_empty())?
82 .to_owned();
83 let author = json["artist"]
84 .as_str()
85 .or_else(|| json["artistName"].as_str())
86 .unwrap_or("Unknown Artist")
87 .to_owned();
88 let duration_secs = json["duration"]
89 .as_f64()
90 .or_else(|| {
91 json["duration"]
92 .as_str()
93 .and_then(|s| s.parse::<f64>().ok())
94 })
95 .unwrap_or(0.0);
96 let length = (duration_secs * 1000.0).round() as u64;
97 let artwork_url = Self::build_artwork_url(json);
98 let uri = format!("https://play.anghami.com/song/{}", id);
99 Some(Track::new(TrackInfo {
100 identifier: id,
101 is_seekable: true,
102 author,
103 length,
104 is_stream: false,
105 position: 0,
106 title,
107 uri: Some(uri),
108 artwork_url,
109 isrc: None,
110 source_name: "anghami".to_owned(),
111 }))
112 }
113 fn extract_tracks(&self, body: &Value) -> Vec<Track> {
114 if let Some(songbuffers) = body["songbuffers"].as_array() {
115 let mut song_map = std::collections::HashMap::new();
116 for buffer_base64 in songbuffers {
117 if let Some(s) = buffer_base64.as_str()
118 && let Ok(decoded) =
119 base64::Engine::decode(&base64::prelude::BASE64_STANDARD, s.as_bytes())
120 {
121 let songs = super::reader::decode_song_batch(&decoded);
122 for (id, track_info) in songs {
123 song_map.insert(id, Track::new(track_info));
124 }
125 }
126 }
127 if !song_map.is_empty() {
128 if let Some(order) = self.get_song_order(body) {
129 let tracks: Vec<Track> = order
130 .split(',')
131 .filter_map(|id| song_map.remove(id.trim()))
132 .collect();
133 if !tracks.is_empty() {
134 return tracks;
135 }
136 }
137 return song_map.into_values().collect();
138 }
139 }
140 if let Some(sections) = body["sections"].as_array() {
141 for section in sections {
142 let type_ = section["type"].as_str().unwrap_or("");
143 let group = section["group"].as_str().unwrap_or("");
144 if (type_ == "song" || group == "songs" || group == "album_songs")
145 && let Some(data) = section["data"].as_array()
146 {
147 let tracks: Vec<Track> =
148 data.iter().filter_map(|s| self.parse_track(s)).collect();
149 if !tracks.is_empty() {
150 return tracks;
151 }
152 }
153 }
154 }
155 for path in &["songs", "playlist/songs", "album/songs"] {
156 let parts: Vec<&str> = path.split('/').collect();
157 let mut current = body;
158 for part in parts {
159 current = ¤t[part];
160 }
161 if current.is_null() {
162 continue;
163 }
164 let mut song_map = std::collections::HashMap::new();
165 if let Some(obj) = current.as_object() {
166 for v in obj.values() {
167 let s = if !v["_attributes"].is_null() {
168 &v["_attributes"]
169 } else {
170 v
171 };
172 if let Some(track) = self.parse_track(s) {
173 song_map.insert(track.info.identifier.clone(), track);
174 }
175 }
176 }
177 if song_map.is_empty() {
178 continue;
179 }
180 let tracks: Vec<Track> = if let Some(order) = self.get_song_order(body) {
181 order
182 .split(',')
183 .filter_map(|id| song_map.remove(id.trim()))
184 .collect()
185 } else {
186 song_map.into_values().collect()
187 };
188 if !tracks.is_empty() {
189 return tracks;
190 }
191 }
192 body["data"]
193 .as_array()
194 .map(|data| data.iter().filter_map(|s| self.parse_track(s)).collect())
195 .unwrap_or_default()
196 }
197 fn get_song_order<'a>(&self, body: &'a Value) -> Option<&'a str> {
198 [
199 &body["songorder"],
200 &body["_attributes"]["songorder"],
201 &body["playlist"]["songorder"],
202 &body["album"]["songorder"],
203 ]
204 .iter()
205 .find_map(|v| v.as_str())
206 }
207 fn collection_title(body: &Value, type_hint: &str, default: &str) -> String {
208 let mut candidates = vec![
209 &body["title"],
210 &body["name"],
211 &body["playlist_name"],
212 &body["album_name"],
213 &body["albumTitle"],
214 &body["playlistTitle"],
215 &body["album_info"]["title"],
216 &body["playlist_info"]["title"],
217 ];
218 for t in &["album", "playlist", type_hint] {
219 candidates.push(&body[*t]["title"]);
220 candidates.push(&body[*t]["name"]);
221 candidates.push(&body[*t]["album_name"]);
222 candidates.push(&body[*t]["playlist_name"]);
223 candidates.push(&body[*t]["albumTitle"]);
224 candidates.push(&body[*t]["playlistTitle"]);
225 candidates.push(&body[*t]["_attributes"]["title"]);
226 candidates.push(&body[*t]["_attributes"]["name"]);
227 }
228 candidates.push(&body["_attributes"]["title"]);
229 candidates.push(&body["_attributes"]["name"]);
230 if let Some(title) = candidates
231 .into_iter()
232 .find_map(|v| v.as_str().filter(|s| !s.is_empty()))
233 {
234 return title.to_owned();
235 }
236 if let Some(sections) = body["sections"].as_array() {
237 for sec in sections {
238 if let Some(t) = sec["title"]
239 .as_str()
240 .or_else(|| sec["name"].as_str())
241 .filter(|s| !s.is_empty())
242 {
243 return t.to_owned();
244 }
245 }
246 }
247 default.to_owned()
248 }
249 async fn get_search(&self, query: &str) -> LoadResult {
250 if query.is_empty() {
251 return LoadResult::Empty {};
252 }
253 let body = match self
254 .api_request(vec![
255 ("type", "GETtabsearch"),
256 ("query", query),
257 ("web2", "true"),
258 ("language", "en"),
259 ("output", "json"),
260 ])
261 .await
262 {
263 Some(b) => b,
264 None => return LoadResult::Empty {},
265 };
266 let tracks: Vec<Track> = body["sections"]
267 .as_array()
268 .and_then(|secs| {
269 secs.iter().find(|s| {
270 s["type"].as_str() == Some("genericitem")
271 && s["group"].as_str() == Some("songs")
272 })
273 })
274 .and_then(|s| s["data"].as_array())
275 .map(|data| {
276 data.iter()
277 .take(self.search_limit)
278 .filter_map(|item| self.parse_track(item))
279 .collect()
280 })
281 .unwrap_or_default();
282 if tracks.is_empty() {
283 LoadResult::Empty {}
284 } else {
285 LoadResult::Search(tracks)
286 }
287 }
288 async fn get_song(&self, id: &str) -> LoadResult {
289 if let Some(body) = self
290 .api_request(vec![
291 ("type", "GETsongdata"),
292 ("songId", id),
293 ("output", "jsonhp"),
294 ])
295 .await
296 && body["status"].as_str() == Some("ok")
297 && let Some(track) = self.parse_track(&body)
298 {
299 return LoadResult::Track(track);
300 }
301 if let Some(body) = self
302 .api_request(vec![
303 ("type", "GETtabsearch"),
304 ("query", id),
305 ("web2", "true"),
306 ("language", "en"),
307 ("output", "json"),
308 ])
309 .await
310 && let Some(sections) = body["sections"].as_array()
311 {
312 for section in sections {
313 if let Some(data) = section["data"].as_array() {
314 let song = data.iter().find(|s| {
315 s["id"].as_str() == Some(id)
316 || s["id"].as_i64().map(|n| n.to_string()).as_deref() == Some(id)
317 });
318 if let Some(s) = song
319 && let Some(track) = self.parse_track(s)
320 {
321 return LoadResult::Track(track);
322 }
323 }
324 }
325 }
326 LoadResult::Empty {}
327 }
328 async fn get_album(&self, id: &str) -> LoadResult {
329 for buffered in &[false, true] {
330 let mut params = vec![
331 ("type", "GETalbumdata"),
332 ("albumId", id),
333 ("web2", "true"),
334 ("language", "en"),
335 ("output", "json"),
336 ];
337 if *buffered {
338 params.push(("buffered", "1"));
339 }
340 let body = match self.api_request(params).await {
341 Some(b) if b["error"].is_null() => b,
342 _ => continue,
343 };
344 let tracks = self.extract_tracks(&body);
345 if tracks.is_empty() {
346 continue;
347 }
348 let mut name = Self::collection_title(&body, "album", "Unknown Album");
349 if name == "Unknown Album" {
350 if let Some(first_album) = body["data"]
351 .as_array()
352 .and_then(|a| a.first())
353 .and_then(|t| t["album"].as_str().or_else(|| t["albumName"].as_str()))
354 {
355 name = first_album.to_owned();
356 } else if let Some(sections) = body["sections"].as_array() {
357 for sec in sections {
358 if let Some(first_album) = sec["data"]
359 .as_array()
360 .and_then(|a| a.first())
361 .and_then(|t| {
362 t["album"].as_str().or_else(|| t["albumName"].as_str())
363 })
364 {
365 name = first_album.to_owned();
366 break;
367 }
368 }
369 }
370 }
371 let artwork_url = tracks.first().and_then(|t| t.info.artwork_url.clone());
372 let author = tracks.first().map(|t| t.info.author.clone());
373 return LoadResult::Playlist(PlaylistData {
374 info: PlaylistInfo {
375 name,
376 selected_track: -1,
377 },
378 plugin_info: json!({
379 "type": "album",
380 "url": format!("https://play.anghami.com/album/{}", id),
381 "artworkUrl": artwork_url,
382 "author": author,
383 "totalTracks": tracks.len()
384 }),
385 tracks,
386 });
387 }
388 LoadResult::Empty {}
389 }
390 async fn get_playlist(&self, id: &str) -> LoadResult {
391 for buffered in &[false, true] {
392 let mut params = vec![
393 ("type", "GETplaylistdata"),
394 ("playlistId", id),
395 ("web2", "true"),
396 ("language", "en"),
397 ("output", "json"),
398 ];
399 if *buffered {
400 params.push(("buffered", "1"));
401 }
402 let body = match self.api_request(params).await {
403 Some(b) if b["error"].is_null() => b,
404 _ => continue,
405 };
406 let tracks = self.extract_tracks(&body);
407 if tracks.is_empty() {
408 continue;
409 }
410 let mut name = Self::collection_title(&body, "playlist", "Unknown Playlist");
411 if name == "Unknown Playlist"
412 && let Some(alt_name) = body["playlist"]["name"]
413 .as_str()
414 .or_else(|| body["playlist"]["title"].as_str())
415 {
416 name = alt_name.to_owned();
417 }
418 let artwork_url = tracks.first().and_then(|t| t.info.artwork_url.clone());
419 return LoadResult::Playlist(PlaylistData {
420 info: PlaylistInfo {
421 name,
422 selected_track: -1,
423 },
424 plugin_info: json!({
425 "type": "playlist",
426 "url": format!("https://play.anghami.com/playlist/{}", id),
427 "artworkUrl": artwork_url,
428 "totalTracks": tracks.len()
429 }),
430 tracks,
431 });
432 }
433 LoadResult::Empty {}
434 }
435 async fn get_artist(&self, id: &str) -> LoadResult {
436 let body = match self
437 .api_request(vec![
438 ("type", "GETartistprofile"),
439 ("artistId", id),
440 ("web2", "true"),
441 ("language", "en"),
442 ("output", "json"),
443 ])
444 .await
445 {
446 Some(b) => b,
447 None => return LoadResult::Empty {},
448 };
449 let tracks: Vec<Track> = if let Some(sections) = body["sections"].as_array() {
450 sections
451 .iter()
452 .find(|s| {
453 s["group"].as_str() == Some("songs") || s["type"].as_str() == Some("song")
454 })
455 .and_then(|s| s["data"].as_array())
456 .map(|data| data.iter().filter_map(|s| self.parse_track(s)).collect())
457 .unwrap_or_default()
458 } else {
459 body["data"]
460 .as_array()
461 .map(|data| data.iter().filter_map(|s| self.parse_track(s)).collect())
462 .unwrap_or_default()
463 };
464 if tracks.is_empty() {
465 return LoadResult::Empty {};
466 }
467 let name = body["name"]
468 .as_str()
469 .or_else(|| body["title"].as_str())
470 .unwrap_or("Unknown Artist")
471 .to_owned();
472 let artwork_url = tracks.first().and_then(|t| t.info.artwork_url.clone());
473 LoadResult::Playlist(PlaylistData {
474 info: PlaylistInfo {
475 name: format!("{}'s Top Tracks", name),
476 selected_track: -1,
477 },
478 plugin_info: json!({
479 "type": "artist",
480 "url": format!("https://play.anghami.com/artist/{}", id),
481 "artworkUrl": artwork_url,
482 "author": name,
483 "totalTracks": tracks.len()
484 }),
485 tracks,
486 })
487 }
488 pub fn base_request(&self, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
489 builder
490 .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")
491 .header("Referer", "https://play.anghami.com/")
492 .header("Origin", "https://play.anghami.com")
493 }
494 }
495 #[async_trait]
496 impl SourcePlugin for AnghamiSource {
497 fn name(&self) -> &str {
498 "anghami"
499 }
500 fn can_handle(&self, identifier: &str) -> bool {
501 self.search_prefixes()
502 .into_iter()
503 .any(|p| identifier.starts_with(p))
504 || self.url_regex.is_match(identifier)
505 }
506 fn search_prefixes(&self) -> Vec<&str> {
507 vec!["agsearch:"]
508 }
509 fn is_mirror(&self) -> bool {
510 true
511 }
512 async fn load(
513 &self,
514 identifier: &str,
515 _routeplanner: Option<Arc<dyn crate::routeplanner::RoutePlanner>>,
516 ) -> LoadResult {
517 if let Some(prefix) = self
518 .search_prefixes()
519 .into_iter()
520 .find(|p| identifier.starts_with(p))
521 {
522 return self.get_search(&identifier[prefix.len()..]).await;
523 }
524 if let Some(caps) = self.url_regex.captures(identifier) {
525 let type_ = caps.name("type").map(|m| m.as_str()).unwrap_or("");
526 let id = caps.name("id").map(|m| m.as_str()).unwrap_or("");
527 return match type_ {
528 "song" => self.get_song(id).await,
529 "album" => self.get_album(id).await,
530 "playlist" => self.get_playlist(id).await,
531 "artist" => self.get_artist(id).await,
532 _ => LoadResult::Empty {},
533 };
534 }
535 LoadResult::Empty {}
536 }
537 async fn get_track(
538 &self,
539 _identifier: &str,
540 _routeplanner: Option<Arc<dyn crate::routeplanner::RoutePlanner>>,
541 ) -> Option<BoxedTrack> {
542 None
543 }
544 }
545}
546pub mod reader {
547 use crate::protocol::tracks::TrackInfo;
548 pub fn decode_song_batch(buf: &[u8]) -> Vec<(String, TrackInfo)> {
549 let mut songs = Vec::new();
550 let mut reader = ProtoReader::new(buf);
551 while reader.has_more() {
552 let tag = match reader.read_uint32() {
553 Some(t) => t,
554 None => break,
555 };
556 let field_no = tag >> 3;
557 let wire_type = tag & 7;
558 if field_no == 2 {
559 if let Some(len) = reader.read_uint32() {
560 let end = reader.pos + len as usize;
561 let mut key = String::new();
562 let mut song = None;
563 while reader.pos < end {
564 let map_tag = match reader.read_uint32() {
565 Some(t) => t,
566 None => break,
567 };
568 match map_tag >> 3 {
569 1 => key = reader.read_string().unwrap_or_default(),
570 2 => {
571 if let Some(song_len) = reader.read_uint32() {
572 song = decode_song(reader.read_slice(song_len as usize));
573 }
574 }
575 _ => reader.skip_type(map_tag & 7),
576 }
577 }
578 if !key.is_empty()
579 && let Some(s) = song
580 {
581 songs.push((key, s));
582 }
583 }
584 } else {
585 reader.skip_type(wire_type);
586 }
587 }
588 songs
589 }
590 fn decode_song(buf: &[u8]) -> Option<TrackInfo> {
591 let mut reader = ProtoReader::new(buf);
592 let mut id = String::new();
593 let mut title = String::new();
594 let mut artist = String::new();
595 let mut duration = 0.0f32;
596 let mut cover_art = String::new();
597 while reader.has_more() {
598 let tag = match reader.read_uint32() {
599 Some(t) => t,
600 None => break,
601 };
602 match tag >> 3 {
603 1 => id = reader.read_string().unwrap_or_default(),
604 2 => title = reader.read_string().unwrap_or_default(),
605 5 => artist = reader.read_string().unwrap_or_default(),
606 9 => duration = reader.read_float().unwrap_or(0.0),
607 10 => cover_art = reader.read_string().unwrap_or_default(),
608 _ => reader.skip_type(tag & 7),
609 }
610 }
611 if id.is_empty() || title.is_empty() {
612 return None;
613 }
614 let artwork_url = (!cover_art.is_empty())
615 .then(|| format!("https://artwork.anghcdn.co/?id={}&size=640", cover_art));
616 Some(TrackInfo {
617 identifier: id.clone(),
618 is_seekable: true,
619 author: if artist.is_empty() {
620 "Unknown Artist".to_owned()
621 } else {
622 artist
623 },
624 length: (duration * 1000.0).round() as u64,
625 is_stream: false,
626 position: 0,
627 title,
628 uri: Some(format!("https://play.anghami.com/song/{}", id)),
629 artwork_url,
630 isrc: None,
631 source_name: "anghami".to_owned(),
632 })
633 }
634 struct ProtoReader<'a> {
635 buf: &'a [u8],
636 pos: usize,
637 }
638 impl<'a> ProtoReader<'a> {
639 fn new(buf: &'a [u8]) -> Self {
640 Self { buf, pos: 0 }
641 }
642 fn has_more(&self) -> bool {
643 self.pos < self.buf.len()
644 }
645 fn read_uint32(&mut self) -> Option<u32> {
646 let mut value = 0u32;
647 let mut shift = 0;
648 while self.pos < self.buf.len() {
649 let b = self.buf[self.pos];
650 self.pos += 1;
651 value |= ((b & 0x7F) as u32) << shift;
652 if b < 0x80 {
653 return Some(value);
654 }
655 shift += 7;
656 if shift >= 35 {
657 break;
658 }
659 }
660 None
661 }
662 fn read_string(&mut self) -> Option<String> {
663 let len = self.read_uint32()? as usize;
664 if self.pos + len > self.buf.len() {
665 return None;
666 }
667 let s = String::from_utf8_lossy(&self.buf[self.pos..self.pos + len]).into_owned();
668 self.pos += len;
669 Some(s)
670 }
671 fn read_float(&mut self) -> Option<f32> {
672 if self.pos + 4 > self.buf.len() {
673 return None;
674 }
675 let mut b = [0u8; 4];
676 b.copy_from_slice(&self.buf[self.pos..self.pos + 4]);
677 self.pos += 4;
678 Some(f32::from_le_bytes(b))
679 }
680 fn read_slice(&mut self, len: usize) -> &[u8] {
681 let end = (self.pos + len).min(self.buf.len());
682 let slice = &self.buf[self.pos..end];
683 self.pos = end;
684 slice
685 }
686 fn skip_type(&mut self, wire_type: u32) {
687 match wire_type {
688 0 => {
689 let _ = self.read_uint32();
690 }
691 1 => self.pos += 8,
692 2 => {
693 if let Some(len) = self.read_uint32() {
694 self.pos += len as usize;
695 }
696 }
697 5 => self.pos += 4,
698 _ => {}
699 }
700 }
701 }
702}
703pub use manager::AnghamiSource;