Skip to main content

lavende_core/sources/
anghami.rs

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 = &current[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;